feat(tgu-ui): вкладка «Слоты» — расчёт покрытия и бронирование слотов заявки
- новая вкладка: заявки интервала на карте, выбор заявки кликом/инспектором, расчёт слотов покрытия, бронирование и снятие брони - выбор заявки без выпадающего списка (не масштабируется на ~10к): все заявки на карте, панель справа с кнопкой «Выбрать», имя выбранной заявки в тулбаре, кнопка «Сброс», фильтр заявок по интервалу - бронь отмечаем по ответу POST booking/request (by-request отдаёт слот в чужом cycle-фрейме — ключ не совпадал, бейдж «бронь» не появлялся) - подсветка и автоскролл активной строки по клику на слот (ручной scrollBy) - меню выбора при плотном наложении слотов (SlotPickMenu); контур заявки pickable:false, чтобы не «съедать» меню; панель не закрывается при выборе - фильтр отображения по суткам (SlotDaySelector) - gateway: маршрут slots (/api/pcp-slots/**) - карточка полосы обзора (swath): КА и виток в инспекторе
This commit is contained in:
@@ -57,6 +57,12 @@ spring:
|
|||||||
- Path=/api/pcp-complex/**
|
- Path=/api/pcp-complex/**
|
||||||
filters:
|
filters:
|
||||||
- StripPrefix=2
|
- StripPrefix=2
|
||||||
|
- id: slots
|
||||||
|
uri: ${pcp.services.slots}
|
||||||
|
predicates:
|
||||||
|
- Path=/api/pcp-slots/**
|
||||||
|
filters:
|
||||||
|
- StripPrefix=2
|
||||||
# --- Станции: путь СОХРАНЯЕМ (в vite было без rewrite) — без StripPrefix.
|
# --- Станции: путь СОХРАНЯЕМ (в vite было без rewrite) — без StripPrefix.
|
||||||
- id: stations
|
- id: stations
|
||||||
uri: ${pcp.services.stations}
|
uri: ${pcp.services.stations}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
> Задачи #12–#25 заведены по результатам аудита. Подробности, обоснование и рекомендации
|
> Задачи #12–#25 заведены по результатам аудита. Подробности, обоснование и рекомендации
|
||||||
> по каждой — в [аудит-проекта-2026-06-06.md](аудит-проекта-2026-06-06.md) (ссылки на коды находок B/F/R/C).
|
> по каждой — в [аудит-проекта-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** — Отображать трассы полос исходя из возможности съёмки КА. Для оптики показывать только участки, попадающие в допустимый угол Солнца.
|
- [ ] **#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** — Показ забронированных слотов на вкладках «Карта» и «Редактор». Включает **подгрузку** данных по забронированным слотам с бэкенда (фетч), а не только отрисовку уже имеющихся. На вкладке «Редактор» они должны быть другого цвета и выделяться.
|
- [ ] **#3** — Показ забронированных слотов на вкладках «Карта» и «Редактор». Включает **подгрузку** данных по забронированным слотам с бэкенда (фетч), а не только отрисовку уже имеющихся. На вкладке «Редактор» они должны быть другого цвета и выделяться.
|
||||||
|
|
||||||
### Группировка
|
### Группировка
|
||||||
@@ -38,6 +38,7 @@ _Обновлено: 2026-06-10_
|
|||||||
|
|
||||||
### Бэкенд / инфраструктура
|
### Бэкенд / инфраструктура
|
||||||
- [ ] **#7** — Перевести сервис станций на ordinis (по примеру получения аппаратов в сервисе tgu).
|
- [ ] **#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» точки трассы с другим номером выпадают и пропадает орбитальный контекст. Свести нумерацию витков к единому источнику (или маппинг на границе), чтобы трасса и маршрут согласовывались по витку.
|
- [ ] **#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 чист.
|
- [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`) и удаление кода — **отложено** до проверки репойнта на живом стенде. Пока сервис задеплоен, но без маршрутов.
|
- [ ] Шаг 8: вывод сервиса из эксплуатации (compose/helm/CI/config-repo/реестр `pcp.services.ui`) и удаление кода — **отложено** до проверки репойнта на живом стенде. Пока сервис задеплоен, но без маршрутов.
|
||||||
|
|
||||||
### Слоты (вкладка «Заявка» и «Редактор»)
|
### Слоты (вкладка «Слоты»)
|
||||||
- [ ] **#41** — Запрос слотового покрытия по заявке. На вкладке «Заявка» (и в «Редакторе») запрашивать у slots-service слотовое покрытие заявки — какие слоты КА покрывают AOI заявки на её интервале. Источник — slots-service (минуя умирающий `pcp-ui-service`, ср. #40). Уточнить контракт ответа (слоты + связь с ячейками/покрытием заявки) и где он отрисовывается.
|
- [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-*`._
|
||||||
- [ ] **#42** — Бронирование слотов по заявке. С вкладки «Заявка» (и/или «Редактор») бронировать выбранные слоты под заявку через slots-service. Связано с показом забронированных слотов (#3) и пересчётом комплексного плана при бронировании (#6). Уточнить: единичное/пакетное бронирование, идемпотентность, что возвращается после брони.
|
- [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) — последнее пока на бэкенде не триггерится. Уточнить при необходимости идемпотентность повторной брони._
|
||||||
- [ ] **#43** — Уточнение статуса слота. На вкладке «Заявка» (и «Редактор») показывать/обновлять актуальный статус слота (свободен / забронирован / занят по заложенному плану). Учитывать, что слоты занимаются по `LAID_IN`, а не по `ACCEPT` (ср. #10). Источник статусов — slots-service; согласовать с #41/#42.
|
- [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** — Ошибка при попытке принять план на вкладке «Планы».
|
- [ ] **#8** — Ошибка при попытке принять план на вкладке «Планы».
|
||||||
@@ -105,4 +113,5 @@ _Источник и рекомендации: [аудит-сервис-зада
|
|||||||
|
|
||||||
|
|
||||||
Добавил вручную:
|
Добавил вручную:
|
||||||
- убрать кнопки + - квадрат с вкладки редактор и карта с карты ибо ничего не делают
|
- ~~убрать кнопки + - квадрат с вкладки редактор и карта с карты ибо ничего не делают~~ _Решено иначе (2026-06-11): кнопки не убрали, а починили — они не срабатывали из-за `setPointerCapture` на контейнере карты в `onPointerDown` (перехватывал жест, и клик по кнопке внутри карты не доходил). Добавлен гард: нажатие в области `.tgu-2d-map__zoom` не запускает панорамирование/захват, плюс `stopPropagation` на кнопках. Теперь «+/−» (зум) и «□» (вернуть обзор) работают на всех вкладках с картой._
|
||||||
|
- [x] **Линейка на карте** _(2026-06-11): инструмент измерения расстояния на всех вкладках с картой (Карта, Заявки, Комплексный план, Редактор, Слоты — общий `Tgu2DMapView`). Иконка-линейка в правом нижнем углу рядом с «+/−/□», включается/выключается кликом (тумблер). В активном режиме клик по карте ставит вершины замера, рисуется пунктирная ломаная с подписями накопленного расстояния по дуге большого круга (haversine), в бейдже — итоговая длина; «Сброс» очищает замер, повторный клик по иконке выключает. Реализовано целиком в `Tgu2DMapView` (состояние `rulerActive`/`rulerPoints`), стили `.tgu-2d-map__ruler*`._
|
||||||
@@ -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<string> {
|
||||||
|
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<SlotDTO[]> {
|
||||||
|
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<SlotDTO[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Бронирует выбранные слоты под заявку (slots-service: POST /api/slots/booking/request).
|
||||||
|
* @param requestId Идентификатор заявки.
|
||||||
|
* @param slots Список слотов (КА, номер слота, цикл).
|
||||||
|
*
|
||||||
|
* @returns Забронированные слоты.
|
||||||
|
*/
|
||||||
|
export async function bookRequestSlots(
|
||||||
|
requestId: string,
|
||||||
|
slots: SlotBookingRequest[],
|
||||||
|
): Promise<BookedSlotDTO[]> {
|
||||||
|
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<BookedSlotDTO[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Снимает все брони заявки (slots-service: DELETE /api/slots/booking/request).
|
||||||
|
* @param requestId Идентификатор заявки.
|
||||||
|
*
|
||||||
|
* @returns Сколько броней снято.
|
||||||
|
*/
|
||||||
|
export async function cancelRequestBooking(requestId: string): Promise<number> {
|
||||||
|
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<SlotDTO[]> {
|
||||||
|
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<SlotDTO[]>
|
||||||
|
}
|
||||||
@@ -672,6 +672,17 @@ export function MapInfoPanel({ selection, requestItems, onClose }: MapInfoPanelP
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{selection.type === "polygon" && selection.layer === "swath" && (
|
||||||
|
<section className="tgu-editor-panel__section">
|
||||||
|
<div className="tgu-editor-eyebrow">{selection.name}</div>
|
||||||
|
<dl className="tgu-map-request-details__grid">
|
||||||
|
{selection.spacecraftId && (<><dt>КА</dt><dd className="mono">{selection.spacecraftId}</dd></>)}
|
||||||
|
<dt>Виток</dt>
|
||||||
|
<dd className="mono">{selection.revolution ?? "—"}</dd>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -755,7 +755,10 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
|||||||
planEnd: toLocalIso(selectedPlan.endMs)
|
planEnd: toLocalIso(selectedPlan.endMs)
|
||||||
});
|
});
|
||||||
setSurveyRoute(savedRoute);
|
setSurveyRoute(savedRoute);
|
||||||
void refreshDraftModes(draftId);
|
// Ждём перечитывания planModes ДО снятия editingWorkId ниже: иначе на миг editingModeId
|
||||||
|
// перестаёт исключать старый режим из сцены, а свежие planModes ещё не пришли — и старый
|
||||||
|
// контур мелькает на карте, пока не сделаешь что-нибудь ещё.
|
||||||
|
await refreshDraftModes(draftId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setRouteError(error instanceof Error ? error.message : "Не удалось сохранить маршрут съёмки.");
|
setRouteError(error instanceof Error ? error.message : "Не удалось сохранить маршрут съёмки.");
|
||||||
return;
|
return;
|
||||||
@@ -794,6 +797,9 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
|||||||
setTarget(undefined);
|
setTarget(undefined);
|
||||||
setTargetPickActive(false);
|
setTargetPickActive(false);
|
||||||
setSelectedShootPass(undefined);
|
setSelectedShootPass(undefined);
|
||||||
|
// Гасим превью окна построения сразу: сохранённый маршрут уже виден из planModes/черновика.
|
||||||
|
setSurveyRoute(undefined);
|
||||||
|
setRouteWindow(undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
const stationNameById = useCallback(
|
const stationNameById = useCallback(
|
||||||
@@ -852,8 +858,9 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
|||||||
});
|
});
|
||||||
savedDropId = savedDrop.id;
|
savedDropId = savedDrop.id;
|
||||||
savedDropVolumeMb = savedDrop.volumeMb;
|
savedDropVolumeMb = savedDrop.volumeMb;
|
||||||
// Перечитываем включения DRAFT (источник истины) и ревалидируем.
|
// Перечитываем включения DRAFT (источник истины) и ревалидируем. Ждём ДО снятия
|
||||||
void refreshDraftModes(draftId);
|
// editingWorkId ниже — иначе старая трасса сброса мелькает, пока planModes не обновились.
|
||||||
|
await refreshDraftModes(draftId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setDropError(error instanceof Error ? error.message : "Не удалось сохранить сброс.");
|
setDropError(error instanceof Error ? error.message : "Не удалось сохранить сброс.");
|
||||||
return;
|
return;
|
||||||
@@ -1630,6 +1637,8 @@ function buildEditorMapScene(
|
|||||||
kind: "swath",
|
kind: "swath",
|
||||||
layer: "swath",
|
layer: "swath",
|
||||||
points: [...leftEdge, ...rightEdge],
|
points: [...leftEdge, ...rightEdge],
|
||||||
|
spacecraftId,
|
||||||
|
revolution,
|
||||||
zIndex: zIndex++
|
zIndex: zIndex++
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ type MapObjectInspectorProps = {
|
|||||||
requestItems?: RequestMapItem[];
|
requestItems?: RequestMapItem[];
|
||||||
/** Ячейки карты приоритетов — для карточки ячейки тепловой карты (layer "heatmap"). */
|
/** Ячейки карты приоритетов — для карточки ячейки тепловой карты (layer "heatmap"). */
|
||||||
priorityCells?: PriorityCell[];
|
priorityCells?: PriorityCell[];
|
||||||
|
/** Если задан — в карточке заявки появляется кнопка «Выбрать» (вкладка «Слоты»). */
|
||||||
|
onSelectRequest?: (requestId: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function MapObjectInspector({
|
export function MapObjectInspector({
|
||||||
@@ -37,6 +39,7 @@ export function MapObjectInspector({
|
|||||||
modes = [],
|
modes = [],
|
||||||
requestItems = [],
|
requestItems = [],
|
||||||
priorityCells = [],
|
priorityCells = [],
|
||||||
|
onSelectRequest,
|
||||||
}: MapObjectInspectorProps) {
|
}: MapObjectInspectorProps) {
|
||||||
const renderCard = (selection: Tgu2DMapSelection): ReactNode => {
|
const renderCard = (selection: Tgu2DMapSelection): ReactNode => {
|
||||||
if (selection.type === "marker") {
|
if (selection.type === "marker") {
|
||||||
@@ -49,7 +52,7 @@ export function MapObjectInspector({
|
|||||||
case "complexPlan":
|
case "complexPlan":
|
||||||
return <ModeCard selection={selection} modes={modes} />;
|
return <ModeCard selection={selection} modes={modes} />;
|
||||||
case "requests":
|
case "requests":
|
||||||
return <RequestCard selection={selection} requestItems={requestItems} />;
|
return <RequestCard selection={selection} requestItems={requestItems} onSelectRequest={onSelectRequest} />;
|
||||||
case "heatmap":
|
case "heatmap":
|
||||||
return <CellCard selection={selection} priorityCells={priorityCells} />;
|
return <CellCard selection={selection} priorityCells={priorityCells} />;
|
||||||
case "planWorks":
|
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;
|
const item = selection.requestId ? requestItems.find((r) => r.id === selection.requestId) : undefined;
|
||||||
if (!item) return <div className="tgu-editor-panel__hint">{selection.name || selection.id}</div>;
|
if (!item) return <div className="tgu-editor-panel__hint">{selection.name || selection.id}</div>;
|
||||||
return (
|
return (
|
||||||
@@ -156,6 +167,17 @@ function RequestCard({ selection, requestItems }: { selection: PolygonSelection;
|
|||||||
<dt>Приоритет передачи</dt>
|
<dt>Приоритет передачи</dt>
|
||||||
<dd>{item.highPriorityTransmit ? "Да" : "Нет"}</dd>
|
<dd>{item.highPriorityTransmit ? "Да" : "Нет"}</dd>
|
||||||
</dl>
|
</dl>
|
||||||
|
{onSelectRequest && (
|
||||||
|
<div className="tgu-editor-panel__actions">
|
||||||
|
<button
|
||||||
|
className="tgu-button tgu-button--primary"
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelectRequest(item.id)}
|
||||||
|
>
|
||||||
|
Выбрать
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,6 +112,10 @@ export function Tgu2DMapView({
|
|||||||
const [tooltip, setTooltip] = useState<TooltipState>();
|
const [tooltip, setTooltip] = useState<TooltipState>();
|
||||||
// Гео-координаты под курсором — показываем по центру внизу карты.
|
// Гео-координаты под курсором — показываем по центру внизу карты.
|
||||||
const [cursorGeo, setCursorGeo] = useState<GeoPoint>();
|
const [cursorGeo, setCursorGeo] = useState<GeoPoint>();
|
||||||
|
// Линейка: включается кнопкой в правом нижнем углу; в активном режиме клик по карте
|
||||||
|
// добавляет вершину замера, а наложение рисует ломаную с накопленным расстоянием.
|
||||||
|
const [rulerActive, setRulerActive] = useState(false);
|
||||||
|
const [rulerPoints, setRulerPoints] = useState<GeoPoint[]>([]);
|
||||||
const selectedPolygonRef = useRef<MapPolygon | undefined>(undefined);
|
const selectedPolygonRef = useRef<MapPolygon | undefined>(undefined);
|
||||||
const selectedLineRef = useRef<MapLine | undefined>(undefined);
|
const selectedLineRef = useRef<MapLine | undefined>(undefined);
|
||||||
const activePolygons = useMemo(
|
const activePolygons = useMemo(
|
||||||
@@ -256,6 +260,10 @@ export function Tgu2DMapView({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const onPointerDown = (event: React.PointerEvent) => {
|
const onPointerDown = (event: React.PointerEvent) => {
|
||||||
|
// Нажатие на кнопки управления картой (зум, линейка, «Сброс») не должно запускать перетаскивание
|
||||||
|
// и захват указателя: setPointerCapture на контейнере иначе перехватывает жест, и кнопки внутри
|
||||||
|
// карты перестают «кликаться» (родная причина того, что +/−/□ и «Сброс» не срабатывали).
|
||||||
|
if ((event.target as HTMLElement).closest("button")) return;
|
||||||
const screenPoint = screenPointFromEvent(event);
|
const screenPoint = screenPointFromEvent(event);
|
||||||
hasDraggedRef.current = false;
|
hasDraggedRef.current = false;
|
||||||
dragRef.current = {
|
dragRef.current = {
|
||||||
@@ -303,6 +311,14 @@ export function Tgu2DMapView({
|
|||||||
wrapperRef.current?.releasePointerCapture(event.pointerId);
|
wrapperRef.current?.releasePointerCapture(event.pointerId);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Линейка: переключаем режим; при выключении замер очищаем.
|
||||||
|
const toggleRuler = () => {
|
||||||
|
setRulerActive((active) => {
|
||||||
|
if (active) setRulerPoints([]);
|
||||||
|
return !active;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// Применить выбор конкретного объекта (из меню выбора либо при единственном попадании):
|
// Применить выбор конкретного объекта (из меню выбора либо при единственном попадании):
|
||||||
// выставляем внутреннюю подсветку и сообщаем наверх через onObjectSelect.
|
// выставляем внутреннюю подсветку и сообщаем наверх через onObjectSelect.
|
||||||
const applyEditorHit = useCallback((hit: EditorHit) => {
|
const applyEditorHit = useCallback((hit: EditorHit) => {
|
||||||
@@ -313,16 +329,24 @@ export function Tgu2DMapView({
|
|||||||
}, [onObjectSelect]);
|
}, [onObjectSelect]);
|
||||||
|
|
||||||
// Клик в редакторе: собираем ВСЕ интерактивные объекты под точкой (заявки, маршруты съёмки,
|
// Клик в редакторе: собираем ВСЕ интерактивные объекты под точкой (заявки, маршруты съёмки,
|
||||||
// сбросы, станции). 0 → возможно полоса обзора под низом; 1 → выбираем сразу;
|
// сбросы, станции). 0 → смотрим на полосы обзора; 1 → выбираем сразу;
|
||||||
// ≥2 → отдаём список наверх (оператор выбирает нужный в правой панели).
|
// ≥2 → отдаём список наверх (оператор выбирает нужный в правой панели).
|
||||||
const handleEditorClick = useCallback((screenPoint: ScreenPoint) => {
|
const handleEditorClick = useCallback((screenPoint: ScreenPoint) => {
|
||||||
const geoPoint = projection.unproject(screenPoint);
|
const geoPoint = projection.unproject(screenPoint);
|
||||||
const hits: EditorHit[] = [];
|
const hits: EditorHit[] = [];
|
||||||
|
// Полосы обзора собираем отдельно: они почти всегда лежат под кликом и не должны
|
||||||
|
// превращать выбор заявки/маршрута в список. Их список показываем, только когда под точкой
|
||||||
|
// нет другого интерактивного объекта, а самих полос несколько (наложение соседних витков).
|
||||||
|
const swathHits: EditorHit[] = [];
|
||||||
|
|
||||||
const marker = hitTestMarkers(activeMarkers, screenPoint, projection);
|
const marker = hitTestMarkers(activeMarkers, screenPoint, projection);
|
||||||
if (marker) hits.push({ kind: "marker", marker, selection: markerToSelection(marker) });
|
if (marker) hits.push({ kind: "marker", marker, selection: markerToSelection(marker) });
|
||||||
|
|
||||||
for (const polygon of hitTestAllPolygons(polygonIndex, geoPoint)) {
|
for (const polygon of hitTestAllPolygons(polygonIndex, geoPoint)) {
|
||||||
|
if (polygon.layer === "swath") {
|
||||||
|
swathHits.push({ kind: "polygon", polygon, selection: polygonToSelection(polygon) });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
polygon.layer !== "requests" &&
|
polygon.layer !== "requests" &&
|
||||||
polygon.layer !== "planWorks" &&
|
polygon.layer !== "planWorks" &&
|
||||||
@@ -340,19 +364,38 @@ export function Tgu2DMapView({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (hits.length >= 2 && onObjectsAtPoint) {
|
if (hits.length >= 2 && onObjectsAtPoint) {
|
||||||
// Несколько объектов — внутреннюю подсветку снимаем (её ведёт правая панель через selectedObjectId).
|
// Несколько интерактивных объектов — показываем список. В него добавляем и полосы обзора,
|
||||||
|
// лежащие под точкой, чтобы их данные тоже были видны. Порядок: маршрут → заявка → полоса.
|
||||||
|
const combined = [...hits, ...swathHits].sort((a, b) => editorHitRank(a) - editorHitRank(b));
|
||||||
|
// Внутреннюю подсветку снимаем (её ведёт правая панель через selectedObjectId).
|
||||||
selectedPolygonRef.current = undefined;
|
selectedPolygonRef.current = undefined;
|
||||||
selectedMarkerRef.current = undefined;
|
selectedMarkerRef.current = undefined;
|
||||||
selectedLineRef.current = undefined;
|
selectedLineRef.current = undefined;
|
||||||
onObjectsAtPoint(hits.map((hit) => hit.selection));
|
onObjectsAtPoint(combined.map((hit) => hit.selection));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (hits.length >= 1) {
|
if (hits.length === 1) {
|
||||||
|
// Единственный интерактивный объект — выбираем сразу (полосы обзора лежат под кликом почти
|
||||||
|
// всегда, поэтому ради одного объекта список не показываем — иначе любой клик стал бы списком).
|
||||||
applyEditorHit(hits[0]);
|
applyEditorHit(hits[0]);
|
||||||
return;
|
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);
|
const fallback = hitTestPolygons(polygonIndex, geoPoint);
|
||||||
selectedPolygonRef.current = fallback;
|
selectedPolygonRef.current = fallback;
|
||||||
selectedMarkerRef.current = undefined;
|
selectedMarkerRef.current = undefined;
|
||||||
@@ -363,6 +406,11 @@ export function Tgu2DMapView({
|
|||||||
const onClick = (event: React.MouseEvent) => {
|
const onClick = (event: React.MouseEvent) => {
|
||||||
if (hasDraggedRef.current) return;
|
if (hasDraggedRef.current) return;
|
||||||
const screenPoint = screenPointFromEvent(event);
|
const screenPoint = screenPointFromEvent(event);
|
||||||
|
if (rulerActive) {
|
||||||
|
// Режим линейки: клик добавляет вершину замера, выбор объектов не трогаем.
|
||||||
|
setRulerPoints((points) => [...points, projection.unproject(screenPoint)]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (drawMode) {
|
if (drawMode) {
|
||||||
onDrawAddPoint?.(projection.unproject(screenPoint));
|
onDrawAddPoint?.(projection.unproject(screenPoint));
|
||||||
return;
|
return;
|
||||||
@@ -449,8 +497,22 @@ export function Tgu2DMapView({
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectedPolygon = selectedPolygonRef.current;
|
// Внутренние ref-выделения (клик по карте) могут указывать на объект, которого уже нет в сцене —
|
||||||
const selectedMarker = selectedMarkerRef.current;
|
// например, контур съёмки, скрытый при входе в режим правки (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 externalSelectedPolygon = useMemo(() => {
|
||||||
const id = selectedRequestPolygonId ?? selectedObjectId;
|
const id = selectedRequestPolygonId ?? selectedObjectId;
|
||||||
return id ? activePolygons.find((p) => p.id === id) : undefined;
|
return id ? activePolygons.find((p) => p.id === id) : undefined;
|
||||||
@@ -461,7 +523,7 @@ export function Tgu2DMapView({
|
|||||||
[selectedObjectId, scene.lines]
|
[selectedObjectId, scene.lines]
|
||||||
);
|
);
|
||||||
// Линии под подсветкой: выбранная кликом на карте (внутренняя) и заданная извне (по selectedObjectId).
|
// Линии под подсветкой: выбранная кликом на карте (внутренняя) и заданная извне (по selectedObjectId).
|
||||||
const overlayLines = uniqueLines([selectedLineRef.current, externalSelectedLine]);
|
const overlayLines = uniqueLines([selectedLine, externalSelectedLine]);
|
||||||
const externalHoveredPolygon = useMemo(
|
const externalHoveredPolygon = useMemo(
|
||||||
() => hoveredRequestPolygonId ? activePolygons.find((p) => p.id === hoveredRequestPolygonId) : undefined,
|
() => hoveredRequestPolygonId ? activePolygons.find((p) => p.id === hoveredRequestPolygonId) : undefined,
|
||||||
[hoveredRequestPolygonId, activePolygons]
|
[hoveredRequestPolygonId, activePolygons]
|
||||||
@@ -469,9 +531,21 @@ export function Tgu2DMapView({
|
|||||||
const overlayPolygons = uniquePolygons([externalSelectedPolygon, selectedPolygon, externalHoveredPolygon, hoverRef.current]);
|
const overlayPolygons = uniquePolygons([externalSelectedPolygon, selectedPolygon, externalHoveredPolygon, hoverRef.current]);
|
||||||
const overlayMarkers = uniqueMarkers([selectedMarker, hoverMarkerRef.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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`tgu-2d-map ${pickMode || drawMode ? "is-picking" : ""}`}
|
className={`tgu-2d-map ${pickMode || drawMode || rulerActive ? "is-picking" : ""}`}
|
||||||
ref={wrapperRef}
|
ref={wrapperRef}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
onPointerDown={onPointerDown}
|
onPointerDown={onPointerDown}
|
||||||
@@ -579,6 +653,28 @@ export function Tgu2DMapView({
|
|||||||
</g>
|
</g>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
{rulerPoints.length > 0 && (() => {
|
||||||
|
const pts = unwrapX(rulerPoints.map((point) => projection.project(point)), projection.worldSize);
|
||||||
|
return (
|
||||||
|
<g className="tgu-2d-map__ruler">
|
||||||
|
{pts.length >= 2 && (
|
||||||
|
<polyline
|
||||||
|
className="tgu-2d-map__ruler-line"
|
||||||
|
points={pts.map((p) => `${p.x},${p.y}`).join(" ")}
|
||||||
|
fill="none"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{pts.map((p, i) => (
|
||||||
|
<g key={i}>
|
||||||
|
<circle cx={p.x} cy={p.y} r={4} className="tgu-2d-map__ruler-vertex" />
|
||||||
|
<text x={p.x + 8} y={p.y - 8} className="tgu-2d-map__ruler-label">
|
||||||
|
{i === 0 ? "0" : formatDistanceKm(rulerCumKm[i])}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</svg>
|
</svg>
|
||||||
{tooltip && (
|
{tooltip && (
|
||||||
<div className="tgu-2d-map__tooltip" style={{ left: tooltip.x, top: tooltip.y }}>
|
<div className="tgu-2d-map__tooltip" style={{ left: tooltip.x, top: tooltip.y }}>
|
||||||
@@ -588,15 +684,69 @@ export function Tgu2DMapView({
|
|||||||
)}
|
)}
|
||||||
{cursorGeo && <div className="tgu-2d-map__coords">{formatLatLon(cursorGeo)}</div>}
|
{cursorGeo && <div className="tgu-2d-map__coords">{formatLatLon(cursorGeo)}</div>}
|
||||||
<div className="tgu-2d-map__attribution">© <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener noreferrer">OpenStreetMap</a> contributors · © <a href="https://carto.com/attributions" target="_blank" rel="noopener noreferrer">CARTO</a></div>
|
<div className="tgu-2d-map__attribution">© <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener noreferrer">OpenStreetMap</a> contributors · © <a href="https://carto.com/attributions" target="_blank" rel="noopener noreferrer">CARTO</a></div>
|
||||||
|
{rulerActive && (
|
||||||
|
<div className="tgu-2d-map__ruler-total">
|
||||||
|
<span className="tgu-2d-map__ruler-total-label">Линейка</span>
|
||||||
|
<span className="tgu-2d-map__ruler-total-value">
|
||||||
|
{rulerPoints.length < 2 ? "кликайте по карте" : formatDistanceKm(rulerTotalKm)}
|
||||||
|
</span>
|
||||||
|
{rulerPoints.length > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="tgu-2d-map__ruler-reset"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
setRulerPoints([]);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Сброс
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="tgu-2d-map__zoom">
|
<div className="tgu-2d-map__zoom">
|
||||||
<button type="button" onClick={() => setView((current) => ({ ...current, zoom: Math.min(8, current.zoom + 0.5) }))}>+</button>
|
<button
|
||||||
<button type="button" onClick={() => setView((current) => ({ ...current, zoom: Math.max(0.8, current.zoom - 0.5) }))}>-</button>
|
type="button"
|
||||||
<button type="button" onClick={() => setView(INITIAL_VIEW)}>□</button>
|
className={`tgu-2d-map__zoom-ruler${rulerActive ? " is-active" : ""}`}
|
||||||
|
title="Линейка — измерение расстояния по карте"
|
||||||
|
aria-pressed={rulerActive}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
toggleRuler();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
<rect x="2.5" y="8.5" width="19" height="7" rx="1" />
|
||||||
|
<path d="M6.5 8.5v3M10 8.5v4M13.5 8.5v3M17 8.5v4" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={(event) => { event.stopPropagation(); setView((current) => ({ ...current, zoom: Math.min(8, current.zoom + 0.5) })); }}>+</button>
|
||||||
|
<button type="button" onClick={(event) => { event.stopPropagation(); setView((current) => ({ ...current, zoom: Math.max(0.8, current.zoom - 0.5) })); }}>-</button>
|
||||||
|
<button type="button" title="Вернуть обзор" onClick={(event) => { event.stopPropagation(); setView(INITIAL_VIEW); }}>□</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Расстояние между двумя гео-точками по дуге большого круга (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 {
|
function formatLatLon(point: GeoPoint): string {
|
||||||
const lat = point.lat;
|
const lat = point.lat;
|
||||||
@@ -695,6 +845,26 @@ type EditorHit =
|
|||||||
| { kind: "polygon"; polygon: MapPolygon; selection: Tgu2DMapSelection }
|
| { kind: "polygon"; polygon: MapPolygon; selection: Tgu2DMapSelection }
|
||||||
| { kind: "line"; line: MapLine; 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 {
|
function markerToSelection(marker: MapMarker): Tgu2DMapSelection {
|
||||||
return {
|
return {
|
||||||
type: "marker",
|
type: "marker",
|
||||||
@@ -722,6 +892,7 @@ function polygonToSelection(polygon: MapPolygon): Tgu2DMapSelection {
|
|||||||
surveyType: polygon.surveyType,
|
surveyType: polygon.surveyType,
|
||||||
status: polygon.status,
|
status: polygon.status,
|
||||||
modeId: polygon.modeId,
|
modeId: polygon.modeId,
|
||||||
|
revolution: polygon.revolution,
|
||||||
importance: polygon.importance,
|
importance: polygon.importance,
|
||||||
cellNum: polygon.cellNum
|
cellNum: polygon.cellNum
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export function hitTestAllPolygons(index: PolygonSpatialIndex, point: GeoPoint):
|
|||||||
const hits: MapPolygon[] = [];
|
const hits: MapPolygon[] = [];
|
||||||
|
|
||||||
for (const entry of index.entries) {
|
for (const entry of index.entries) {
|
||||||
|
if (entry.polygon.pickable === false) continue; // контур отрисовывается, но из выбора исключён
|
||||||
const inBbox =
|
const inBbox =
|
||||||
bboxContainsPoint(entry.bbox, point) ||
|
bboxContainsPoint(entry.bbox, point) ||
|
||||||
bboxContainsPoint(entry.bbox, { lon: point.lon + 360, lat: point.lat }) ||
|
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;
|
let selected: MapPolygon | undefined;
|
||||||
|
|
||||||
for (const entry of index.entries) {
|
for (const entry of index.entries) {
|
||||||
|
if (entry.polygon.pickable === false) continue; // контур отрисовывается, но из выбора исключён
|
||||||
// Check bbox allowing ±360° shifts so antimeridian-crossing polygons are not skipped.
|
// Check bbox allowing ±360° shifts so antimeridian-crossing polygons are not skipped.
|
||||||
const inBbox =
|
const inBbox =
|
||||||
bboxContainsPoint(entry.bbox, point) ||
|
bboxContainsPoint(entry.bbox, point) ||
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ export type Tgu2DMapSelection =
|
|||||||
status?: TguPlanStatus;
|
status?: TguPlanStatus;
|
||||||
/** id режима плана (включения) для polygon-ов слоя planWorks — связь с работой таймлайна. */
|
/** id режима плана (включения) для polygon-ов слоя planWorks — связь с работой таймлайна. */
|
||||||
modeId?: number;
|
modeId?: number;
|
||||||
|
/** Номер витка (layer "swath") — для карточки полосы обзора. */
|
||||||
|
revolution?: number;
|
||||||
/** Важность ячейки тепловой карты (layer "heatmap"). */
|
/** Важность ячейки тепловой карты (layer "heatmap"). */
|
||||||
importance?: number;
|
importance?: number;
|
||||||
/** Номер ячейки сетки заданий (layer "heatmap"). */
|
/** Номер ячейки сетки заданий (layer "heatmap"). */
|
||||||
|
|||||||
@@ -34,10 +34,14 @@ export type MapPolygon = {
|
|||||||
surveyType?: string;
|
surveyType?: string;
|
||||||
/** id режима плана (survey), к которому относится контур — для связи «включение ↔ объект на карте». */
|
/** id режима плана (survey), к которому относится контур — для связи «включение ↔ объект на карте». */
|
||||||
modeId?: number;
|
modeId?: number;
|
||||||
|
/** Номер витка (layer "swath") — для карточки полосы обзора. */
|
||||||
|
revolution?: number;
|
||||||
/** Важность ячейки тепловой карты (layer "heatmap"): задаёт цвет заливки и показывается в инспекторе. */
|
/** Важность ячейки тепловой карты (layer "heatmap"): задаёт цвет заливки и показывается в инспекторе. */
|
||||||
importance?: number;
|
importance?: number;
|
||||||
/** Номер ячейки сетки заданий (layer "heatmap") — ключ для поиска полной карточки ячейки. */
|
/** Номер ячейки сетки заданий (layer "heatmap") — ключ для поиска полной карточки ячейки. */
|
||||||
cellNum?: number;
|
cellNum?: number;
|
||||||
|
/** false — контур рисуется, но исключён из hit-теста/выбора (напр. контур заявки на вкладке «Слоты»). */
|
||||||
|
pickable?: boolean;
|
||||||
zIndex: number;
|
zIndex: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { ConstellationTab } from "../constellation/ConstellationTab";
|
|||||||
import { TguEditorTab } from "../tgu-editor/TguEditorTab";
|
import { TguEditorTab } from "../tgu-editor/TguEditorTab";
|
||||||
import { Tgu2DMapTab } from "../tgu-map-2d/Tgu2DMapTab";
|
import { Tgu2DMapTab } from "../tgu-map-2d/Tgu2DMapTab";
|
||||||
import { TguRequestsTab } from "../tgu-requests/TguRequestsTab";
|
import { TguRequestsTab } from "../tgu-requests/TguRequestsTab";
|
||||||
|
import { TguSlotsTab } from "../tgu-slots/TguSlotsTab";
|
||||||
import {
|
import {
|
||||||
abandonDraft,
|
abandonDraft,
|
||||||
createDraft,
|
createDraft,
|
||||||
@@ -587,6 +588,12 @@ export function TguPlanningPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{visitedTabs.has("slots") && (
|
||||||
|
<div style={tabStyle("slots")}>
|
||||||
|
<TguSlotsTab range={appliedRange} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{visitedTabs.has("constellation") && (
|
{visitedTabs.has("constellation") && (
|
||||||
<div style={tabStyle("constellation")}>
|
<div style={tabStyle("constellation")}>
|
||||||
<ConstellationTab />
|
<ConstellationTab />
|
||||||
|
|||||||
@@ -26,6 +26,11 @@ export function TguToolbar({
|
|||||||
onApply,
|
onApply,
|
||||||
onQuickRange
|
onQuickRange
|
||||||
}: TguToolbarProps) {
|
}: TguToolbarProps) {
|
||||||
|
// Глобальный диапазон «С … По» относится только к вкладке «Планы». Остальные вкладки имеют свой
|
||||||
|
// источник интервала: «Карта» и «Заявки» — фильтр дат в левой панели, «Комплексный план» — свой
|
||||||
|
// диапазон внутри вкладки, «Редактор» отражает выбранный на «Планах» план (его окно задаётся
|
||||||
|
// планом), «Группировка» диапазоном не пользуется. Поэтому панель показываем только на «Планах».
|
||||||
|
const showRange = activeTab === "timeline";
|
||||||
return (
|
return (
|
||||||
<header className="tgu-toolbar">
|
<header className="tgu-toolbar">
|
||||||
<div className="tgu-toolbar__row">
|
<div className="tgu-toolbar__row">
|
||||||
@@ -83,6 +88,15 @@ export function TguToolbar({
|
|||||||
>
|
>
|
||||||
Заявки
|
Заявки
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className={activeTab === "slots" ? "is-active" : ""}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeTab === "slots"}
|
||||||
|
onClick={() => onTabChange("slots")}
|
||||||
|
>
|
||||||
|
Слоты
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
className={activeTab === "constellation" ? "is-active" : ""}
|
className={activeTab === "constellation" ? "is-active" : ""}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -93,7 +107,10 @@ export function TguToolbar({
|
|||||||
Группировка
|
Группировка
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showRange && (
|
||||||
|
<div className="tgu-toolbar__row tgu-toolbar__row--range">
|
||||||
<label className="tgu-field">
|
<label className="tgu-field">
|
||||||
<span>С</span>
|
<span>С</span>
|
||||||
<DateTime24Field value={fromValue} onChange={onFromChange} />
|
<DateTime24Field value={fromValue} onChange={onFromChange} />
|
||||||
@@ -116,6 +133,7 @@ export function TguToolbar({
|
|||||||
7 суток
|
7 суток
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
{error && <div className="tgu-alert tgu-alert--danger">{error}</div>}
|
{error && <div className="tgu-alert tgu-alert--danger">{error}</div>}
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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<HTMLDivElement>(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<HTMLDivElement>) {
|
||||||
|
e.stopPropagation();
|
||||||
|
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
|
||||||
|
},
|
||||||
|
onPointerMove(e: React.PointerEvent<HTMLDivElement>) {
|
||||||
|
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<HTMLDivElement>) {
|
||||||
|
(e.currentTarget as HTMLDivElement).releasePointerCapture(e.pointerId);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onTrackPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
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 (
|
||||||
|
<div className="map-pass-selector">
|
||||||
|
<div className="map-pass-selector__header">
|
||||||
|
<span className="map-pass-selector__title">Сутки</span>
|
||||||
|
<span className="map-pass-selector__nums">
|
||||||
|
{fromDay.label === toDay.label ? fromDay.label : `${fromDay.label}–${toDay.label}`}
|
||||||
|
</span>
|
||||||
|
<span className="map-pass-selector__times">слотов {countInRange}</span>
|
||||||
|
{!isFullRange && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="map-pass-selector__reset"
|
||||||
|
title="Все сутки"
|
||||||
|
onClick={() => { onFromChange(1); onToChange(n); }}
|
||||||
|
>
|
||||||
|
Все
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="map-pass-selector__body" onPointerDown={onTrackPointerDown}>
|
||||||
|
<div className="map-pass-selector__track" ref={trackRef}>
|
||||||
|
<div
|
||||||
|
className="map-pass-selector__fill"
|
||||||
|
style={{ left: `${fromPct}%`, width: `${toPct - fromPct}%` }}
|
||||||
|
/>
|
||||||
|
{days.map((day) => (
|
||||||
|
<div
|
||||||
|
key={day.index}
|
||||||
|
className={`map-pass-selector__tick${day.index >= fromIndex && day.index <= toIndex ? " is-active" : ""}`}
|
||||||
|
style={{ left: `${pct(day.index)}%` }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<div
|
||||||
|
className="map-pass-selector__thumb"
|
||||||
|
style={{ left: `${fromPct}%` }}
|
||||||
|
{...makeHandlers(true)}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="map-pass-selector__thumb"
|
||||||
|
style={{ left: `${toPct}%` }}
|
||||||
|
{...makeHandlers(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<aside className="tgu-slots-pick">
|
||||||
|
<div className="tgu-slots-pick__head">
|
||||||
|
<span>Слоты под курсором · {picks.length}</span>
|
||||||
|
<button type="button" className="tgu-icon-button" onClick={onClose} title="Закрыть">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="tgu-slots-pick__list">
|
||||||
|
{picks.map(({ id, slot, booked }) => (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
type="button"
|
||||||
|
className={`tgu-slots-pick__item${id === activeId ? " is-active" : ""}`}
|
||||||
|
onClick={() => onPick(id)}
|
||||||
|
>
|
||||||
|
<span className="tgu-slots-pick__title">
|
||||||
|
КА {slot.satelliteId} · слот {slot.slotNumber}
|
||||||
|
{booked && <span className="tgu-slots-badge tgu-slots-badge--booked">бронь</span>}
|
||||||
|
</span>
|
||||||
|
<span className="tgu-slots-pick__meta">
|
||||||
|
виток {slot.revolution} / {slot.revolutionSign === "DESC" ? "нисх" : "восх"} ·{" "}
|
||||||
|
{formatLocalDateTime(parseLocal(slot.tn))}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<RequestMapItem[]>([]);
|
||||||
|
const [requestsLoading, setRequestsLoading] = useState(false);
|
||||||
|
const [requestsLoaded, setRequestsLoaded] = useState(false);
|
||||||
|
const [requestsTotal, setRequestsTotal] = useState(0);
|
||||||
|
const [requestsTruncated, setRequestsTruncated] = useState(false);
|
||||||
|
const [requestsError, setRequestsError] = useState<string>();
|
||||||
|
const [selectedRequestId, setSelectedRequestId] = useState<string>("");
|
||||||
|
|
||||||
|
// Интервал отбора заявок — какие заявки показываем на карте (аналог фильтра вкладки «Карта»).
|
||||||
|
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<SlotDTO[]>([]);
|
||||||
|
const [bookedSlots, setBookedSlots] = useState<SlotDTO[]>([]);
|
||||||
|
const [selectedKeys, setSelectedKeys] = useState<ReadonlySet<string>>(() => 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<string>();
|
||||||
|
const [notice, setNotice] = useState<string>();
|
||||||
|
|
||||||
|
// Подсветка строки/контура по клику на карте и центрирование на слоте.
|
||||||
|
const [activeSlotId, setActiveSlotId] = useState<string>();
|
||||||
|
const [focusPoint, setFocusPoint] = useState<GeoPoint>();
|
||||||
|
const focusTokenRef = useRef(0);
|
||||||
|
const [focusToken, setFocusToken] = useState(0);
|
||||||
|
// Контейнер списка слотов — по нему ищем активную строку (data-slot-id) и скроллим её в зону видимости.
|
||||||
|
const listRef = useRef<HTMLElement>(null);
|
||||||
|
|
||||||
|
// Инспектор заявки справа над картой: объекты под кликом и раскрытый (только до выбора заявки).
|
||||||
|
const [mapHits, setMapHits] = useState<Tgu2DMapSelection[]>([]);
|
||||||
|
const [activeHitId, setActiveHitId] = useState<string>();
|
||||||
|
|
||||||
|
// Меню выбора слота при плотном наложении (несколько слотов под точкой клика).
|
||||||
|
const [slotPicks, setSlotPicks] = useState<SlotPick[]>([]);
|
||||||
|
|
||||||
|
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<string, SlotDTO>();
|
||||||
|
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<SlotDay[]>(() => {
|
||||||
|
const buckets = new Map<number, number>();
|
||||||
|
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<HTMLElement>(`[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<Tgu2DMapScene>(() => {
|
||||||
|
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<Tgu2DMapLayersState>(
|
||||||
|
() => ({
|
||||||
|
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<string>();
|
||||||
|
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 (
|
||||||
|
<main className="tgu-map-workspace">
|
||||||
|
<div className="tgu-map-toolbar">
|
||||||
|
<div>
|
||||||
|
<div className="tgu-map-toolbar__title">Слоты</div>
|
||||||
|
<div className="tgu-map-toolbar__subtitle">Расчёт покрытия и бронирование слотов заявки</div>
|
||||||
|
</div>
|
||||||
|
<div className="tgu-field tgu-slots-request">
|
||||||
|
<span>Заявка</span>
|
||||||
|
<div className="tgu-slots-request__name" title={selectedRequest?.name}>
|
||||||
|
{selectedRequest ? selectedRequest.name : "—"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label className="tgu-field">
|
||||||
|
<span>С</span>
|
||||||
|
<DateTime24Field value={fromValue} onChange={setFromValue} />
|
||||||
|
</label>
|
||||||
|
<label className="tgu-field">
|
||||||
|
<span>По</span>
|
||||||
|
<DateTime24Field value={toValue} onChange={setToValue} />
|
||||||
|
</label>
|
||||||
|
<label className="tgu-field">
|
||||||
|
<span>Ветвь</span>
|
||||||
|
<select
|
||||||
|
className="tgu-select"
|
||||||
|
value={revSign}
|
||||||
|
onChange={(event) => setRevSign(event.target.value as "" | RevolutionSign)}
|
||||||
|
>
|
||||||
|
<option value="">обе</option>
|
||||||
|
<option value="ASC">восходящая</option>
|
||||||
|
<option value="DESC">нисходящая</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="tgu-checkbox" title="Считать площадь покрытия (дольше)">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={withCoverage}
|
||||||
|
onChange={(event) => setWithCoverage(event.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>Покрытие</span>
|
||||||
|
</label>
|
||||||
|
<button className="tgu-button tgu-button--primary" type="button" onClick={calculate} disabled={!canCalculate}>
|
||||||
|
{calculating ? "Расчёт…" : "Рассчитать слоты"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="tgu-button"
|
||||||
|
type="button"
|
||||||
|
onClick={book}
|
||||||
|
disabled={booking || selectedKeys.size === 0}
|
||||||
|
>
|
||||||
|
Забронировать{selectedKeys.size > 0 ? ` (${selectedKeys.size})` : ""}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="tgu-button"
|
||||||
|
type="button"
|
||||||
|
onClick={cancelBooking}
|
||||||
|
disabled={booking || bookedSlots.length === 0}
|
||||||
|
>
|
||||||
|
Снять бронь{bookedSlots.length > 0 ? ` (${bookedSlots.length})` : ""}
|
||||||
|
</button>
|
||||||
|
<div className="tgu-map-toolbar__spacer" style={{ flex: 1 }} />
|
||||||
|
{(slots.length > 0 || bookedSlots.length > 0) && (
|
||||||
|
<div className="tgu-map-toolbar__subtitle">
|
||||||
|
слотов {slots.length} · забронировано {bookedSlots.length}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="tgu-slots-body">
|
||||||
|
<section className="tgu-slots-list" ref={listRef}>
|
||||||
|
{hasRequest ? (
|
||||||
|
filteredSlots.length === 0 ? (
|
||||||
|
<div className="tgu-slots-empty">
|
||||||
|
{slots.length > 0 ? "На выбранные сутки слотов нет." : "Слоты не рассчитаны."}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<table className="tgu-slots-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>
|
||||||
|
<input type="checkbox" checked={allSelected} onChange={toggleAll} aria-label="Выбрать все" />
|
||||||
|
</th>
|
||||||
|
<th>КА</th>
|
||||||
|
<th>Виток</th>
|
||||||
|
<th>Слот / цикл</th>
|
||||||
|
<th>Начало</th>
|
||||||
|
<th>Конец</th>
|
||||||
|
<th>Крен</th>
|
||||||
|
<th>Статус</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filteredSlots.map((slot) => {
|
||||||
|
const key = slotKey(slot);
|
||||||
|
const id = `slot:${key}`;
|
||||||
|
const booked = bookedKeys.has(key);
|
||||||
|
const isActive = activeSlotId === id;
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={key}
|
||||||
|
data-slot-id={id}
|
||||||
|
className={isActive ? "is-active" : undefined}
|
||||||
|
onClick={() => selectRow(slot)}
|
||||||
|
>
|
||||||
|
<td onClick={(event) => event.stopPropagation()}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedKeys.has(key)}
|
||||||
|
onChange={() => toggleSlot(key)}
|
||||||
|
aria-label="Выбрать слот"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>{slot.satelliteId}</td>
|
||||||
|
<td>
|
||||||
|
{slot.revolution} / {slot.revolutionSign === "DESC" ? "нисх" : "восх"}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{slot.slotNumber} / {slot.cycle}
|
||||||
|
</td>
|
||||||
|
<td>{formatLocalDateTime(parseLocal(slot.tn))}</td>
|
||||||
|
<td>{formatLocalDateTime(parseLocal(slot.tk))}</td>
|
||||||
|
<td>{slot.roll.toFixed(1)}°</td>
|
||||||
|
<td>
|
||||||
|
{booked ? (
|
||||||
|
<span className="tgu-slots-badge tgu-slots-badge--booked">бронь</span>
|
||||||
|
) : (
|
||||||
|
<span className="tgu-slots-badge">{slotStateLabel(slot.state)}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<div className="tgu-slots-requests-panel">
|
||||||
|
<div className="tgu-map-panel__section">
|
||||||
|
<div className="tgu-map-panel__label">Заявки на интервал</div>
|
||||||
|
<div className="tgu-map-requests-filter">
|
||||||
|
<label className="tgu-field">
|
||||||
|
<span>С</span>
|
||||||
|
<DateTime24Field value={reqFromValue} onChange={setReqFromValue} />
|
||||||
|
</label>
|
||||||
|
<label className="tgu-field">
|
||||||
|
<span>По</span>
|
||||||
|
<DateTime24Field value={reqToValue} onChange={setReqToValue} />
|
||||||
|
</label>
|
||||||
|
<div className="tgu-map-requests-filter__actions">
|
||||||
|
<button
|
||||||
|
className="tgu-button tgu-button--primary tgu-button--sm"
|
||||||
|
type="button"
|
||||||
|
onClick={applyRequests}
|
||||||
|
disabled={requestsLoading || reqRangeInvalid}
|
||||||
|
>
|
||||||
|
{requestsLoading ? "Загрузка..." : "Загрузить"}
|
||||||
|
</button>
|
||||||
|
<button className="tgu-button tgu-button--sm" type="button" onClick={quickToday} disabled={requestsLoading}>
|
||||||
|
Сегодня
|
||||||
|
</button>
|
||||||
|
<button className="tgu-button tgu-button--sm" type="button" onClick={quick7days} disabled={requestsLoading}>
|
||||||
|
7 дней
|
||||||
|
</button>
|
||||||
|
<button className="tgu-button tgu-button--sm" type="button" onClick={quick30days} disabled={requestsLoading}>
|
||||||
|
30 дней
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{requestsError && (
|
||||||
|
<div className="tgu-alert tgu-alert--danger tgu-alert--compact">{requestsError}</div>
|
||||||
|
)}
|
||||||
|
{requestsLoaded && !requestsError && (
|
||||||
|
<div className="tgu-map-requests-filter__status">
|
||||||
|
{requestItems.length === 0
|
||||||
|
? "Заявок не найдено."
|
||||||
|
: `Загружено: ${requestItems.length} из ${requestsTotal}`}
|
||||||
|
{requestsTruncated && (
|
||||||
|
<span className="tgu-map-requests-filter__truncated"> (показаны первые 5000 — уточните интервал)</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{requestItems.length > 0 && (
|
||||||
|
<div className="tgu-map-requests-legend">
|
||||||
|
<span><i className="req-optics" />OPTICS</span>
|
||||||
|
<span><i className="req-rsa" />RSA</span>
|
||||||
|
<span><i className="req-combined" />COMBINED</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="tgu-slots-empty">Кликните заявку на карте, чтобы выбрать.</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="tgu-map-stage">
|
||||||
|
{overlayMessage && <div className="tgu-map-overlay">{overlayMessage}</div>}
|
||||||
|
{hasRequest && (
|
||||||
|
<button type="button" className="tgu-button tgu-slots-reset" onClick={resetSelection}>
|
||||||
|
Сброс
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{hasRequest && days.length > 0 && (
|
||||||
|
<SlotDaySelector
|
||||||
|
days={days}
|
||||||
|
fromIndex={effDayFrom}
|
||||||
|
toIndex={effDayTo}
|
||||||
|
onFromChange={setDayFrom}
|
||||||
|
onToChange={setDayTo}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Tgu2DMapView
|
||||||
|
scene={scene}
|
||||||
|
layers={layers}
|
||||||
|
redrawToken={hasRequest ? filteredSlots.length + bookedSlots.length : requestItems.length}
|
||||||
|
enableObjectChooser
|
||||||
|
onObjectsAtPoint={handleObjectsAtPoint}
|
||||||
|
onObjectSelect={handleObjectSelect}
|
||||||
|
selectedObjectId={hasRequest ? activeSlotId : activeHitId}
|
||||||
|
focusPoint={focusPoint}
|
||||||
|
focusToken={focusToken}
|
||||||
|
/>
|
||||||
|
{!hasRequest && mapHits.length > 0 && (
|
||||||
|
<aside className="tgu-slots-inspector">
|
||||||
|
<MapObjectInspector
|
||||||
|
hits={mapHits}
|
||||||
|
activeId={activeHitId}
|
||||||
|
onToggle={toggleHit}
|
||||||
|
onClose={closeInspector}
|
||||||
|
requestItems={requestItems}
|
||||||
|
onSelectRequest={chooseRequest}
|
||||||
|
/>
|
||||||
|
</aside>
|
||||||
|
)}
|
||||||
|
{hasRequest && slotPicks.length > 0 && (
|
||||||
|
<SlotPickMenu picks={slotPicks} activeId={activeSlotId} onPick={pickSlot} onClose={closeSlotPick} />
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="tgu-map-errors">
|
||||||
|
{notice && <div className="tgu-alert">{notice}</div>}
|
||||||
|
{error && <div className="tgu-alert tgu-alert--danger">{error}</div>}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function slotStateLabel(state: string): string {
|
||||||
|
switch (state) {
|
||||||
|
case "AVAILABLE":
|
||||||
|
return "свободен";
|
||||||
|
case "BOOKED":
|
||||||
|
return "бронь";
|
||||||
|
case "OCCUPIED":
|
||||||
|
return "занят";
|
||||||
|
default:
|
||||||
|
return state || "—";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -22,7 +22,14 @@ export type TimelineRange = {
|
|||||||
toMs: number;
|
toMs: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TguActiveTab = "timeline" | "map" | "complex" | "editor" | "requests" | "constellation";
|
export type TguActiveTab =
|
||||||
|
| "timeline"
|
||||||
|
| "map"
|
||||||
|
| "complex"
|
||||||
|
| "editor"
|
||||||
|
| "requests"
|
||||||
|
| "slots"
|
||||||
|
| "constellation";
|
||||||
|
|
||||||
export type TimelineRow = {
|
export type TimelineRow = {
|
||||||
spacecraftId: string;
|
spacecraftId: string;
|
||||||
|
|||||||
@@ -23,6 +23,14 @@
|
|||||||
overflow-x: auto;
|
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 {
|
.tgu-toolbar__brand {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -906,6 +914,215 @@
|
|||||||
min-height: 0;
|
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 {
|
.tgu-map-stage {
|
||||||
position: relative;
|
position: relative;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -1052,6 +1269,75 @@
|
|||||||
background: var(--bg-panel);
|
background: var(--bg-panel);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-weight: 700;
|
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 {
|
.tgu-map-overlay {
|
||||||
|
|||||||
Reference in New Issue
Block a user