diff --git a/.gitignore b/.gitignore index 11940ef..a6bae97 100644 --- a/.gitignore +++ b/.gitignore @@ -72,3 +72,8 @@ out/ /services/pcp-tgu-ops-ui/yarn-error.log* /services/pcp-tgu-ops-ui/pnpm-debug.log* .gstack/ + +# Локальная раскладка собранных bootJar всех сервисов (артефакты, ~1.7G) — не версионируем. +/serviceAllBuild/ +# Машинно-зависимый npm prefix фронта (локальный путь разработчика). +/services/pcp-tgu-ui-service/.npmrc diff --git a/buildAll.sh b/buildAll.sh new file mode 100755 index 0000000..18727c7 --- /dev/null +++ b/buildAll.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# +# Сборка bootJar всех Spring-сервисов. +# +# ./buildAll.sh # собрать все jar (без тестов; bootJar тесты не гоняет) +# ./buildAll.sh --clean # clean + сборка с нуля +# +# Фронт pcp-tgu-ui-service здесь не собирается (это npm-проект, не gradle). +# Чтобы после сборки разложить jar в serviceAllBuild/ — запусти ./serviceAllBuild.sh +# (или сразу ./serviceAllBuild.sh --build, который делает сборку и раскладку вместе). +# +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +ARGS=() +[[ "${1:-}" == "--clean" ]] && ARGS+=(clean) +ARGS+=(bootJar) + +echo "==> ./gradlew ${ARGS[*]}" +"$ROOT/gradlew" "${ARGS[@]}" + +echo +echo "Готово. Разложить в одну папку: ./serviceAllBuild.sh" diff --git a/config-repo/pcp-gateway-service.yaml b/config-repo/pcp-gateway-service.yaml index 1637751..bbadf63 100644 --- a/config-repo/pcp-gateway-service.yaml +++ b/config-repo/pcp-gateway-service.yaml @@ -63,6 +63,15 @@ spring: - Path=/api/pcp-slots/** filters: - StripPrefix=2 + # Каталог КА: фронт сюда не ходит, но slots-service обращается к нему строго + # через gateway (settings.satellite-catalog-service -> /api/pcp-satellite-catalog). + # StripPrefix=2: /api/pcp-satellite-catalog/api/satellites -> бэкенд /api/satellites. + - id: satellite-catalog + uri: ${pcp.services.satellite-catalog} + predicates: + - Path=/api/pcp-satellite-catalog/** + filters: + - StripPrefix=2 # --- Станции: путь СОХРАНЯЕМ (в vite было без rewrite) — без StripPrefix. - id: stations uri: ${pcp.services.stations} diff --git a/config-repo/slots-service.yaml b/config-repo/slots-service.yaml index 8021e3f..bbb93e2 100644 --- a/config-repo/slots-service.yaml +++ b/config-repo/slots-service.yaml @@ -68,10 +68,12 @@ spring: codec: max-in-memory-size: 20MB +# Все обращения к другим сервисам — строго через gateway (StripPrefix снимает /api/pcp-). +# Напрямую по адресам сервисов slots-service не ходит. settings: - request-service: ${pcp.services.request} - earth-grid-service: ${pcp.services.request} - satellite-catalog-service: ${pcp.services.satellite-catalog} + request-service: ${pcp.services.gateway}/api/pcp-request + earth-grid-service: ${pcp.services.gateway}/api/pcp-request + satellite-catalog-service: ${pcp.services.gateway}/api/pcp-satellite-catalog app: kafka: @@ -127,10 +129,12 @@ spring: codec: max-in-memory-size: 20MB +# Все обращения к другим сервисам — строго через gateway (StripPrefix снимает /api/pcp-). +# Напрямую по адресам сервисов slots-service не ходит. settings: - request-service: ${pcp.services.request} - earth-grid-service: ${pcp.services.request} - satellite-catalog-service: ${pcp.services.satellite-catalog} + request-service: ${pcp.services.gateway}/api/pcp-request + earth-grid-service: ${pcp.services.gateway}/api/pcp-request + satellite-catalog-service: ${pcp.services.gateway}/api/pcp-satellite-catalog app: kafka: topics: diff --git a/docs/tasks/TASK_slot-strip-selection.md b/docs/tasks/TASK_slot-strip-selection.md new file mode 100644 index 0000000..28e26b5 --- /dev/null +++ b/docs/tasks/TASK_slot-strip-selection.md @@ -0,0 +1,208 @@ + + +# Эргономика выбора слотов: выбор «полосами» + правило выполнимости брони + +## Проблема (как сформулировал оператор) +Сделана работа по сортировке, подсветке брони, показу забронированных, расчёту % +покрытия. Но **сам выбор слотов под бронь по-прежнему неудобен**: сейчас оператор +отмечает слоты галочками поштучно (или Shift+клик диапазоном, или «все видимые / +все сутки»). На сотнях-тысячах слотов это утомительно. Плюс выбор «слепой» — он не +знает, можно ли вообще физически отснять выбранный набор (манёвренность КА). + +## Решённые premise (premise-гейт, 2026-06-16, скорректировано после CEO-ревью) +1. **Ограничение брони через смену крена реально существует** (агилити КА). +2. **КОРРЕКЦИЯ:** модель манёвренности УЖЕ есть в slots-service, по-КА из каталога + (`mmi`, `maxSurveyDuration`, `maxChainLength`, `ROLL_EPSILON=0.01` — см. ниже). + Ручная бронь (`bookReq`) её не проверяет, но coverage-solver и `buildSurveyChains` + её соблюдают. Поэтому **не хардкодим физику на фронте** — проверка выполнимости + будет **бэкенд validate-эндпоинтом**, переиспользующим эти по-КА параметры. +3. **Iteration 1 = только UX выбора полосами** (frontend, без правила). Проверка + выполнимости — **iteration 2** (бэкенд validate-эндпоинт + проводка на фронте). + +## Что уже есть в бэкенде (источник правды для it.2) +- Параметры по-КА из каталога ([SatelliteCatalogClient.kt:116](../../services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/service/SatelliteCatalogClient.kt#L116), + [AbstractSatellite.kt:25](../../services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/model/satellite/AbstractSatellite.kt#L25)): + `mmi`=10с (мин. интервал маневра, тот же крен), `maxSurveyDuration`=300с, + `maxChainLength`=30 слотов, `ROLL_EPSILON`=0.01. +- `buildSurveyChains` / `canMergeInTime` / `sameRoll` ([SlotService.kt:2151](../../services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/service/SlotService.kt#L2151)) + — склейка цепочек по-КА (для отрисовки), `OccupiedIntervalPrefilter` — mmi/maxSurveyDuration. +- **Несоответствия исходного черновика** (закрыты решением «правило за бэком»): фронтовые + 150с ≠ бэкендовые 300с; фронтовый ε=0.1° ≠ бэкендовый 0.01; «30с на смену крена» — + в бэке отдельной такой константы НЕТ (склейка только при том же крене). Эти числа + сверяются с каталогом/баллистиками в it.2, НЕ хардкодятся. + +## Что уже есть (карта на существующий код) +- Выбор: `selectedKeys: Set` в [TguSlotsTab.tsx](../../services/pcp-tgu-ui-service/src/features/tgu-slots/TguSlotsTab.tsx). + Поштучный чек, Shift+клик-диапазон ([slotSelection.ts](../../services/pcp-tgu-ui-service/src/features/tgu-slots/slotSelection.ts) `selectionAfterCheck`), + «выбрать видимые», «все сутки», «снять выбор». +- Слот несёт `roll`, `revolution`, `revolutionSign`, `cycle`, `satelliteId`, `slotNumber`, `tn`, `tk`, `state`. +- Чистые модули с юнит-тестами: slotFilters.ts, slotSelection.ts, coverage.ts (+ .test.ts) — образец стиля. +- Бронь: `book()` → подтверждение → `performBook` (чанк 200), снятие аналогично. +- Бэкенд бронирует без валидации манёвренности ([SlotService.kt:2500](../../services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/service/SlotService.kt#L2500)). В it.1 не трогаем. + +## Решение — Iteration 1 (только UX выбора полосами, frontend) — ФИНАЛ после ревью + +### Модуль 1: `slotStrips.ts` (чистая логика + юнит-тесты) +- `groupStrips(slots, keyOf): Map` — максимальные same-roll цепочки + **по-КА**. `keyOf` — аксессор ключа слота (как `keyAt` в `selectionAfterCheck`), чтобы + не дублировать формулу `${satelliteId}:${slotNumber}:${cycle}`. +- **Детерминизм:** внутри функции тотальный порядок — сортировка по + `(satelliteId, parseLocal(tn), parseLocal(tk), slotNumber, cycle, slotKey)`. Результат + не зависит от порядка входного массива. +- **ε крена = `ROLL_EPSILON = 0.01`** (именованная константа, как в бэкенде; НЕ 0.1). + Сравнение — **с якорным креном полосы** (`abs(roll − anchorRoll) ≤ 0.01`), НЕ с + предыдущим слотом (иначе дрейф крена 0.00→0.009→0.018 ошибочно склеит). Совпадает с + бэкендовым `sameRoll(roll, chainRoll)`. +- **StripId стабилен и читаем:** `strip:${slotKey первого члена}` (не счётчик — счётчик + зависит от порядка обхода КА). +- Краевые: один слот → своя полоса; два КА вперемешку по времени НЕ склеиваются; + `tn` не парсится (NaN) → синглтон без падения; ровно на границе ε (`==0.01`) → та же полоса. + +### Модуль 2: выбор «полосами» (UX в TguSlotsTab) — решения ревью +- **Жест: чип в строке `полоса ×N`** (N = число видимых членов полосы). НЕ Alt+клик + (необнаружим, конфликт с чек/Shift), НЕ клик по карте как основной. Чип появляется + только при N≥2 и если есть хоть один выбираемый (не-BUSY) член. +- **Identity ≠ selection target:** + - `stripIdByKey = useMemo(groupStrips(displaySlots, slotKey), [displaySlots])` — id по + СТАБИЛЬНОМУ набору (не filteredSlots/sortedSlots — иначе фильтр-дырка ложно склеит/разорвёт). + - Видимые выбираемые члены полосы берём из `filteredSlots` ∩ `!slotIsBusy` (по образцу + `selectableVisible`). +- **Поведение чипа = `toggleAllVisible`, но в рамках одной полосы:** если ВСЕ видимые + выбираемые члены уже выбраны → снять их; иначе добавить. После — `anchorIndexRef.current = null`. + Без нового state (`selectedStrips` не заводим — рассинхрон с `selectedKeys`). +- **Группировка визуально по StripId, sort-aware:** в сортировке «время» — левый рельс + (border-left, цвет по `StripId`, приглушённый) на смежных строках полосы. В «крен»/«вклад» + слоты полосы НЕ подряд → рельс НЕ рисуем; подсветка членов полосы по StripId при наведении. + Фон строки оставляем за состоянием (active/selected); статус-ячейки не перекрашиваем. +- **Карта it.1:** оставляем существующую подсветку выбранных слотов (`markedObjectIds` уже + ведётся от `selectedKeys`). Отдельную раскраску полос на карте и «выбрать полосу из + `SlotPickMenu`» — НЕ делаем в it.1 (расширяет scope на модель/рисовалку карты). +- **CSS/таблица:** обновить `colCount`; выравнивание ячеек — классами, не сдвигом + `nth-child`; чип держать в пределах `ROW_HEIGHT = 34`. + +### Матрица состояний строки (зафиксировать перед реализацией) +| Состояние | Источник | Визуал | +|---|---|---| +| active (фокус по карте) | `activeSlotId` | подсветка строки (как есть) | +| selected | `selectedKeys.has(key)` | чек отмечен | +| strip-selected | все видимые выбираемые члены полосы выбраны | чип в активном виде | +| partial | часть членов полосы выбрана | чип в нейтральном виде | +| disabled chip | N<2 или 0 выбираемых членов | чипа нет | +| booked/occupied | как есть (бейдж) | не меняется; в полосу входит визуально, в выбор — нет | + +## Реализовано (it.1, 2026-06-17 — пересмотрено) + +ВАЖНО: первая версия it.1 (авто-чип «полоса ×N» по равному крену) оказалась не той фичей и +переделана. На реальных данных полоса по «подряд по времени + равный крен» не формировалась +(в один момент идёт «веер» кренов с шагом ~3°, равный крен повторяется не подряд, а вдоль трассы +через каденцию). Оператор хотел не авто-магию, а ручной выбор полосы мышью «от первого к последнему» +с запретом выйти за полосу. + +Переопределение полосы (согласовано с оператором): +- **полоса = один КА + один крен (допуск `ROLL_EPSILON`) + подряд по времени (без разрыва каденции);** +- **виток/цикл в границу НЕ входят** — у экватора номер витка меняется, а слоты остаются в одной полосе. + +Сделано: +- `slotStrips.ts`: `groupStrips(slots, keyOf) → { idByKey, membersOf }`. Группировка: по КА → + кластеризация по крену (`ROLL_EPSILON=0.5`, разрыв > ε начинает кластер) → внутри кластера деление + по непрерывности времени (`CADENCE_GAP_FACTOR=1.5` от медианной каденции). `PolosaId = + polosa:${ключ первого по времени члена}`; `membersOf` — члены в порядке времени. +- `slotSelection.ts`: вместо `toggleStripSelection` — `selectStripRange(current, members, fromKey, + toKey)` (чистая: добавляет членов полосы между двумя кликами включительно, без мутации). +- `TguSlotsTab.tsx`: убран чип/hover/`stripInfo`. Жест: обычный клик ставит якорь; Shift+клик по + слоту той же полосы выделяет членов между якорем и кликом (BUSY исключаются); Shift+клик по слоту + другой полосы/КА — выбор не меняется + подсказка «только в пределах одной полосы». Якорь — состояние + (`anchorKey`), строки полосы якоря подсвечены рельсом (оттенок по `PolosaId`); сбрасывается при + смене заявки/сортировки/фильтров/расчёта. Подсказка жеста в selbar. +- `tgu-planning.css`: удалены стили чипа/hover; рельс `tgu-slots-row--strip` переиспользован под + полосу якоря, добавлен `is-strip-anchor`. +- Калибровка `ROLL_EPSILON=0.5` / `CADENCE_GAP_FACTOR=1.5` — по скрину данных; при ручном прогоне + возможна подстройка по фактическим `roll`/`tn`. +- Бэкенд/DTO не тронуты. Правило выполнимости (манёвренность КА) по-прежнему it.2. + +Доработки (2026-06-17): +- **Shift+клик ближе к якорю «сжимает» выделение.** `selectStripRange` теперь задаёт выбор полосы + РОВНО диапазоном [from..to]: члены полосы вне диапазона снимаются, выбор в других полосах сохраняется. + Якорь при Shift+клике не двигается (остаётся первым кликом) — можно тянуть конец туда-обратно. +- **Жест выбора полосы мышью на карте (Shift+drag).** Слоты на карте уже кликабельные полигоны + (`slot:${key}` в spatial-index). Карта на жесте отдаёт наверх ключи слотов под курсором + (`onStripAnchor`/`onStripDrag`/`onStripCommit`), а полосу/диапазон считает `TguSlotsTab` + (`pickStripAnchor` — самый ранний по времени слот под курсором = начало; `pickStripEnd` — самый + поздний член ТОЙ ЖЕ полосы под курсором = конец; за границу полосы выделение не уходит). Обычный + drag = панорама, Shift+drag по слоту = выбор полосы (без отдельной кнопки-режима). Подсветка + диапазона живая — через `markedObjectIds` (SVG-оверлей `is-marked`), отдельный слой превью не нужен. + +## Решение — Iteration 2 (проверка выполнимости, отдельно) +- Бэкенд: read-only `POST /api/slots/booking/validate` (набор слотов → violations), + переиспользует по-КА `mmi`/`maxSurveyDuration`/`maxChainLength`/`ROLL_EPSILON` из каталога. + Сверить точную семантику (300 vs «150 на полосу», нужна ли отдельная 30с-граница) с + баллистиками. Опционально позже — валидация и в самом `bookReq`. +- Фронт: гонять validate при выборе/перед бронью, показывать violations, решить + блокировать кнопку или предупреждать. + +## NOT in scope (it.1) +- Любая физика выполнимости на фронте (перенесено в it.2, за бэкенд). +- Переработка автоподбора покрытия (GREEDY/CONTINUOUS). +- Изменение модели хранения броней. + +## Тест-план (it.1) — финал +- `slotStrips.test.ts` (groupStrips): мультиполоса в одном КА; два КА вперемешку по времени + (не склеиваются); один слот → своя полоса; граница ε (`diff==0.01` склеивает, `0.0101` рвёт); + дрейф крена 0.00/0.009/0.018 (с якорным сравнением → две полосы); устойчивость к порядку + входа (равенство РАЗБИЕНИЯ при перемешивании); tie-break по равному `tn`; NaN `tn` → синглтон + без падения; пустой вход → пустая Map. +- **Чистый хелпер выбора полосы** (выделить из компонента, как slotSelection): скрытые члены + не выбираются; BUSY исключён; «все видимые выбраны → снять, иначе добавить»; нет мутации + входного `selectedKeys`. +- Ручная проверка в vite dev: чип `полоса ×N`, рельс в сортировке «время», подсветка по + StripId в «крен»/«вклад», согласованность с Shift+клик/чек/«все сутки», счётчик «Выбрано». + +## Смежная находка (вне scope, флаг оператору) +`anchorIndexRef` (якорь Shift-выбора) сбрасывается при смене сортировки, но НЕ при смене +фильтров (статус/КА/ветвь/сутки), хотя они тоже меняют порядок строк → Shift-диапазон после +фильтрации может выбрать не то. Существующий латентный баг, рядом с этой задачей. Дёшево +починить (сбрасывать якорь и при смене фильтров) — включить в it.1 или отдельным фиксом. + +--- + +## Review artifacts (/autoplan, 2026-06-16) + +### CEO consensus +| # | Вопрос | Claude | Codex | Консенсус | +|---|--------|--------|-------|-----------| +| 1 | Та ли проблема | да (UX выбора) | да | CONFIRMED | +| 2 | Фронт-физика — регрет? | да, критично | да, критично | CONFIRMED → правило за бэк | +| 3 | Бэкенд уже имеет модель? | да (нашёл chain-логику) | — | подтверждено в коде | +| 4 | Калибровка scope | разбить | разбить | CONFIRMED → it.1 только UX | + +### Design consensus +| Тема | Claude | Codex | Консенсус | +|------|--------|-------|-----------| +| Жест | чип в строке | чип в строке | CONFIRMED | +| Grouping vs sort | sort-aware по StripId | sort-aware по StripId | CONFIRMED | +| Скрытые члены | только видимые | только видимые | CONFIRMED | +| Карта | вторично/отложить | вторично/отложить | CONFIRMED | +| Состояния | матрица | матрица | CONFIRMED | + +### Eng consensus +| Тема | Claude | Codex | Консенсус | +|------|--------|-------|-----------| +| StripId над displaySlots | да | да | CONFIRMED | +| Identity ≠ selection target | да | да | CONFIRMED | +| Memo на [displaySlots] | да | да | CONFIRMED | +| ε: якорь vs предыдущий | оставил открытым | якорь (анти-дрейф) | DISAGREE → решено: якорь | +| Бэкенд-изменения в it.1 | нет | нет | CONFIRMED | +| Сброс якоря на фильтрах | — | да (смежный баг) | флаг оператору | + +### Decision Audit Trail +| # | Фаза | Решение | Класс | Принцип | Обоснование | +|---|------|---------|-------|---------|-------------| +| 1 | premise | Правило выполнимости — за бэкенд (не фронт-хардкод) | User Challenge | — | оба ревьюера + бэкенд уже имеет по-КА модель из каталога | +| 2 | premise | it.1 = только UX выбора; правило → it.2 | User Challenge | P2/P3 | развязать безопасный win от спорной физики | +| 3 | design | Жест = чип `полоса ×N` | Taste | P5 (явное) | обнаружим, не конфликтует с чек/Shift | +| 4 | design | Grouping sort-aware (рельс в «время», подсветка иначе) | Mechanical | P1 | зебра по строкам врёт вне time-сортировки | +| 5 | design | Чип выбирает только видимые не-BUSY члены | Mechanical | P1 | не выбирать невидимое (доверие счётчику) | +| 6 | eng | StripId над `displaySlots`, memo на `[displaySlots]` | Mechanical | P3 | стабильность id + перф на тысячах строк | +| 7 | eng | ε-сравнение с якорным креном полосы | Mechanical | P1 | анти-дрейф, совпадает с бэкендовым sameRoll | +| 8 | eng | Без бэкенд/DTO-изменений в it.1 | Mechanical | P4 | всё нужное уже в SlotDTO | +| 9 | eng | Карта — без раскраски полос в it.1 | Mechanical | P3/P5 | не расширять scope на модель карты | diff --git a/docs/бэклог-pcp-tgu-ops-ui.md b/docs/бэклог-pcp-tgu-ops-ui.md index 5d65995..8590cf8 100644 --- a/docs/бэклог-pcp-tgu-ops-ui.md +++ b/docs/бэклог-pcp-tgu-ops-ui.md @@ -13,7 +13,7 @@ > Задачи #12–#25 заведены по результатам аудита. Подробности, обоснование и рекомендации > по каждой — в [аудит-проекта-2026-06-06.md](аудит-проекта-2026-06-06.md) (ссылки на коды находок B/F/R/C). -_Обновлено: 2026-06-11_ +_Обновлено: 2026-06-16_ --- @@ -44,6 +44,7 @@ _Обновлено: 2026-06-11_ - **Главный риск — Kafka:** `KafkaConsumerConfig`/сериализаторы завязаны на `com.fasterxml` ObjectMapper. Перед стартом — спайк (~полдня): умеет ли `spring-kafka` в нашем Boot 4 Jackson 3-сериализаторы. Если нет — Kafka-слой мигрируем отдельно, HTTP/внутренний JSON переводим независимо. - **Масштаб (src/main):** 67 файлов в 10 сервисах — pcp-dynamic-plan-service 22, pcp-ui-service 16 (выводится из эксплуатации, ср. #40 — можно не мигрировать), pcp-request-service 7, slots-service/pcp-tgu-service по 5, остальные 1–4. **Грубая оценка всей репы — ~6–10 человеко-дней**, основной риск — Kafka. Big-bang не нужен: шаг 1 закрывает причину бага, полную миграцию вести фоново сервис за сервисом. - [ ] **#45** — Собрать native-образ сервиса (Kotlin/Native, `kompile`) и контейнер на его основе. Цель — быстрый старт и меньший footprint в сравнении с JVM-образом. Уточнить: какой сервис(ы) переводим первым, совместимость с рефлексией/AOT-конфигом (reachability metadata), сборку в CI и базовый образ контейнера. +- [ ] **#51** — Распространить правило «межсервисные вызовы только через gateway» на остальные сервисы. Для `slots-service` уже сделано (config-only): добавлен маршрут `satellite-catalog` в `pcp-gateway-service.yaml`, а `settings.request-service`/`earth-grid-service`/`satellite-catalog-service` переведены на `${pcp.services.gateway}/api/pcp-` (код уже строит `$base/`, gateway снимает префикс `StripPrefix=2`). Аналогично проверить и перевести прочие сервисы, которые ходят к чужим API напрямую по `pcp.services.` (кандидаты: mission-planing, tgu, complex-mission, dynamic-plan и т.п.) — для каждого целевого API завести gateway-маршрут `/api/pcp-/**` и репойнтить их `settings.*` на gateway. Заодно решить, нужен ли маршрут для тех целей, что сейчас вызываются прямо, но не проксируются (route-processing, coverage-scheme, stations уже без StripPrefix). - [ ] **#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» точки трассы с другим номером выпадают и пропадает орбитальный контекст. Свести нумерацию витков к единому источнику (или маппинг на границе), чтобы трасса и маршрут согласовывались по витку. ### Проверка сервисов @@ -63,6 +64,18 @@ _Обновлено: 2026-06-11_ - [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). +- [x] **#48** — (баг, бэкенд) Слоты бронируются «не на те даты». _Исправлено 2026-06-15:_ `BookedSlotEntity.toViewDTO()` (единственный билдер за `GET …/booking/by-request/{id}`) отдавал `tn/tk/revolution` из **базового** cycle-фрейма без сдвига, тогда как все остальные пути (`toDetails`, `allByInterval`→`shiftedToCycle`, `resolveBookedSlotStartTime`) сдвигают на `cycle*durationCalc`. Сдвиг перенесён в `SlotService.bookedByReqId` (новый `toShiftedViewDTO(satellite)`, КА резолвится из каталога; нет в каталоге → базовый фрейм без падения), базовый билдер в сущности удалён. Регрессия: `SlotServiceCancelTest` (cycle=2 → +32 сут / +486 витков; fallback без КА). POST `booking/request` сохраняет только ключ (`bookedSlotId/sat/slotNum/cycle`), tn/tk не пересчитывает — корректно.
_Исходный отчёт:_ При бронировании набора слотов фактическое время брони расходится с тем, что оператор видел/выбирал в таблице расчёта. Корень — та же рассинхронизация cycle-фрейма, что разобрана в задаче «Бронь не отражалась в UI» (#42): `GET …/booking/by-request/{id}` отдаёт слот в **базовом** cycle-фрейме (`bookedSlot.slot.toDTO` без сдвига на `bookedSlot.cycle`), тогда как `request-cover` перечисляет слоты со сдвигом — поэтому время/сутки брони, восстановленные из by-request, не совпадают с рассчитанным слотом. Фронт сейчас обходит это, отмечая бронь по ответу POST (там cycle верный) и не перечитывая by-request на горячем пути, но это лишь маскирует симптом: пред-существующие брони и брони после перезагрузки страницы по-прежнему могут показываться/трактоваться с неверной датой. Root-fix на бэке: `slots-service SlotService.bookedByReqId/bookedById` должны возвращать слот, сдвинутый на `bookedSlot.cycle` (как `resolveBookedSlotStartTime` / перечисление в `request-cover`), чтобы время брони было согласовано с расчётом. Проверить также, что POST `booking/request` сохраняет именно те tn/tk, что соответствуют выбранному слоту, а не пересчитывает их в базовом фрейме. + +- [ ] **#49** — (баг, фронт) После снятия брони статусы «недоступен» не обновляются. После снятия бронирования со слотов соседние слоты, помеченные «недоступен» (`BUSY`/band — заблокированы из-за пересечения с бронью или длины цепочки), остаются недоступными, пока оператор вручную не нажмёт «Показать все слоты». Причина: снятие брони чистит только локальные отметки брони, а «недоступность» вычисляется бэком при перечислении (`request-cover`/`all=true` с учётом соседних броней) и без повторного запроса не пересчитывается. Нужно после успешного снятия инвалидировать/перечитать затронутые слоты (или пересчитать `state` локально), чтобы освободившиеся «недоступен» сразу становились «свободен» без ручного «Показать все слоты». Файлы: `features/tgu-slots/TguSlotsTab.tsx` (хэндлеры снятия `runCancel`/`performCancelAll`), `api/slotsApi.ts`. + +- [ ] **#50** — Ручное бронирование: слой поддержки решений оператора. Маршрут ручного бронирования уже есть (#41/#42), но оператор проживает его вслепую: нет сигнала «достаточно ли покрытия», отбор галочками не масштабируется на тысячи слотов, конфликты (BUSY) не объяснены. Кладём поверх маршрута информационный слой поддержки решений — «достаточно» по-прежнему судит оператор сам (без гейтов, в линии WAITING_DECISION/REJECTED). Подход — frontend-геометрия (union/intersect контуров на клиенте, без раундтрипов; точность вторична, раз судит человек). Дизайн-док: `~/.gstack/projects/missionis-observatio-terrae/dasolovyev-feat-tgu-slots-filters-design-20260616-122013.md`. Прогоняется по цепочке `/plan-ceo-review` → `/plan-design-review` → `/plan-eng-review` → код → `/review` → `/qa`. + - [ ] **M1 — ядро покрытия (frontend, без бэка):** новый `features/tgu-slots/coverage.ts` (площадь заявки, покрытие выбранного набора; юнит-тесты по образцу `slotFilters.test.ts`), `CoveragePanel` в шапке списка (живой % + бар + цель заявки справочно), слой `coverage` на карте (закрашенный накопленный след union контуров, заклипанный полигоном заявки). Лечит «достаточность». Новая npm-зависимость геометрии (`polygon-clipping` + `@turf/area` или `@turf`-subset — решить на M1). Пересчёт дебаунсом (~100 мс), union только по выбранному набору. + - [ ] **M2 — эргономика (frontend):** сортировка списка «по вкладу в покрытие» (жадный маргинальный прирост, по кнопке) + по крену/Солнцу. Лечит объём — набор собирается кратно меньшим числом кликов. + - [ ] **M3 — конфликты (backend + frontend):** обогатить BUSY-слот держателем (`bookedByReqId` уже есть в slots-service + имя/важность заявки) в `SlotDTO`/`reqCover(all=true)`, бейдж конфликта `занят: <заявка> (важн. K)` в строке. Лечит осознанное разруливание. Единственное изменение бэка. + +> Задачи #52–#53 — по результатам `/review` ветки `feat/tgu-slots-filters` (2026-06-16). Замечания информационные, лендингу не мешают. +- [ ] **#52** — (производительность, бэкенд) `EarthService.requestHolders` грузит весь каталог заявок на каждый `request-cover` с `all=true`. Чтобы разрешить имя/важность горстки заявок-держателей BUSY-слотов (M3), метод постранично выкачивает **все** заявки (`for page in 1 until firstPage.totalPages`) и фильтрует их в памяти — O(всех заявок) по сети и памяти на каждый обзор с BUSY-держателями. Сейчас деградация безопасна (`runCatching → emptyMap`: при недоступности request-service бейдж показывает только id), но операция будет линейно замедляться с ростом БД заявок. Решение: добавить в `pcp-request-service` выборку заявок **по списку id** (batch-by-ids) и звать её из `requestHolders` вместо полного обхода (либо кэш summary). Файлы: `services/slots-service/.../service/EarthService.kt`, эндпоинт request-service. +- [ ] **#53** — (фронт, низкий приоритет/низкая уверенность) `redrawToken` карты на вкладке «Слоты» — сумма длин. В `TguSlotsTab.tsx` токен перерисовки canvas = `filteredSlots.length + bookedSlots.length + selectedKeys.size + (coverageOn ? 1 : 0)`. Разные состояния с одинаковой суммой теоретически не вызовут перерисовку (риск устаревшей картинки на карте). Реалистичную коллизию воспроизвести не удалось (выбор меняет `selectedKeys.size`, след покрытия его отслеживает), поэтому уверенность низкая — занесено для протокола. При желании заменить сумму на устойчивую сигнатуру (напр. хэш ключей видимого набора) или счётчик «версии сцены», инкрементируемый в `scene`-`useMemo`. Файл: `features/tgu-slots/TguSlotsTab.tsx`. ### Аутентификация и роли - [ ] **#46** — Вывести роли на морду и связать их с аутентификацией. На фронте показывать роль(и) пользователя и разграничивать доступ к действиям/вкладкам по роли; источник ролей — аутентификация (токен/headless-auth, `@nstart/auth`). Уточнить: какие роли есть, где они приходят (claims токена / профиль), как маппятся на UI-разрешения (что скрывать/блокировать), и нужен ли серверный enforcement на стороне сервисов, а не только UI-гейтинг. @@ -101,6 +114,7 @@ _Источник и рекомендации: [аудит-сервис-зада --- ## Выполнено +- [x] **Чистки по `/review` `feat/tgu-slots-filters`** _(2026-06-16): по результатам код-ревью ветки применены две безопасные правки. (1) Удалён мёртвый `cancelRequestBooking` в `api/slotsApi.ts` — «Снять всё» перешло на fetch-by-request + батчи `cancelBookedSlots` ради прогресса, вызовов во фронте не осталось (бэкенд-эндпоинт `DELETE /booking/request` цел; совпадения в legacy `pcp-ui-service` не затронуты). (2) `SlotDTO` (`pcp-types-lib`) переведён в `data class`, ручной хелпер `withHolders` (перечисление всех полей) заменён на `slot.copy(holders = …)` в `SlotService.enrichHolders` — убраны грабли «новое поле молча теряется при ручном копировании»; от `SlotDTO` никто не наследуется, 17 мест конструирования совместимы. Kotlin-сборку локально не прогоняли (ограничения реестра @nstart) — проверить при сборке. Остальные находки ревью занесены в #52/#53._ - [x] **#34** — (RS6) Проброс требуемого процента покрытия из API + разведение терминов. _Готово: в `CreateRequestDto` добавлено опциональное поле `coverageRequiredPercent` (0 < x ≤ 100, по умолчанию 100), оно пробрасывается в сущность и используется как порог COMPLETED; захардкоженная константа убрана. В KDoc/коде разведены «прогресс покрытия» (`coverageRequiredPercent`) и «важность заявки» (`importance`, карта приоритетов ячеек). Тесты: `RequestServiceTest`, `RequestApiDtoContractTest`._ - [x] **#31** — (RS3) DLQ и error-handler для Kafka-листенера маршрутов. _Готово: `KafkaConsumerConfig` — `DefaultErrorHandler` + `DeadLetterPublishingRecoverer` в топик `pcp.route.georeference-dlq.v1`, экспоненциальный bounded retry, контрактные ошибки (`JsonProcessingException`/`ConstraintViolationException`) → DLQ без ретраев, диагностические заголовки. Настройки `app.kafka.consumer.route.*`, активируется при заданном `spring.kafka.bootstrap-servers`. Тест `KafkaConsumerConfigTest`._ - [x] **#35** — (RS7) Уведомление **geoportal** о финале заявки. _Готово: единый топик `pcp.request.status.v1` (заменил orphan `pcp.request.completed.v1`) с терминальным статусом в payload (`COMPLETED`/`EXPIRED`). Эмиссия из двух мест через тот же transactional outbox: `RouteMatchingService` (по покрытию) и `RequestExpiryService` (по сроку, #29). Сервис `RequestCompletedOutboxService`→`RequestStatusOutboxService`, eventType `REQUEST_TERMINAL_STATUS`. Outbox получил backoff: колонки `attempts`/`next_attempt_at`, удвоение `initial→max`, предел `max-attempts` (терминальный FAILED). Конфиг `pcp.outbox.*`. Тесты: `OutboxPublisherServiceTest`, `OutboxEventRepositoryJpaTest`, `RequestStatusOutboxServiceTest`, `RequestExpiryServiceTest`, `RouteMatchingServiceTest`. Док: интеракции обновлены. Известный мелкий зазор: заявка, созданная уже просроченной, события не шлёт (вырожденный кейс)._ diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/slots/BookedSlotViewDTO.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/slots/BookedSlotViewDTO.kt new file mode 100644 index 0000000..0e1f926 --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/slots/BookedSlotViewDTO.kt @@ -0,0 +1,28 @@ +package space.nstart.pcp.pcp_types_lib.dto.requests.slots + +import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign +import java.time.LocalDateTime + +/** + * Забронированный слот для отображения и снятия брони (GET /api/slots/booking/by-request/{id}). + * + * В отличие от [SlotDTO] несёт [bookedSlotId] — он нужен фронту, чтобы снимать бронь по списку id + * (DELETE /api/slots/booking/batch). В отличие от [BookedSlotDTO] несёт контур и координаты — нужны + * карте. Критично: [cycle] — это цикл БРОНИ (BookedSlotEntity.cycle), а не базовый slot.cycle, + * иначе ключ sat:slotNum:cycle на фронте не совпадёт с ответом POST booking/request. + */ +class BookedSlotViewDTO( + val bookedSlotId : Long = 0, + val cycle : Long = 0, + val satelliteId : Long = 0, + val tn : LocalDateTime, + val tk : LocalDateTime, + val roll : Double, + val contour : String, + val revolution : Long = 0, + val revolutionSign: RevolutionSign = RevolutionSign.ASC, + val slotNumber : Long = 0, + val state : SlotStatus = SlotStatus.BOOKED, + val latitude : Double = 0.0, + val longitude : Double = 0.0, +) diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/slots/SlotDTO.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/slots/SlotDTO.kt index 66e64f6..89cad2a 100644 --- a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/slots/SlotDTO.kt +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/slots/SlotDTO.kt @@ -3,7 +3,7 @@ package space.nstart.pcp.pcp_types_lib.dto.requests.slots import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign import java.time.LocalDateTime -class SlotDTO( +data class SlotDTO( val cycle : Long = 0, val satelliteId : Long = 0, val tn : LocalDateTime, @@ -16,4 +16,7 @@ class SlotDTO( val state : SlotStatus = SlotStatus.AVAILABLE, val latitude : Double = 0.0, val longitude : Double = 0.0, + // Держатели слота — заполняются только для BUSY-слотов в обзорном request-cover (all=true). + // null/пусто для AVAILABLE/BOOKED и для всех прочих путей (обратно совместимо). + val holders : List? = null, ) \ No newline at end of file diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/slots/SlotHolderDTO.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/slots/SlotHolderDTO.kt new file mode 100644 index 0000000..54d02fc --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/slots/SlotHolderDTO.kt @@ -0,0 +1,12 @@ +package space.nstart.pcp.pcp_types_lib.dto.requests.slots + +/** + * Держатель занятого (BUSY) слота — заявка, чья бронь блокирует слот на этом проходе КА. + * Заполняется только в обзорном ответе request-cover (all=true); name/importance могут быть null, + * если request-service недоступен (деградация — бейдж показывает только идентификатор заявки). + */ +data class SlotHolderDTO( + val requestId: String, + val name: String? = null, + val importance: Double? = null, +) diff --git a/serviceAllBuild.sh b/serviceAllBuild.sh new file mode 100755 index 0000000..1280f7f --- /dev/null +++ b/serviceAllBuild.sh @@ -0,0 +1,258 @@ +#!/usr/bin/env bash +# +# Собирает (опционально) и складывает bootJar всех Spring-сервисов в одну папку +# serviceAllBuild/ в корне проекта, рядом кладёт config-repo и хелпер запуска run.sh. +# +# Использование: +# ./serviceAllBuild.sh # только собрать jar-ы в одно место (сервисы уже собраны) +# ./serviceAllBuild.sh --build # сначала ./gradlew bootJar, потом собрать в одно место +# +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +OUT="$ROOT/serviceAllBuild" +SERVICES_DIR="$ROOT/services" + +DO_BUILD=0 +[[ "${1:-}" == "--build" ]] && DO_BUILD=1 + +if [[ $DO_BUILD -eq 1 ]]; then + echo "==> Сборка bootJar всех сервисов (./gradlew bootJar)..." + "$ROOT/gradlew" bootJar +fi + +mkdir -p "$OUT" + +echo "==> Сбор jar-ов в $OUT" +count=0 +for dir in "$SERVICES_DIR"/*/; do + name="$(basename "$dir")" + libs="$dir/build/libs" + [[ -d "$libs" ]] || { echo " - $name: нет build/libs (не Spring-сервис или не собран), пропуск"; continue; } + # bootJar = единственный *.jar без суффикса -plain.jar; берём самый свежий по имени + jar="$(find "$libs" -maxdepth 1 -name '*.jar' ! -name '*-plain.jar' | sort | tail -n1)" + [[ -n "$jar" ]] || { echo " - $name: bootJar не найден, пропуск"; continue; } + cp -f "$jar" "$OUT/$name.jar" + echo " + $name.jar <- $(basename "$jar")" + count=$((count + 1)) +done + +echo "==> Копирую config-repo в $OUT/config-repo (самодостаточная папка)" +rm -rf "$OUT/config-repo" +cp -r "$ROOT/config-repo" "$OUT/config-repo" + +echo "==> Генерирую $OUT/run.sh" +cat > "$OUT/run.sh" <<'RUNEOF' +#!/usr/bin/env bash +# +# Запуск одного сервиса из собранной папки через java -jar. +# +# ./run.sh # список доступных сервисов +# ./run.sh spring-cloud-config-server # ПЕРВЫМ всегда поднимаем config server +# ./run.sh pcp-tgu-service # затем нужные сервисы (в отдельных терминалах) +# +# Память не ограничена. Задать лимит при необходимости: +# JAVA_OPTS="-Xmx384m" ./run.sh pcp-tgu-service +# +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CONFIG_REPO="$HERE/config-repo" + +JAVA_OPTS="${JAVA_OPTS:-}" + +name="${1:-}" +if [[ -z "$name" ]]; then + echo "Использование: ./run.sh <имя-сервиса>" + echo + echo "Сначала запусти config server: ./run.sh spring-cloud-config-server" + echo + echo "Доступные сервисы:" + for j in "$HERE"/*.jar; do basename "$j" .jar; done | sed 's/^/ /' + exit 1 +fi + +jar="$HERE/$name.jar" +[[ -f "$jar" ]] || { echo "Нет файла: $jar"; exit 1; } + +if [[ "$name" == "spring-cloud-config-server" ]]; then + # config server в профиле local работает в native-режиме и читает yaml отсюда + export SPRING_PROFILES_ACTIVE="${SPRING_PROFILES_ACTIVE:-local}" + export CONFIG_NATIVE_SEARCH_LOCATIONS="${CONFIG_NATIVE_SEARCH_LOCATIONS:-optional:file:$CONFIG_REPO}" + echo "==> config-repo: $CONFIG_REPO" +fi + +echo "==> java $JAVA_OPTS -jar $name.jar" +exec java $JAVA_OPTS -jar "$jar" +RUNEOF +chmod +x "$OUT/run.sh" + +echo "==> Генерирую $OUT/up.sh" +cat > "$OUT/up.sh" <<'UPEOF' +#!/usr/bin/env bash +# +# Поднять сразу несколько сервисов в фоне (config server — всегда первым). +# +# ./up.sh # запустить ВСЕ сервисы +# ./up.sh pcp-tgu-service slots-service # config server + перечисленные +# +# Имя сервиса = имя jar-файла без .jar. Логи: logs/.log, pid: logs/.pid. +# Погасить всё: ./down.sh +# +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CONFIG_REPO="$HERE/config-repo" +LOGS="$HERE/logs" +mkdir -p "$LOGS" + +JAVA_OPTS="${JAVA_OPTS:-}" +CFG="spring-cloud-config-server" + +running() { local p="$LOGS/$1.pid"; [[ -f "$p" ]] && kill -0 "$(cat "$p")" 2>/dev/null; } + +start_one() { + local name="$1" jar="$HERE/$1.jar" + [[ -f "$jar" ]] || { echo " ! нет $name.jar, пропуск"; return; } + if running "$name"; then echo " = $name уже запущен (pid $(cat "$LOGS/$name.pid"))"; return; fi + if [[ "$name" == "$CFG" ]]; then + SPRING_PROFILES_ACTIVE="${SPRING_PROFILES_ACTIVE:-local}" \ + CONFIG_NATIVE_SEARCH_LOCATIONS="${CONFIG_NATIVE_SEARCH_LOCATIONS:-optional:file:$CONFIG_REPO}" \ + nohup java $JAVA_OPTS -jar "$jar" > "$LOGS/$name.log" 2>&1 & + else + nohup java $JAVA_OPTS -jar "$jar" > "$LOGS/$name.log" 2>&1 & + fi + echo $! > "$LOGS/$name.pid" + echo " + $name (pid $!) -> logs/$name.log" +} + +wait_config_server() { + printf "==> жду config server на :8888" + for _ in $(seq 1 60); do + if curl -sf http://localhost:8888/actuator/health >/dev/null 2>&1; then echo " — UP"; return 0; fi + printf "."; sleep 2 + done + echo " — не дождался; смотри logs/$CFG.log"; exit 1 +} + +targets=("$@") +if [[ ${#targets[@]} -eq 0 ]]; then + for j in "$HERE"/*.jar; do targets+=("$(basename "$j" .jar)"); done +fi + +echo "==> config server" +start_one "$CFG" +running "$CFG" && wait_config_server + +echo "==> остальные сервисы" +for name in "${targets[@]}"; do + [[ "$name" == "$CFG" ]] && continue + start_one "$name" +done + +echo "Готово. Логи: tail -f logs/.log Стоп: ./down.sh" +UPEOF +chmod +x "$OUT/up.sh" + +echo "==> Генерирую $OUT/down.sh" +cat > "$OUT/down.sh" <<'DOWNEOF' +#!/usr/bin/env bash +# +# Погасить сервисы, запущенные через up.sh. +# ./down.sh # погасить все +# ./down.sh pcp-tgu-service ... # погасить только перечисленные +# +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +LOGS="$HERE/logs" + +names=("$@") +if [[ ${#names[@]} -eq 0 ]]; then + shopt -s nullglob + for p in "$LOGS"/*.pid; do names+=("$(basename "$p" .pid)"); done +fi + +for name in "${names[@]}"; do + p="$LOGS/$name.pid" + [[ -f "$p" ]] || { echo " - $name: pid-файла нет"; continue; } + pid="$(cat "$p")" + if kill -0 "$pid" 2>/dev/null; then kill "$pid"; echo " x $name (pid $pid) остановлен"; else echo " - $name (pid $pid) уже не жив"; fi + rm -f "$p" +done +DOWNEOF +chmod +x "$OUT/down.sh" + +echo "==> Генерирую $OUT/README.md" +cat > "$OUT/README.md" <<'MDEOF' +# serviceAllBuild — локальный запуск сервисов через `java -jar` + +Папку формирует скрипт `../serviceAllBuild.sh`. Здесь лежат bootJar всех +Spring-сервисов, копия `config-repo` и хелпер `run.sh`. + +## Куда класть config-repo + +`spring-cloud-config-server` в профиле `local` работает в **native**-режиме и +читает yaml-файлы из локальной папки (а не из git). Папка `config-repo` +**уже скопирована сюда** скриптом сборки, и `run.sh` сам передаёт в config server +абсолютный путь к ней через `CONFIG_NATIVE_SEARCH_LOCATIONS`. + +То есть ничего вручную раскладывать не нужно — достаточно запускать config server +через `./run.sh spring-cloud-config-server`. Если правишь конфиги — правь +`serviceAllBuild/config-repo/*.yaml` (или пересобери скриптом, чтобы подтянуть +изменения из корневого `config-repo`). + +## Перед запуском: инфраструктура + +Сервисам нужны (см. `config-repo/application-local.yaml`): +- PostgreSQL на `localhost:5432` +- Kafka на `localhost:29092` + +Подними их (например, через docker-compose проекта) и создай БД скриптами из корня +(`create-service-dbs.sh` / `create_pcp_databases.sh`). + +## Порядок запуска + +Config server слушает порт **8888** — остальные сервисы ходят за конфигом именно +туда, поэтому он всегда первый. Имя сервиса = имя jar-файла без `.jar`. + +### Несколько сервисов сразу (в фоне) + +```bash +./up.sh # ВСЕ сервисы +./up.sh pcp-tgu-service slots-service # config server + перечисленные +``` + +`up.sh` сам поднимает config server первым, ждёт его готовности на :8888, затем +запускает остальные в фоне. Логи — в `logs/.log`, pid — в `logs/.pid`. + +```bash +tail -f logs/pcp-tgu-service.log # смотреть лог +./down.sh # погасить все +./down.sh slots-service # погасить один +``` + +### По одному (на переднем плане, каждый в своём терминале) + +```bash +# терминал 1 +./run.sh spring-cloud-config-server + +# терминал 2 (после того как config server поднялся) +./run.sh pcp-tgu-service +``` + +Порты сервисов заданы в `config-repo/application-local.yaml` (`pcp.ports.*`): +tgu=7011, slots=7006, ui=7008, gateway=7080, request=7005, mission=7010 и т.д. + +## Память + +Лимит heap не задан — JVM сама возьмёт до 25% физической RAM на каждый процесс. +Запускай только то, что нужно прямо сейчас. При нехватке памяти можно ограничить +конкретный сервис: + +```bash +JAVA_OPTS="-Xmx384m" ./run.sh pcp-mission-planing-service +``` +MDEOF + +echo +echo "Готово: $count сервисов в $OUT" +echo "Запуск: cd serviceAllBuild && ./run.sh spring-cloud-config-server" diff --git a/services/pcp-request-service/.gitignore b/services/pcp-request-service/.gitignore new file mode 100644 index 0000000..12a8916 --- /dev/null +++ b/services/pcp-request-service/.gitignore @@ -0,0 +1 @@ +requests.json diff --git a/services/pcp-tgu-ui-service/package-lock.json b/services/pcp-tgu-ui-service/package-lock.json index 02627dd..ff26663 100644 --- a/services/pcp-tgu-ui-service/package-lock.json +++ b/services/pcp-tgu-ui-service/package-lock.json @@ -13,6 +13,7 @@ "@nstart/ui": "^0.4.0", "@phosphor-icons/react": "^2.1.10", "@tanstack/react-query": "^5.96.1", + "@tanstack/react-virtual": "^3.14.2", "d3-geo": "^3.1.1", "react": "^19.2.4", "react-dom": "^19.2.4", diff --git a/services/pcp-tgu-ui-service/src/api/slotsApi.test.ts b/services/pcp-tgu-ui-service/src/api/slotsApi.test.ts index e78f813..8f509e5 100644 --- a/services/pcp-tgu-ui-service/src/api/slotsApi.test.ts +++ b/services/pcp-tgu-ui-service/src/api/slotsApi.test.ts @@ -1,5 +1,10 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { bookRequestSlots, fetchRequestSlots } from "./slotsApi"; +import { + bookRequestSlots, + cancelBookedSlots, + fetchBookedSlotsByRequest, + fetchRequestSlots, +} from "./slotsApi"; function jsonResponse(body: unknown): Response { return new Response(JSON.stringify(body), { @@ -8,6 +13,10 @@ function jsonResponse(body: unknown): Response { }); } +function textResponse(body: string): Response { + return new Response(body, { status: 200 }); +} + const baseParams = { requestId: "r1", timeStart: "2026-01-01T00:00:00", timeStop: "2026-01-02T00:00:00" }; afterEach(() => vi.restoreAllMocks()); @@ -27,6 +36,18 @@ describe("fetchRequestSlots", () => { expect(String(fetchMock.mock.calls[0][0])).not.toContain("coverageStrategy"); }); + it("прокидывает all=true в query (показать и занятые бронью)", async () => { + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse([])); + await fetchRequestSlots({ ...baseParams, all: true }); + expect(String(fetchMock.mock.calls[0][0])).toContain("all=true"); + }); + + it("не добавляет all, если не задан", async () => { + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse([])); + await fetchRequestSlots(baseParams); + expect(String(fetchMock.mock.calls[0][0])).not.toContain("all="); + }); + it("пробрасывает AbortSignal в fetch", async () => { const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse([])); const ctrl = new AbortController(); @@ -61,3 +82,59 @@ describe("bookRequestSlots", () => { expect(res).toHaveLength(1); }); }); + +describe("cancelBookedSlots", () => { + it("DELETE booking/batch с requestId и списком ids в query, возвращает count", async () => { + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(textResponse("3")); + const res = await cancelBookedSlots("r1", [11, 12, 13]); + const [url, init] = fetchMock.mock.calls[0]; + expect(String(url)).toContain("booking/batch"); + expect(String(url)).toContain("requestId=r1"); + expect(String(url)).toContain("ids=11"); + expect(String(url)).toContain("ids=12"); + expect(String(url)).toContain("ids=13"); + expect(init?.method).toBe("DELETE"); + expect(res).toBe(3); + }); + + it("пустой список ids — не зовёт fetch и возвращает 0", async () => { + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(textResponse("0")); + const res = await cancelBookedSlots("r1", []); + expect(fetchMock).not.toHaveBeenCalled(); + expect(res).toBe(0); + }); + + it("на не-ok бросает с текстом ошибки сервера", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("boom", { status: 500 })); + await expect(cancelBookedSlots("r1", [1])).rejects.toThrow("boom"); + }); +}); + +describe("fetchBookedSlotsByRequest", () => { + it("возвращает брони с bookedSlotId и контуром", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + jsonResponse([ + { + bookedSlotId: 42, + cycle: 3, + satelliteId: 12, + slotNumber: 340, + tn: "2026-01-01T10:00:00", + tk: "2026-01-01T10:01:00", + roll: 0, + contour: "POLYGON ((0 0, 1 0, 1 1, 0 0))", + revolution: 1, + revolutionSign: "ASC", + state: "BOOKED", + latitude: 0, + longitude: 0, + }, + ]), + ); + const res = await fetchBookedSlotsByRequest("r1"); + expect(res).toHaveLength(1); + expect(res[0].bookedSlotId).toBe(42); + expect(res[0].cycle).toBe(3); + expect(res[0].contour).toContain("POLYGON"); + }); +}); diff --git a/services/pcp-tgu-ui-service/src/api/slotsApi.ts b/services/pcp-tgu-ui-service/src/api/slotsApi.ts index 36afec8..f96dc5e 100644 --- a/services/pcp-tgu-ui-service/src/api/slotsApi.ts +++ b/services/pcp-tgu-ui-service/src/api/slotsApi.ts @@ -1,5 +1,6 @@ import type { BookedSlotDTO, + BookedSlotView, RequestSlotsParams, SlotBookingRequest, SlotDTO, @@ -45,6 +46,7 @@ export async function fetchRequestSlots( if (params.cov != null) query.set('cov', String(params.cov)) if (params.sun != null) query.set('sun', String(params.sun)) if (params.coverageStrategy) query.set('coverageStrategy', params.coverageStrategy) + if (params.all != null) query.set('all', String(params.all)) params.satellites?.forEach((id) => query.append('satellites', String(id))) const response = await apiFetch(`${BASE}/request-cover?${query}`, { @@ -84,16 +86,29 @@ export async function bookRequestSlots( } /** - * Снимает все брони заявки (slots-service: DELETE /api/slots/booking/request). + * Снимает выбранные брони заявки по списку bookedSlotId (slots-service: DELETE + * /api/slots/booking/batch). Один запрос = один чанк; крупный набор чанкует вызывающий + * (runChunked), как при бронировании. Список id передаётся в query (?ids=1&ids=2), а не в теле — + * тело у DELETE через gateway ненадёжно. requestId обязателен: снятие request-aware, чтобы + * шаренный между заявками слот не сносился у других. * @param requestId Идентификатор заявки. + * @param bookedSlotIds Идентификаторы броней (bookedSlotId) одного чанка. * - * @returns Сколько броней снято. + * @returns Сколько броней снято в этом чанке. */ -export async function cancelRequestBooking(requestId: string): Promise { - const response = await apiFetch( - `${BASE}/booking/request?requestId=${encodeURIComponent(requestId)}`, - { method: 'DELETE', headers: { Accept: 'application/json' } }, - ) +export async function cancelBookedSlots( + requestId: string, + bookedSlotIds: number[], +): Promise { + if (bookedSlotIds.length === 0) return 0 + const query = new URLSearchParams() + query.set('requestId', requestId) + bookedSlotIds.forEach((id) => query.append('ids', String(id))) + + const response = await apiFetch(`${BASE}/booking/batch?${query}`, { + method: 'DELETE', + headers: { Accept: 'application/json' }, + }) if (!response.ok) { throw new Error(await readErrorMessage(response, 'Не удалось снять бронь')) @@ -107,9 +122,10 @@ export async function cancelRequestBooking(requestId: string): Promise { * Загружает уже забронированные слоты заявки (slots-service: GET /api/slots/booking/by-request/{id}). * @param requestId Идентификатор заявки. * - * @returns Список забронированных слотов с контурами. + * @returns Список забронированных слотов с контурами и bookedSlotId (для снятия по id); + * cycle здесь — цикл брони, поэтому slotKey совпадает с ответом POST booking/request. */ -export async function fetchBookedSlotsByRequest(requestId: string): Promise { +export async function fetchBookedSlotsByRequest(requestId: string): Promise { const response = await apiFetch( `${BASE}/booking/by-request/${encodeURIComponent(requestId)}`, { headers: { Accept: 'application/json' } }, @@ -119,5 +135,5 @@ export async function fetchBookedSlotsByRequest(requestId: string): Promise + return response.json() as Promise } diff --git a/services/pcp-tgu-ui-service/src/features/tgu-editor/MapPassSelector.tsx b/services/pcp-tgu-ui-service/src/features/tgu-editor/MapPassSelector.tsx index a7eaaf3..e8ab98e 100644 --- a/services/pcp-tgu-ui-service/src/features/tgu-editor/MapPassSelector.tsx +++ b/services/pcp-tgu-ui-service/src/features/tgu-editor/MapPassSelector.tsx @@ -42,21 +42,34 @@ export function MapPassSelector({ passes, fromIndex, toIndex, onFromChange, onTo return Math.round(Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)) * (n - 1)) + 1; }; - const makeHandlers = (isFrom: boolean) => ({ + // Какой ползунок реально тянем. При наложении (fromIndex === toIndex) хват попадает на верхний + // (to), но на правом краю он заперт fromIndex и не может уйти влево — поэтому при наложении + // выбираем ползунок по направлению движения: влево → from (открыть диапазон), вправо → to. + const dragRef = useRef<"from" | "to" | null>(null); + + const makeHandlers = (which: "from" | "to") => ({ onPointerDown(e: React.PointerEvent) { e.stopPropagation(); + dragRef.current = which; (e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId); }, onPointerMove(e: React.PointerEvent) { if (!(e.currentTarget as HTMLDivElement).hasPointerCapture(e.pointerId)) return; const idx = indexAt(e.clientX); - if (isFrom) { + let active = dragRef.current; + if (fromIndex === toIndex) { + if (idx < fromIndex) active = "from"; + else if (idx > toIndex) active = "to"; + dragRef.current = active; + } + if (active === "from") { onFromChange(Math.max(1, Math.min(idx, toIndex))); } else { onToChange(Math.min(n, Math.max(idx, fromIndex))); } }, onPointerUp(e: React.PointerEvent) { + dragRef.current = null; (e.currentTarget as HTMLDivElement).releasePointerCapture(e.pointerId); }, }); @@ -111,13 +124,13 @@ export function MapPassSelector({ passes, fromIndex, toIndex, onFromChange, onTo
{/* To handle */}
diff --git a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/Tgu2DMapView.tsx b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/Tgu2DMapView.tsx index 0cfbf2d..cadac9b 100644 --- a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/Tgu2DMapView.tsx +++ b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/Tgu2DMapView.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { drawBaseMap } from "./canvas/drawBaseMap"; import { drawComplexPlan } from "./canvas/drawComplexPlan"; +import { drawCoverage } from "./canvas/drawCoverage"; import { drawHeatmap } from "./canvas/drawHeatmap"; import { drawPlanWorks } from "./canvas/drawPlanWorks"; import { drawRequests } from "./canvas/drawRequests"; @@ -26,6 +27,8 @@ type Tgu2DMapViewProps = { selectedObjectId?: string; /** Отмеченные объекты (постоянная подсветка независимо от выбора) — напр. слоты, отобранные под бронь. */ markedObjectIds?: ReadonlySet; + /** Приглушённые объекты — напр. слоты, конфликтующие с текущим выбором (подсказка «недоступно»). */ + dimmedObjectIds?: ReadonlySet; /** Точка, на которую центрировать карту; перецентрирование срабатывает при смене focusToken. */ focusPoint?: GeoPoint; focusToken?: number; @@ -37,6 +40,16 @@ type Tgu2DMapViewProps = { enableObjectChooser?: boolean; /** Несколько объектов под точкой клика — список передаётся наверх (выбор делает оператор справа). */ onObjectsAtPoint?: (selections: Tgu2DMapSelection[]) => void; + /** + * Жест «выбор полосы» мышью: Shift+перетаскивание по слотам. Карта остаётся «глупой» — отдаёт + * наверх ключи слотов под курсором (id вида `slot:${key}`), а группировку в полосу и диапазон + * считает владелец выбора (TguSlotsTab). onStripAnchor — Shift+нажатие на слот (начало жеста), + * onStripDrag — движение (текущие слоты под курсором), onStripCommit — отпускание (конец жеста). + * Если жест не задан или Shift зажат над пустым местом — работает обычная панорама. + */ + onStripAnchor?: (slotKeysUnderCursor: string[]) => void; + onStripDrag?: (slotKeysUnderCursor: string[]) => void; + onStripCommit?: () => void; pickMode?: boolean; onPickPoint?: (point: { lat: number; lon: number }) => void; targets?: Tgu2DMapTarget[]; @@ -87,10 +100,14 @@ export function Tgu2DMapView({ selectedRequestPolygonId, selectedObjectId, markedObjectIds, + dimmedObjectIds, focusPoint, focusToken, enableObjectChooser = false, onObjectsAtPoint, + onStripAnchor, + onStripDrag, + onStripCommit, pickMode = false, onPickPoint, targets = [], @@ -105,6 +122,8 @@ export function Tgu2DMapView({ const canvasRef = useRef(null); const dragRef = useRef(undefined); const hasDraggedRef = useRef(false); + // Активен жест «выбор полосы» (Shift+drag по слотам): пока true — панорама и обычный клик подавлены. + const stripGestureRef = useRef(false); const hoverRef = useRef(undefined); const hoverMarkerRef = useRef(undefined); const selectedMarkerRef = useRef(undefined); @@ -213,6 +232,11 @@ export function Tgu2DMapView({ if (layers.complexPlan) { drawComplexPlan(context, visiblePolygons.filter((polygon) => polygon.layer === "complexPlan"), projection, size); } + // След покрытия (вкладка «Слоты») — отдельный пасс поверх полос слотов; кольца приходят + // через scene.coverageRings (тумблер на стороне TguSlotsTab очищает их, когда слой выключен). + if (scene.coverageRings && scene.coverageRings.length > 0) { + drawCoverage(context, scene.coverageRings, projection, size); + } if (layers.planWorks) { drawPlanWorks(context, visiblePolygons.filter((polygon) => polygon.layer === "planWorks"), projection, size); } @@ -225,7 +249,7 @@ export function Tgu2DMapView({ }); return () => window.cancelAnimationFrame(frameId); - }, [handleTileLoad, layers, projection, redrawToken, scene.lines, scene.markers, size, tileVersion, view, visiblePolygons]); + }, [handleTileLoad, layers, projection, redrawToken, scene.coverageRings, scene.lines, scene.markers, size, tileVersion, view, visiblePolygons]); const screenPointFromEvent = useCallback((event: React.PointerEvent | React.MouseEvent): ScreenPoint => { const bounds = wrapperRef.current?.getBoundingClientRect(); @@ -262,12 +286,39 @@ export function Tgu2DMapView({ [activeMarkers, polygonIndex, projection, screenPointFromEvent] ); + // Ключи слотов (id `slot:${key}`) под экранной точкой — для жеста выбора полосы. Брони вне окна + // (`booked:${key}`) и прочие слои не берём: полосой выбираем только кандидатов-слоты. + const slotKeysAtPoint = useCallback( + (screenPoint: ScreenPoint): string[] => { + const geoPoint = projection.unproject(screenPoint); + const keys: string[] = []; + for (const polygon of hitTestAllPolygons(polygonIndex, geoPoint)) { + if (polygon.id.startsWith("slot:")) keys.push(polygon.id.slice("slot:".length)); + } + return keys; + }, + [polygonIndex, projection] + ); + const onPointerDown = (event: React.PointerEvent) => { // Нажатие на кнопки управления картой (зум, линейка, «Сброс») не должно запускать перетаскивание // и захват указателя: setPointerCapture на контейнере иначе перехватывает жест, и кнопки внутри // карты перестают «кликаться» (родная причина того, что +/−/□ и «Сброс» не срабатывали). if ((event.target as HTMLElement).closest("button")) return; const screenPoint = screenPointFromEvent(event); + // Shift+нажатие на слот → жест выбора полосы (вместо панорамы). Над пустым местом Shift не мешает. + if (event.shiftKey && onStripAnchor) { + const keys = slotKeysAtPoint(screenPoint); + if (keys.length > 0) { + event.preventDefault(); // не даём Shift+drag начать выделение текста / перетаскивание + stripGestureRef.current = true; + hasDraggedRef.current = false; + dragRef.current = undefined; + wrapperRef.current?.setPointerCapture(event.pointerId); + onStripAnchor(keys); + return; + } + } hasDraggedRef.current = false; dragRef.current = { startX: screenPoint.x, @@ -280,6 +331,14 @@ export function Tgu2DMapView({ const DRAG_THRESHOLD = 4; const onPointerMove = (event: React.PointerEvent) => { + if (stripGestureRef.current) { + // Жест выбора полосы: тянем — отдаём слоты под курсором наверх (там клампится по границе полосы). + hasDraggedRef.current = true; + const screenPoint = screenPointFromEvent(event); + setCursorGeo(projection.unproject(screenPoint)); + onStripDrag?.(slotKeysAtPoint(screenPoint)); + return; + } const drag = dragRef.current; if (!drag) { updateHover(event); @@ -310,6 +369,14 @@ export function Tgu2DMapView({ }; const onPointerUp = (event: React.PointerEvent) => { + if (stripGestureRef.current) { + stripGestureRef.current = false; + // hasDraggedRef оставляем true — иначе следующий onClick выбрал бы слот «сверху» жеста. + hasDraggedRef.current = true; + wrapperRef.current?.releasePointerCapture(event.pointerId); + onStripCommit?.(); + return; + } dragRef.current = undefined; wrapperRef.current?.releasePointerCapture(event.pointerId); }; @@ -540,6 +607,14 @@ export function Tgu2DMapView({ : [], [markedObjectIds, activePolygons] ); + // Приглушённые (конфликтующие с выбором) объекты — рисуем под отмеченными/выбранными. + const dimmedPolygons = useMemo( + () => + dimmedObjectIds && dimmedObjectIds.size > 0 + ? activePolygons.filter((p) => dimmedObjectIds.has(p.id)) + : [], + [dimmedObjectIds, activePolygons] + ); const overlayMarkers = uniqueMarkers([selectedMarker, hoverMarkerRef.current]); // Накопленное расстояние (км) от первой вершины линейки до каждой — по дуге большого круга. @@ -574,6 +649,24 @@ export function Tgu2DMapView({ >
+
+ {label}… + + {done}/{total} + +
+
+
+
+
+ ); +} diff --git a/services/pcp-tgu-ui-service/src/features/tgu-slots/CoveragePanel.tsx b/services/pcp-tgu-ui-service/src/features/tgu-slots/CoveragePanel.tsx new file mode 100644 index 0000000..02de0fd --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-slots/CoveragePanel.tsx @@ -0,0 +1,91 @@ +import type { CoverageResult } from "./coverage"; + +type CoveragePanelProps = { + /** Покрытие только забронированным набором. null — покрытие недоступно (нет геометрии/0 сэмплов). */ + booked: CoverageResult | null; + /** Покрытие union(забронированное ∪ выбранное галочками). */ + withSelection: CoverageResult | null; + /** Есть ли выбор сверх брони (показывать второй сегмент «с выбором»). */ + hasSelection: boolean; + /** Геометрия заявки пригодна для расчёта (false → «недоступно»). */ + available: boolean; + /** Тумблер следа покрытия на карте (D4 — локальный, вкл. по умолчанию). */ + coverageOn: boolean; + onToggleCoverage: (on: boolean) => void; + /** Тумблер подсветки конфликтов выбора (тусклый слой) — рядом со следом на карте. */ + showConflicts: boolean; + onToggleConflicts: (on: boolean) => void; +}; + +// Панель покрытия над списком слотов (D-D1): «забронировано NN% · с выбором MM%» + двухсегментный +// бар + явные состояния. Цвет = цвет слоя coverage на карте (violet) — единый язык «покрыто». +export function CoveragePanel({ + booked, + withSelection, + hasSelection, + available, + coverageOn, + onToggleCoverage, + showConflicts, + onToggleConflicts, +}: CoveragePanelProps) { + const bookedPct = booked?.percent ?? 0; + const withPct = withSelection?.percent ?? bookedPct; + // Прирост от выбора (второй сегмент) — поверх забронированного. + const gainPct = Math.max(0, withPct - bookedPct); + const lowPrecision = !!(booked?.lowPrecision || withSelection?.lowPrecision); + + return ( +
+
+ Покрытие заявки +
+ + +
+
+ + {!available ? ( +
Покрытие недоступно — у заявки нет геометрии.
+ ) : ( + <> +
+
+ {hasSelection && gainPct > 0 && ( +
+ )} +
+
+ забронировано {bookedPct}% + {hasSelection && ( + · с выбором {withPct}% + )} + {lowPrecision && ( + + {" "} + · низкая точность + + )} +
+ + )} +
+ ); +} diff --git a/services/pcp-tgu-ui-service/src/features/tgu-slots/PassStepper.tsx b/services/pcp-tgu-ui-service/src/features/tgu-slots/PassStepper.tsx new file mode 100644 index 0000000..579ee91 --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-slots/PassStepper.tsx @@ -0,0 +1,72 @@ +type PassStepperProps = { + /** Индекс текущего витка (0-based) или -1, если виток не выбран. */ + index: number; + /** Всего витков в наборе. */ + total: number; + /** Подпись текущего витка (КА · оборот · ветвь · время) — пустая, если виток не выбран. */ + label: string; + canPrev: boolean; + canNext: boolean; + onPrev: () => void; + onNext: () => void; + /** Сброс курсора витка — вернуться к показу всего набора. */ + onClear: () => void; +}; + +/** + * Степпер по виткам (проходам): ◀/▶ переводят курсор на предыдущий/следующий проход в хронологии, + * таблица скроллится к нему. Пока прохождение выбрано — отображение изолировано до него; «Все» + * возвращает к показу всего набора. Стоит под фильтром «Сутки»: сутки — грубый диапазон, проход — + * точный курсор внутри. + */ +export function PassStepper({ + index, + total, + label, + canPrev, + canNext, + onPrev, + onNext, + onClear, +}: PassStepperProps) { + const active = index >= 0; + return ( +
+
+ Прохождение + {active ? `${index + 1} / ${total}` : `— / ${total}`} + {active ? "" : "выберите прохождение ◀ ▶"} + {active && ( + + )} +
+ + {active &&
{label}
} + +
+ + +
+
+ ); +} diff --git a/services/pcp-tgu-ui-service/src/features/tgu-slots/SlotDaySelector.tsx b/services/pcp-tgu-ui-service/src/features/tgu-slots/SlotDaySelector.tsx index 915616e..24c3269 100644 --- a/services/pcp-tgu-ui-service/src/features/tgu-slots/SlotDaySelector.tsx +++ b/services/pcp-tgu-ui-service/src/features/tgu-slots/SlotDaySelector.tsx @@ -37,21 +37,34 @@ export function SlotDaySelector({ days, fromIndex, toIndex, onFromChange, onToCh return Math.round(Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)) * (n - 1)) + 1; }; - const makeHandlers = (isFrom: boolean) => ({ + // Какой ползунок реально тянем. При наложении (fromIndex === toIndex) хват попадает на верхний + // (to), но на правом краю он заперт fromIndex и не может уйти влево — поэтому при наложении + // выбираем ползунок по направлению движения: влево → from (открыть диапазон), вправо → to. + const dragRef = useRef<"from" | "to" | null>(null); + + const makeHandlers = (which: "from" | "to") => ({ onPointerDown(e: React.PointerEvent) { e.stopPropagation(); + dragRef.current = which; (e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId); }, onPointerMove(e: React.PointerEvent) { if (!(e.currentTarget as HTMLDivElement).hasPointerCapture(e.pointerId)) return; const idx = indexAt(e.clientX); - if (isFrom) { + let active = dragRef.current; + if (fromIndex === toIndex) { + if (idx < fromIndex) active = "from"; + else if (idx > toIndex) active = "to"; + dragRef.current = active; + } + if (active === "from") { onFromChange(Math.max(1, Math.min(idx, toIndex))); } else { onToChange(Math.min(n, Math.max(idx, fromIndex))); } }, onPointerUp(e: React.PointerEvent) { + dragRef.current = null; (e.currentTarget as HTMLDivElement).releasePointerCapture(e.pointerId); }, }); @@ -101,12 +114,12 @@ export function SlotDaySelector({ days, fromIndex, toIndex, onFromChange, onToCh
diff --git a/services/pcp-tgu-ui-service/src/features/tgu-slots/SlotFilterBar.tsx b/services/pcp-tgu-ui-service/src/features/tgu-slots/SlotFilterBar.tsx new file mode 100644 index 0000000..304ab0b --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-slots/SlotFilterBar.tsx @@ -0,0 +1,147 @@ +import type { RevolutionSign } from "../../model/slotTypes"; +import type { SlotStatusKey } from "./slotFilters"; + +/** Сортировка таблицы слотов: время (порядок расчёта) · вклад (ранжирование по покрытию) · крен. */ +export type SlotSortMode = "time" | "gain" | "roll"; + +type SlotFilterBarProps = { + /** Счётчики статусов по окну суток (до статус-фильтра) — стабильны при переключении чипов. */ + statusCounts: Record; + statusFilter: ReadonlySet; + onToggleStatus: (key: SlotStatusKey) => void; + /** Список КА в наборе (отсортирован). Чип КА не показываем, если спутник один. */ + satellites: number[]; + satFilter: ReadonlySet; + onToggleSat: (id: number) => void; + branchFilter: ReadonlySet; + onToggleBranch: (branch: RevolutionSign) => void; + /** Текущая сортировка таблицы (одиночный выбор). */ + sortMode: SlotSortMode; + onSortMode: (mode: SlotSortMode) => void; + /** Доступна ли сортировка «по вкладу» (есть геометрия заявки) — иначе чип заблокирован. */ + gainAvailable: boolean; + /** Фильтры в дефолте (всё видно) — тогда «Сбросить» не показываем. */ + isDefault: boolean; + onReset: () => void; +}; + +const STATUS_META: { key: SlotStatusKey; label: string; variant: string }[] = [ + { key: "free", label: "свободен", variant: "" }, + { key: "booked", label: "бронь", variant: " is-booked" }, + { key: "occupied", label: "недоступен", variant: " is-occupied" }, +]; + +const SORT_META: { key: SlotSortMode; label: string }[] = [ + { key: "time", label: "время" }, + { key: "gain", label: "вклад" }, + { key: "roll", label: "крен" }, +]; + +/** + * Панель фильтров отображения слотов (видимость). Сужает И таблицу, И карту через единый + * filteredSlots. Стоит над строкой выбора: сначала сужаешь, потом отбираешь. Чипы — + * настоящие кнопки aria-pressed; цвет статус-чипа совпадает с бейджем статуса в таблице, + * на каждом текст-метка (не только цвет — дальтоники). + */ +export function SlotFilterBar({ + statusCounts, + statusFilter, + onToggleStatus, + satellites, + satFilter, + onToggleSat, + branchFilter, + onToggleBranch, + sortMode, + onSortMode, + gainAvailable, + isDefault, + onReset, +}: SlotFilterBarProps) { + return ( +
+
+ Статус + {STATUS_META.map(({ key, label, variant }) => { + const active = statusFilter.has(key); + return ( + + ); + })} +
+ + {satellites.length > 1 && ( +
+ КА + {satellites.map((id) => { + const active = satFilter.has(id); + return ( + + ); + })} +
+ )} + +
+ Ветвь + {(["ASC", "DESC"] as RevolutionSign[]).map((branch) => { + const active = branchFilter.has(branch); + return ( + + ); + })} +
+ +
+ Сортировка + {SORT_META.map(({ key, label }) => { + const active = sortMode === key; + const disabled = key === "gain" && !gainAvailable; + return ( + + ); + })} +
+ + {!isDefault && ( + + )} +
+ ); +} diff --git a/services/pcp-tgu-ui-service/src/features/tgu-slots/TguSlotsTab.tsx b/services/pcp-tgu-ui-service/src/features/tgu-slots/TguSlotsTab.tsx index 46f14fb..b632634 100644 --- a/services/pcp-tgu-ui-service/src/features/tgu-slots/TguSlotsTab.tsx +++ b/services/pcp-tgu-ui-service/src/features/tgu-slots/TguSlotsTab.tsx @@ -1,15 +1,15 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useVirtualizer } from "@tanstack/react-virtual"; import { fetchRequestsForMap } from "../../api/requestApi"; import { bookRequestSlots, - cancelRequestBooking, + cancelBookedSlots, fetchBookedSlotsByRequest, fetchRequestSlots } from "../../api/slotsApi"; import type { RequestMapItem } from "../../model/requestTypes"; import type { - BookedSlotDTO, + BookedSlotView, RevolutionSign, SlotBookingRequest, SlotCoverageStrategy, @@ -23,19 +23,33 @@ 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 { formatLocalDateTimeSeconds, parseLocal, toLocalInputValue, toLocalIso } from "../../utils/localTime"; import { SlotDaySelector, type SlotDay } from "./SlotDaySelector"; +import { PassStepper } from "./PassStepper"; +import { groupPasses } from "./slotPasses"; import { SlotPickMenu, type SlotPick } from "./SlotPickMenu"; -import { chunk, selectionAfterCheck } from "./slotSelection"; +import { SlotFilterBar, type SlotSortMode } from "./SlotFilterBar"; +import { CoveragePanel } from "./CoveragePanel"; +import { + buildCoverageGrid, + coverageOfMasks, + rankByMarginalGain, + sampleSlotMask, + unionMasks, + type RankCandidate, + type SlotMask +} from "./coverage"; +import { applySlotFilters, slotStatusKey, type SlotStatusKey } from "./slotFilters"; +import { computeConflicts } from "./slotConflicts"; +import { deselectStripRange, selectionAfterCheck, selectStripRange } from "./slotSelection"; +import { groupStrips, pickStripAnchor, pickStripEnd, type PolosaId } from "./slotStrips"; +import { runChunked } from "./runChunked"; +import { ChunkProgress } from "./ChunkProgress"; const DAY_MS = 24 * 60 * 60 * 1000; /** Размер чанка при бронировании больших наборов (бэк бронирует слоты по одному). */ const BOOK_CHUNK = 200; -/** Выше этого числа слотов бронь требует подтверждения. */ -const BOOK_CONFIRM_THRESHOLD = 500; -/** Выше этого числа полигонов в окне суток показываем подсказку «сузьте сутки». */ -const MAP_HINT_THRESHOLD = 800; /** Оценка высоты строки таблицы (px) для виртуализатора. */ const ROW_HEIGHT = 34; @@ -80,25 +94,54 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) { 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 [strategy, setStrategy] = useState("GREEDY"); - // Результаты: слоты-кандидаты и уже забронированные слоты заявки. + // Результаты: слоты-кандидаты и уже забронированные слоты заявки (с bookedSlotId для снятия по id). const [slots, setSlots] = useState([]); - const [bookedSlots, setBookedSlots] = useState([]); + const [bookedSlots, setBookedSlots] = useState([]); const [selectedKeys, setSelectedKeys] = useState>(() => new Set()); + // Тумблер следа покрытия на карте (D4 — локальный state, вкл. по умолчанию; глобальный + // Tgu2DMapLayersState не трогаем). Оператор может снять след, оставив только % в панели. + const [coverageOn, setCoverageOn] = useState(true); + // Тумблер подсветки конфликтов выбора (тусклый слой). Вкл. по умолчанию; выключение и гасит слой, + // и пропускает пересчёт (см. conflictKeys) — заодно дешевле на больших наборах. + const [showConflicts, setShowConflicts] = useState(true); + // Сортировка таблицы: «время» (порядок расчёта), «вклад» (жадное ранжирование по приросту + // покрытия, колонка +N%), «крен» (по модулю угла крена). Вклад доступен только при наличии + // геометрии заявки (иначе чип «вклад» заблокирован в SlotFilterBar). + const [sortMode, setSortMode] = useState("time"); // Фильтр отображения по суткам: индексы (1-based) выбранного диапазона суток расчёта. const [dayFrom, setDayFrom] = useState(1); const [dayTo, setDayTo] = useState(1); + // Курсор прохождения (витка): индекс в хронологическом списке passes; -1 — проход не выбран. + // Пока проход выбран, отображение изолировано до него; «Все» (clearPass) возвращает курсор в -1. + const [passCursor, setPassCursor] = useState(-1); + + // Фильтры видимости (статус/КА/ветвь). Единая модель «selected = visible»: множество = что + // видно, пусто = ничего. Дефолт — всё видно; satFilter инициализируется всеми КА при расчёте. + const [statusFilter, setStatusFilter] = useState>( + () => new Set(["free", "booked", "occupied"]) + ); + const [satFilter, setSatFilter] = useState>(() => new Set()); + const [branchFilter, setBranchFilter] = useState>( + () => new Set(["ASC", "DESC"]) + ); + const [calculating, setCalculating] = useState(false); const [autoFilling, setAutoFilling] = useState(false); const [booking, setBooking] = useState(false); - // Прогресс чанкованной брони «X/Y» и набор, ждущий подтверждения (крупная бронь). - const [bookProgress, setBookProgress] = useState(); + // Снятие выбранных броней пачками (request-aware batch) — отдельный флаг «в полёте». + const [cancellingSel, setCancellingSel] = useState(false); + // Прогресс крупной чанковой операции (бронь / снятие выбранных) — общий бар . + const [progress, setProgress] = useState<{ label: string; done: number; total: number } | null>(null); const [pendingBook, setPendingBook] = useState(null); + // «Снять всё» убирает ВСЕ брони заявки — необратимо, поэтому всегда через подтверждение. + // cancelling отделяет «снятие всего в полёте» от брони/снятия выбранных. + const [pendingCancel, setPendingCancel] = useState(false); + const [cancelling, setCancelling] = useState(false); const [error, setError] = useState(); const [notice, setNotice] = useState(); @@ -107,8 +150,12 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) { const wktCacheRef = useRef(new Map()); // Контроллер отмены текущего расчёта/автоподбора — прерывается при повторе или смене заявки. const abortRef = useRef(null); - // Якорь для выбора диапазона по Shift+клику (индекс в filteredSlots). - const anchorIndexRef = useRef(null); + // Якорь жеста «полоса от первого к последнему»: ключ первого кликнутого слота. Состояние (не ref): + // по нему подсвечиваем строки той же полосы, чтобы оператор видел границу выделения. + const [anchorKey, setAnchorKey] = useState(null); + // Последний конец Shift-диапазона в таблице: повторный Shift+клик по тому же концу снимает диапазон + // (toggle). Ref — для логики жеста, перерисовка не нужна; сбрасывается при смене якоря/набора. + const lastShiftEndRef = useRef(null); // Подсветка строки/контура по клику на карте и центрирование на слоте. const [activeSlotId, setActiveSlotId] = useState(); @@ -141,8 +188,14 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) { !Number.isFinite(parsedRange.toMs) || parsedRange.fromMs >= parsedRange.toMs; - // Ключи забронированных слотов — для бейджа «бронь» в таблице и отдельного слоя на карте. + // Ключи забронированных слотов — для бейджа «бронь» в таблице и слоя на карте. const bookedKeys = useMemo(() => new Set(bookedSlots.map(slotKey)), [bookedSlots]); + // Бронь по ключу слота — чтобы из выбранной/кликнутой строки достать bookedSlotId для снятия. + const bookedByKey = useMemo(() => { + const map = new Map(); + for (const slot of bookedSlots) map.set(slotKey(slot), slot); + return map; + }, [bookedSlots]); // id полигонов слотов, отобранных галочками под бронь — постоянная подсветка на карте. const markedSlotIds = useMemo( @@ -158,10 +211,37 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) { return map; }, [slots, bookedSlots]); - // Сутки, по которым раскладываются рассчитанные слоты (для фильтра отображения). + // Строки таблицы = кандидаты + брони, которых нет среди кандидатов (Finding 2-A). Так бронь + // выбираема и снимаема, даже если кандидаты ещё не рассчитаны или бронь в другом наборе. + const displaySlots = useMemo(() => { + const slotKeySet = new Set(slots.map(slotKey)); + const orphanBooked = bookedSlots.filter((slot) => !slotKeySet.has(slotKey(slot))); + return orphanBooked.length === 0 ? slots : [...slots, ...orphanBooked]; + }, [slots, bookedSlots]); + + // bookedSlotId выбранных броней (для «Снять выбранные», чанкуется) и число выбранных кандидатов + // под бронь (для «Забронировать»). Бронь и кандидат различаем по bookedKeys. + const selectedBookedIds = useMemo(() => { + const ids: number[] = []; + for (const key of selectedKeys) { + const booked = bookedByKey.get(key); + if (booked) ids.push(booked.bookedSlotId); + } + return ids; + }, [selectedKeys, bookedByKey]); + const selectedBookableCount = useMemo(() => { + let n = 0; + for (const key of selectedKeys) { + const slot = slotByKey.get(key); + if (!bookedKeys.has(key) && !(slot && slotIsBusy(slot))) n += 1; + } + return n; + }, [selectedKeys, bookedKeys, slotByKey]); + + // Сутки, по которым раскладываются строки таблицы (для фильтра отображения). const days = useMemo(() => { const buckets = new Map(); - for (const slot of slots) { + for (const slot of displaySlots) { const ms = parseLocal(slot.tn); if (!Number.isFinite(ms)) continue; const start = dayStartMs(ms); @@ -170,36 +250,216 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) { return Array.from(buckets.entries()) .sort(([a], [b]) => a - b) .map(([dayMs, count], idx) => ({ index: idx + 1, dayMs, count, label: dayLabel(dayMs) })); - }, [slots]); + }, [displaySlots]); - // Новый расчёт меняет набор суток — раскрываем фильтр на весь диапазон. + // Сигнатура набора суток (границы дней). Бронь меняет bookedSlots → новый референс days, но сам + // набор суток тот же — по сигнатуре отличаем «реально другой набор» от «то же, новый массив». + const dayKeys = useMemo(() => days.map((d) => d.dayMs).join(","), [days]); + + // Новый расчёт меняет НАБОР суток — раскрываем фильтр на весь диапазон. Сброс только при смене + // сигнатуры (новый расчёт/иной интервал), а не на каждое изменение bookedSlots после брони. useEffect(() => { + const count = dayKeys === "" ? 0 : dayKeys.split(",").length; setDayFrom(1); - setDayTo(days.length || 1); - }, [days]); + setDayTo(count || 1); + }, [dayKeys]); 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; + // Витки (проходы) расчёта в хронологическом порядке — для пошаговой навигации ◀/▶. + // Строим над СТАБИЛЬНЫМ displaySlots (как полосы), чтобы курсор доходил до любого витка + // независимо от фильтров видимости и окна суток. + const passes = useMemo(() => groupPasses(displaySlots, slotKey), [displaySlots]); + + // Новый расчёт меняет набор проходов — сбрасываем курсор (как окно суток по dayKeys). + useEffect(() => { + setPassCursor(-1); + }, [dayKeys]); + + // Курсор не должен «висеть» за пределами усохшего набора (бронь/смена набора): клампим к -1. + const effPassCursor = passCursor >= 0 && passCursor < passes.length ? passCursor : -1; + const currentPass = effPassCursor >= 0 ? passes[effPassCursor] : null; + // Ключи слотов текущего витка — для подсветки строк и режима изоляции. + const passKeySet = useMemo(() => new Set(currentPass?.keys ?? []), [currentPass]); + + // Слоты в окне суток (до фильтров видимости) — база для счётчиков статусов и фильтрации. + // Пока выбрано прохождение — показываем только его слоты (изоляция); иначе — окно суток. + const dayWindowed = useMemo(() => { + if (currentPass) { + return displaySlots.filter((slot) => passKeySet.has(slotKey(slot))); + } + if (days.length === 0) return displaySlots; const fromMs = days[effDayFrom - 1].dayMs; const toMsExcl = days[effDayTo - 1].dayMs + DAY_MS; - return slots.filter((slot) => { + return displaySlots.filter((slot) => { const ms = parseLocal(slot.tn); return Number.isFinite(ms) && ms >= fromMs && ms < toMsExcl; }); - }, [slots, days, effDayFrom, effDayTo]); + }, [displaySlots, days, effDayFrom, effDayTo, currentPass, passKeySet]); - // Виртуализация таблицы: в DOM держим только видимое окно строк (+overscan), а не все слоты — - // иначе на тысячах строк рендер встаёт. Скролл-контейнер — секция списка (listRef). - const rowVirtualizer = useVirtualizer({ - count: filteredSlots.length, - getScrollElement: () => listRef.current, - estimateSize: () => ROW_HEIGHT, - overscan: 12 - }); + // Счётчики статусов по окну суток (до статус-фильтра) — стабильны при переключении чипов. + const statusCounts = useMemo>(() => { + const counts: Record = { free: 0, booked: 0, occupied: 0 }; + for (const slot of dayWindowed) counts[slotStatusKey(slot, bookedKeys.has(slotKey(slot)))] += 1; + return counts; + }, [dayWindowed, bookedKeys]); + + // КА в наборе (для чипов фильтра); сигнатура — чтобы инициализировать satFilter только при + // смене набора КА (новый расчёт), а не при каждой брони. + const satellites = useMemo(() => { + const ids = new Set(); + for (const slot of displaySlots) ids.add(slot.satelliteId); + return Array.from(ids).sort((a, b) => a - b); + }, [displaySlots]); + const satSignature = satellites.join(","); + + // Новый набор КА → показываем все КА (бронирование набор КА не меняет, поэтому не сбрасываем). + // useLayoutEffect — чтобы satFilter раскрылся ДО отрисовки и не мигнул пустой таблицей. + useLayoutEffect(() => { + setSatFilter(new Set(satellites)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [satSignature]); + + // Слоты, видимые в таблице и на карте: окно суток + фильтры видимости (статус/КА/ветвь). + const filteredSlots = useMemo( + () => + applySlotFilters( + dayWindowed, + { status: statusFilter, satellites: satFilter, branches: branchFilter }, + (slot) => bookedKeys.has(slotKey(slot)), + ), + [dayWindowed, statusFilter, satFilter, branchFilter, bookedKeys], + ); + + // Ключи слотов, конфликтующих с текущим выбором (тот же КА + недостаточный зазор на манёвр) — гасим + // их как подсказку «станет недоступным». Приближённая клиентская эвристика; реальная выполнимость + // (slew/maxChainLength) — за бэкендом (it.2), поэтому слоты остаются выбираемыми. См. slotConflicts. + // + // Производительность: выбор берём через useDeferredValue — во время Shift+drag (selectedKeys меняется + // на каждый pointermove) пересчёт идёт фоном/после паузы, не блокируя жест. Кандидаты — только видимый + // filteredSlots; источник занятых интервалов — полный displaySlots (выбор может быть вне окна суток). + // Разметка полос — над СТАБИЛЬНЫМ набором displaySlots (НЕ filteredSlots/sortedSlots: иначе + // фильтр-дырка ложно разорвала или склеила бы полосу). Полоса = один КА + один крен + подряд по + // времени (виток не граница). idByKey — id полосы по ключу слота; membersOf — члены полосы по времени. + // Нужна и для конфликтов ниже: слоты одной полосы между собой не конфликтуют. + const polosa = useMemo(() => groupStrips(displaySlots, slotKey), [displaySlots]); + + const deferredSelectedKeys = useDeferredValue(selectedKeys); + const conflictKeys = useMemo( + () => + showConflicts + ? computeConflicts( + displaySlots, + filteredSlots, + slotKey, + (slot) => deferredSelectedKeys.has(slotKey(slot)), + (slot) => bookedKeys.has(slotKey(slot)) || slotIsBusy(slot), + (slot) => polosa.idByKey.get(slotKey(slot)), + ) + : new Set(), + [showConflicts, displaySlots, filteredSlots, deferredSelectedKeys, bookedKeys, polosa] + ); + // id полигонов конфликтных слотов — приглушение на карте (параллель markedSlotIds). + const dimmedSlotIds = useMemo( + () => new Set(Array.from(conflictKeys, (key) => `slot:${key}`)), + [conflictKeys] + ); + + // Конфликтные слоты нельзя добавлять в выбор под бронь (жёсткая блокировка). Держим текущий набор в + // ref, чтобы обработчики жестов (useCallback) читали его без пересоздания на каждый ререндер выбора. + const conflictKeysRef = useRef>(conflictKeys); + conflictKeysRef.current = conflictKeys; + // Убирает заблокированные (конфликтные) ключи из нового набора выбора. Уже выбранные слоты в + // conflictKeys не попадают, поэтому снятие выбора и прежний выбор не затрагиваются — режутся только + // конфликтные слоты, которые жест попытался добавить. Мутирует переданный (свежий) Set. + const dropConflicts = useCallback((next: Set): Set => { + const blocked = conflictKeysRef.current; + if (blocked.size > 0) for (const key of blocked) next.delete(key); + return next; + }, []); + + // Состояние жеста «выбор полосы мышью на карте» (Shift+drag). Держим в ref: правки идут на каждом + // движении указателя, перерисовка не нужна. members — члены полосы якоря без BUSY (их не бронируем); + // endKey — текущий конец (на старте = якорь; меняется в drag) — нужен commit, чтобы отличить + // одиночный клик (end === anchor) от протяжки. + const mapStripRef = useRef<{ anchorKey: string; anchorId: PolosaId; members: string[]; endKey: string } | null>(null); + // Последний конец одиночного Shift-клика на карте: повтор по тому же слоту снимает его (toggle). + const lastMapEndRef = useRef(null); + + // Shift+нажатие по слотам на карте: якорь полосы = самый ранний по времени выбираемый слот под + // курсором. Выбор этой полосы сбрасываем к одному якорю (далее drag растянет), как и в таблице. + const handleStripAnchor = useCallback( + (keys: string[]) => { + const tnMsOf = (key: string) => { + const slot = slotByKey.get(key); + return slot ? parseLocal(slot.tn) : Number.NaN; + }; + // Конфликтные слоты в выбор не берём — на них нельзя ни встать якорем, ни попасть в полосу. + const blocked = conflictKeysRef.current; + const selectable = keys.filter((key) => { + const slot = slotByKey.get(key); + return slot && !slotIsBusy(slot) && !blocked.has(key); + }); + const anchor = pickStripAnchor(selectable, tnMsOf); + const anchorId = anchor ? polosa.idByKey.get(anchor) : undefined; + if (!anchor || !anchorId) { + mapStripRef.current = null; + return; + } + const members = (polosa.membersOf.get(anchorId) ?? []).filter((key) => { + const slot = slotByKey.get(key); + return !(slot && slotIsBusy(slot)) && !blocked.has(key); + }); + mapStripRef.current = { anchorKey: anchor, anchorId, members, endKey: anchor }; + setAnchorKey(anchor); + lastShiftEndRef.current = null; + setSelectedKeys((current) => selectStripRange(current, members, anchor, anchor)); + }, + [polosa, slotByKey] + ); + + // Движение указателя в жесте: конец полосы = самый поздний по времени член ТОЙ ЖЕ полосы под + // курсором (граница полосы соблюдается). Никого из полосы под курсором — оставляем прежний конец. + const handleStripDrag = useCallback( + (keys: string[]) => { + const gesture = mapStripRef.current; + if (!gesture) return; + const end = pickStripEnd(polosa, gesture.anchorId, keys); + if (!end) return; + gesture.endKey = end; + setSelectedKeys((current) => selectStripRange(current, gesture.members, gesture.anchorKey, end)); + }, + [polosa] + ); + + const handleStripCommit = useCallback(() => { + const gesture = mapStripRef.current; + mapStripRef.current = null; + if (!gesture) return; + // Одиночный Shift+клик (без протяжки): end === anchor. Повтор по тому же слоту снимает его + // (anchor на pointerdown уже переотметил его — на commit гасим). Протяжку (диапазон) не тогглим. + const isClick = gesture.endKey === gesture.anchorKey; + if (isClick && lastMapEndRef.current === gesture.anchorKey) { + setSelectedKeys((current) => { + const next = new Set(current); + next.delete(gesture.anchorKey); + return next; + }); + lastMapEndRef.current = null; + } else { + lastMapEndRef.current = isClick ? gesture.anchorKey : null; + } + }, []); + + // Якорь жеста привязан к ВИДИМОМУ набору: смена фильтров/окна суток/брони делает прежний якорь + // неактуальным → сбрасываем (как при смене сортировки), иначе Shift выделил бы не ту полосу. + useEffect(() => { + setAnchorKey(null); + mapStripRef.current = null; + lastShiftEndRef.current = null; + lastMapEndRef.current = null; + }, [filteredSlots]); // Загрузка заявок интервала (на карту); вызывается на входе, при смене диапазона и из фильтра. const loadRequests = useCallback(async (fromInput: string, toInput: string) => { @@ -230,16 +490,6 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) { void loadRequests(fromInput, toInput); }, [range.fromMs, range.toMs, loadRequests]); - // Клик по слоту на карте подсвечивает строку — центрируем её в видимой части таблицы. - // Скроллим контейнер вручную (по rect-ам), а не через scrollIntoView: тот может выбрать другой - // скролл-предок или счесть строку «достаточно близкой» и не сдвинуться. - useEffect(() => { - if (!activeSlotId || !activeSlotId.startsWith("slot:")) return; - const key = activeSlotId.slice("slot:".length); - const index = filteredSlots.findIndex((slot) => slotKey(slot) === key); - if (index >= 0) rowVirtualizer.scrollToIndex(index, { align: "center" }); - }, [activeSlotId, filteredSlots, rowVirtualizer]); - const focusOn = useCallback((point?: GeoPoint) => { if (!point) return; setFocusPoint(point); @@ -258,6 +508,156 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) { return rings; }, []); + // Сетка сэмплов покрытия по геометрии заявки (кэш на заявку, D1). null — заявка без геометрии + // или дегенерат (0 валидных сэмплов) → панель «недоступно». + const coverageGrid = useMemo( + () => (selectedRequest?.geometry ? buildCoverageGrid(parseWktPolygon(selectedRequest.geometry)) : null), + [selectedRequest?.geometry] + ); + + // Маска слота на сетке заявки, с кэшем по контуру (D2 — сигнатура grid+контур). Кэш живёт ровно + // пока стабильна сетка: новый coverageGrid (смена заявки/геометрии) пересоздаёт Map → чистка. + const maskFor = useMemo(() => { + const grid = coverageGrid; + const cache = new Map(); + return (key: string, contour: string): SlotMask | null => { + if (!grid) return null; + let mask = cache.get(contour); + if (!mask) { + mask = sampleSlotMask(grid, ringsFor(key, contour)); + cache.set(contour, mask); + } + return mask; + }; + }, [coverageGrid, ringsFor]); + + // Кольца следа покрытия для карты = union контуров (забронированное ∪ выбранное галочками), + // независимо от фильтров вида (codex P1). Пусто, когда тумблер выключен или нет заявки. + const coverageRings = useMemo(() => { + if (!coverageOn || !hasRequest) return undefined; + const rings: GeoPoint[][] = []; + const seen = new Set(); + const push = (key: string, contour: string) => { + if (seen.has(key)) return; + seen.add(key); + for (const ring of ringsFor(key, contour)) if (ring.length >= 3) rings.push(ring); + }; + for (const slot of bookedSlots) push(slotKey(slot), slot.contour); + for (const key of selectedKeys) { + const slot = slotByKey.get(key); + if (slot) push(key, slot.contour); + } + return rings; + }, [coverageOn, hasRequest, bookedSlots, selectedKeys, slotByKey, ringsFor]); + + // % покрытия для панели: забронированным набором и набором (забронированное ∪ выбранное). + // Семантика codex: % = покрытие union(booked ∪ selected), независимо от фильтров вида. + const coverageStats = useMemo(() => { + if (!coverageGrid) return { booked: null, withSelection: null, hasSelection: false }; + const bookedMasks: SlotMask[] = []; + for (const slot of bookedSlots) { + const mask = maskFor(slotKey(slot), slot.contour); + if (mask) bookedMasks.push(mask); + } + const selMasks: SlotMask[] = []; + let hasSelection = false; + for (const key of selectedKeys) { + if (bookedKeys.has(key)) continue; // бронь уже учтена + const slot = slotByKey.get(key); + if (!slot) continue; + const mask = maskFor(key, slot.contour); + if (mask) { + selMasks.push(mask); + hasSelection = true; + } + } + return { + booked: coverageOfMasks(coverageGrid, bookedMasks), + withSelection: coverageOfMasks(coverageGrid, [...bookedMasks, ...selMasks]), + hasSelection, + }; + }, [coverageGrid, maskFor, bookedSlots, selectedKeys, bookedKeys, slotByKey]); + + // Покрытие доступно (есть геометрия заявки) — гейт сортировки «по вкладу» и колонки +N%. + const coverageAvailable = coverageGrid !== null; + + // Ранжирование «по вкладу»: жадно (rankByMarginalGain) поверх забронированного union по ВИДИМЫМ + // незабронированным кандидатам. Вклад фиксируется на момент ранжирования (не live — отсюда подпись + // колонки). Считаем только в режиме сортировки «вклад» и при наличии сетки. key → gainPercent + ранг. + const gainRanking = useMemo(() => { + if (sortMode !== "gain" || !coverageGrid) return null; + const bookedMasks: SlotMask[] = []; + for (const slot of bookedSlots) { + const mask = maskFor(slotKey(slot), slot.contour); + if (mask) bookedMasks.push(mask); + } + const baseUnion = unionMasks(coverageGrid, bookedMasks); + const candidates: RankCandidate[] = []; + for (const slot of filteredSlots) { + const key = slotKey(slot); + if (bookedKeys.has(key)) continue; // бронь уже в baseUnion → её вклад 0 + const mask = maskFor(key, slot.contour); + if (mask) candidates.push({ key, mask }); + } + const ranked = rankByMarginalGain(coverageGrid, candidates, baseUnion); + const order = new Map(); + const pct = new Map(); + ranked.forEach((r, i) => { + order.set(r.key, i); + pct.set(r.key, r.gainPercent); + }); + return { order, pct }; + }, [sortMode, coverageGrid, maskFor, bookedSlots, filteredSlots, bookedKeys]); + + // Колонку +N% показываем только в режиме «вклад» и при наличии геометрии. + const showGain = sortMode === "gain" && coverageAvailable; + + // Порядок строк таблицы по выбранной сортировке. «время» — исходный (порядок расчёта, как есть). + // «крен» — по модулю крена (меньше = лучше геометрия), стабильно. «вклад» — жадный ранг; брони + // (вне ранжирования) уходят в конец, сохраняя исходный порядок. sort стабилен (ES2019+). + const sortedSlots = useMemo(() => { + if (sortMode === "roll") { + return [...filteredSlots].sort((a, b) => Math.abs(a.roll) - Math.abs(b.roll)); + } + if (sortMode === "gain" && gainRanking) { + const order = gainRanking.order; + return [...filteredSlots].sort((a, b) => { + const ra = order.get(slotKey(a)); + const rb = order.get(slotKey(b)); + if (ra == null && rb == null) return 0; + if (ra == null) return 1; + if (rb == null) return -1; + return ra - rb; + }); + } + return filteredSlots; + }, [sortMode, filteredSlots, gainRanking]); + + // Виртуализация таблицы: в DOM держим только видимое окно строк (+overscan), а не все слоты — + // иначе на тысячах строк рендер встаёт. Скролл-контейнер — секция списка (listRef). + const rowVirtualizer = useVirtualizer({ + count: sortedSlots.length, + getScrollElement: () => listRef.current, + estimateSize: () => ROW_HEIGHT, + overscan: 12 + }); + + // Клик по слоту на карте подсвечивает строку — центрируем её в видимой части таблицы. + // Скроллим контейнер вручную (по rect-ам), а не через scrollIntoView: тот может выбрать другой + // скролл-предок или счесть строку «достаточно близкой» и не сдвинуться. + useEffect(() => { + if (!activeSlotId || !activeSlotId.startsWith("slot:")) return; + const key = activeSlotId.slice("slot:".length); + const index = sortedSlots.findIndex((slot) => slotKey(slot) === key); + if (index >= 0) rowVirtualizer.scrollToIndex(index, { align: "center" }); + }, [activeSlotId, sortedSlots, rowVirtualizer]); + + // Переход на прохождение прокручивает таблицу к началу (без подсветки строки): оператор видит + // первый слот изолированного прохождения. Только при выбранном проходе — иначе скролл не трогаем. + useEffect(() => { + if (effPassCursor >= 0) rowVirtualizer.scrollToIndex(0, { align: "start" }); + }, [effPassCursor, rowVirtualizer]); + // Загружает брони выбранной заявки (для бейджей и слоя карты). const loadBooked = useCallback(async (requestId: string) => { try { @@ -274,14 +674,19 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) { useEffect(() => { abortRef.current?.abort(); wktCacheRef.current.clear(); - anchorIndexRef.current = null; + setAnchorKey(null); setSlots([]); setSelectedKeys(new Set()); setBookedSlots([]); setPendingBook(null); + setPendingCancel(false); setNotice(undefined); setError(undefined); setActiveSlotId(undefined); + // Смена заявки — фильтры видимости в дефолт (satFilter раскроется через satSignature-эффект). + setStatusFilter(new Set(["free", "booked", "occupied"])); + setBranchFilter(new Set(["ASC", "DESC"])); + setSortMode("time"); if (selectedRequestId) void loadBooked(selectedRequestId); }, [selectedRequestId, loadBooked]); @@ -316,7 +721,7 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) { abortRef.current?.abort(); const ctrl = new AbortController(); abortRef.current = ctrl; - anchorIndexRef.current = null; + setAnchorKey(null); if (auto) setAutoFilling(true); else setCalculating(true); setError(undefined); @@ -328,7 +733,11 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) { timeStart: toLocalIso(parsedRange.fromMs), timeStop: toLocalIso(parsedRange.toMs), revSign: revSign || undefined, - cov: withCoverage, + // cov=true (режим покрытия) ровно когда задана стратегия — это путь «Подобрать + // покрытие». «Показать все слоты» идёт без стратегии и получает полный набор кандидатов, + // включая занятые бронью (all=true) — занятые показываем для обзора, бронировать нельзя. + cov: auto, + all: !auto, coverageStrategy }, ctrl.signal @@ -351,9 +760,9 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) { : "Для выбранного интервала слотов покрытия не найдено." ); } - if (result.length > 0) { - focusOn({ lat: result[0].latitude, lon: result[0].longitude }); - } + // Карту НЕ перецентрируем: result[0] — первый по времени/витку, географически произвольный + // угол, а зум до 4.5 спрятал бы остальной набор. Оставляем общий обзор заявки на месте; + // прицельный фокус — по клику на строку таблицы (focusOn в onRowClick). } catch (caught) { // Отмена (повтор/смена заявки) — это не ошибка, молча выходим. if (ctrl.signal.aborted || (caught instanceof DOMException && caught.name === "AbortError")) return; @@ -370,29 +779,34 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) { const autoCover = () => void runCover(strategy); const cancelCalculation = () => abortRef.current?.abort(); - // Бронирует набор чанками (бэк бронирует слоты по одному в одной транзакции — крупный набор - // бьём на части, чтобы не упереться в таймаут/долгую транзакцию) и показывает прогресс. + // Любая операция с бронью «в полёте» — общий гейт блокировки кнопок (бронь / снятие выбранных / + // снятие всего). Отделён от busy (расчёт/автоподбор). + const opBusy = booking || cancelling || cancellingSel; + + // Бронирует набор чанками (бэк бронирует по одному в транзакции — крупный набор бьём на части + // через runChunked) и показывает прогресс. bookedSlotId берём из ответа POST. const performBook = async (toBook: SlotBookingRequest[]) => { setBooking(true); setError(undefined); setNotice(undefined); - setBookProgress(toBook.length > BOOK_CHUNK ? `0/${toBook.length}` : undefined); + setProgress(toBook.length > BOOK_CHUNK ? { label: "Бронирование", done: 0, total: toBook.length } : null); try { - const collected: BookedSlotDTO[] = []; - let done = 0; - for (const part of chunk(toBook, BOOK_CHUNK)) { - const booked = await bookRequestSlots(selectedRequestId, part); - collected.push(...booked); - done += part.length; - if (toBook.length > BOOK_CHUNK) setBookProgress(`${done}/${toBook.length}`); - } - // Источник истины — ответ POST: BookedSlotDTO несёт тот же cycle, что мы отправили (= cycle - // рассчитанного слота). А вот GET booking/by-request отдаёт базовый слот в ДРУГОМ цикле - // (slot.toDTO без сдвига на cycle брони), из-за чего ключ sat:slotNum:cycle не совпадает и - // бейдж «бронь» не появляется. Поэтому отмечаем забронированными ровно те рассчитанные слоты - // (с контуром), что вернул POST, не перечитывая by-request. - const bookedSet = new Set(collected.map((item) => `${item.satelliteId}:${item.slotNum}:${item.cycle}`)); - const justBooked = slots.filter((slot) => bookedSet.has(slotKey(slot))); + const parts = await runChunked( + toBook, + BOOK_CHUNK, + (part) => bookRequestSlots(selectedRequestId, part), + (done, total) => setProgress({ label: "Бронирование", done, total }), + ); + const collected = parts.flat(); + // Источник истины — ответ POST: BookedSlotDTO несёт bookedSlotId и тот же cycle, что мы + // отправили (= cycle рассчитанного слота). Контур берём из рассчитанного слота по ключу; + // by-request не перечитываем. Так bookedSlots получает и контур, и bookedSlotId. + const idByKey = new Map( + collected.map((item) => [`${item.satelliteId}:${item.slotNum}:${item.cycle}`, item.bookedSlotId]), + ); + const justBooked: BookedSlotView[] = slots + .filter((slot) => idByKey.has(slotKey(slot))) + .map((slot) => ({ ...slot, bookedSlotId: idByKey.get(slotKey(slot))! })); setBookedSlots((prev) => { const merged = new Map(prev.map((slot) => [slotKey(slot), slot])); for (const slot of justBooked) merged.set(slotKey(slot), slot); @@ -404,25 +818,24 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) { setError(caught instanceof Error ? caught.message : "Не удалось забронировать слоты."); } finally { setBooking(false); - setBookProgress(undefined); + setProgress(null); } }; + // Выбранные кандидаты под бронь (без уже забронированных — их бронировать незачем). const collectSelected = (): SlotBookingRequest[] => slots - .filter((slot) => selectedKeys.has(slotKey(slot))) + .filter( + (slot) => selectedKeys.has(slotKey(slot)) && !bookedKeys.has(slotKey(slot)) && !slotIsBusy(slot), + ) .map((slot) => ({ satelliteId: slot.satelliteId, slotNum: slot.slotNumber, cycle: slot.cycle })); const book = () => { - if (!selectedRequestId || booking || selectedKeys.size === 0) return; + if (!selectedRequestId || opBusy || selectedBookableCount === 0) return; const toBook = collectSelected(); if (toBook.length === 0) return; - // Крупная бронь — сначала подтверждение, потом чанкованный POST. - if (toBook.length > BOOK_CONFIRM_THRESHOLD) { - setPendingBook(toBook); - return; - } - void performBook(toBook); + // Бронь — всегда через подтверждение (симметрично «Снять всё»), затем чанкованный POST. + setPendingBook(toBook); }; const confirmPendingBook = () => { @@ -432,49 +845,182 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) { }; const cancelPendingBook = () => setPendingBook(null); - const cancelBooking = async () => { - if (!selectedRequestId || booking || bookedSlots.length === 0) return; - setBooking(true); + // Общий чанковый движок снятия: бьёт ids на части (request-aware batch), показывает прогресс + // (тот же бар, что у брони), затем убирает снятые из bookedSlots и из выбора. На него опираются + // и «Снять выбранные», и «Снять всё», и ✕ в строке — снятие везде пачками с прогрессом, а не + // одним bulk-запросом (по одному HTTP нельзя показать ход на 7000 слотов). setBusy — флаг кнопки. + const runCancel = async (ids: number[], setBusy: (value: boolean) => void, forceProgress = false) => { + setBusy(true); setError(undefined); setNotice(undefined); + // forceProgress — для «Снять всё»: держим бар весь процесс независимо от объёма (операция + // делает доп. сверку by-request, поэтому заметно дольше точечного снятия даже на малом наборе). + setProgress(forceProgress || ids.length > BOOK_CHUNK ? { label: "Снятие брони", done: 0, total: ids.length } : null); try { - const cancelled = await cancelRequestBooking(selectedRequestId); - // Снимаются все брони заявки — очищаем локально (by-request не перечитываем, см. book()). - setBookedSlots([]); - setNotice(`Снято броней: ${cancelled}.`); + const counts = await runChunked( + ids, + BOOK_CHUNK, + (part) => cancelBookedSlots(selectedRequestId, part), + (done, total) => setProgress({ label: "Снятие брони", done, total }), + ); + const removed = counts.reduce((a, b) => a + b, 0); + const idSet = new Set(ids); + setSelectedKeys((prev) => { + const next = new Set(prev); + for (const slot of bookedSlots) if (idSet.has(slot.bookedSlotId)) next.delete(slotKey(slot)); + return next; + }); + setBookedSlots((prev) => prev.filter((slot) => !idSet.has(slot.bookedSlotId))); + setNotice(`Снято броней: ${removed}.`); } catch (caught) { setError(caught instanceof Error ? caught.message : "Не удалось снять бронь."); } finally { - setBooking(false); + setBusy(false); + setProgress(null); } }; - // Отметка слота из таблицы. Shift+клик — выбрать диапазон от якоря (предыдущего клика) до - // текущей строки; направление (выбрать/снять) задаётся новым состоянием текущей строки. - const handleSlotCheck = (index: number, shiftKey: boolean) => { - setSelectedKeys((current) => - selectionAfterCheck(current, (i) => slotKey(filteredSlots[i]), index, shiftKey, anchorIndexRef.current) - ); - anchorIndexRef.current = index; + // Снять выбранные брони (по галочкам). Выбор явный — без подтверждения. + const cancelSelected = () => { + if (!selectedRequestId || opBusy || selectedBookedIds.length === 0) return; + void runCancel(selectedBookedIds, setCancellingSel); }; - // Чекбокс шапки — выбрать/снять все ВИДИМЫЕ (по окну суток) слоты. + // Снять одну бронь из строки таблицы — батч из одного id (обратимо ре-бронью, без подтверждения). + const cancelOne = (bookedSlotId: number) => { + if (!selectedRequestId || opBusy) return; + void runCancel([bookedSlotId], setCancellingSel); + }; + + // Снять ВСЕ брони заявки — тоже пачками (request-aware batch) с прогрессом. Необратимо → + // подтверждение. Отдельный флаг cancelling, чтобы «Снятие…» горело именно на этой кнопке. + // Источник истины — backend: перечитываем брони by-request и снимаем ОБЪЕДИНЕНИЕ локального + // набора и серверного. Так «Снять всё» убирает вообще все брони заявки, даже если локальный + // bookedSlots разошёлся с базой (bookedSlotId надёжен независимо от cycle-фрейма брони). + const performCancelAll = async () => { + // Мгновенная обратная связь (как у «Забронировать»/«Снять выбранные»): входим в busy и + // показываем прогресс СРАЗУ, ещё до сетевой сверки by-request, а не после её round-trip. + setCancelling(true); + setError(undefined); + setNotice(undefined); + setProgress({ label: "Снятие брони", done: 0, total: bookedSlots.length }); + const idSet = new Set(bookedSlots.map((slot) => slot.bookedSlotId)); + try { + const fresh = await fetchBookedSlotsByRequest(selectedRequestId); + for (const slot of fresh) idSet.add(slot.bookedSlotId); + } catch { + // by-request недоступен — снимаем по локальному набору броней. + } + await runCancel(Array.from(idSet), setCancelling, true); + }; + + const cancelAll = () => { + if (!selectedRequestId || opBusy || bookedSlots.length === 0) return; + setPendingCancel(true); + }; + const confirmPendingCancel = () => { + setPendingCancel(false); + void performCancelAll(); + }; + const cancelPendingCancel = () => setPendingCancel(false); + + // Отметка слота из таблицы. + // • Обычный клик — переключает один слот и ставит его якорем полосы. + // • Shift+клик — жест «полоса от первого к последнему»: выделяет всех членов ОДНОЙ полосы между + // якорем и текущим слотом (включительно). Если текущий слот из другой полосы (другой КА/крен/ + // разрыв по времени) — выбор не меняем и подсказываем: выйти за полосу нельзя. + const handleSlotCheck = (index: number, shiftKey: boolean) => { + const targetKey = slotKey(sortedSlots[index]); + if (shiftKey && anchorKey) { + const anchorId = polosa.idByKey.get(anchorKey); + const targetId = polosa.idByKey.get(targetKey); + if (!anchorId || anchorId !== targetId) { + setNotice("Выделить можно только в пределах одной полосы (один КА, один крен, подряд по времени)."); + return; // якорь оставляем — оператор может выбрать другой конец той же полосы + } + // Члены полосы по времени, без BUSY (их бронировать нельзя — в выделение не берём). + const members = (polosa.membersOf.get(anchorId) ?? []).filter((k) => { + const slot = slotByKey.get(k); + return !(slot && slotIsBusy(slot)); + }); + if (lastShiftEndRef.current === targetKey) { + // Повторный Shift+клик по тому же концу — снимаем выделенный диапазон [якорь..конец]. + setSelectedKeys((current) => deselectStripRange(current, members, anchorKey, targetKey)); + lastShiftEndRef.current = null; + } else { + // Конфликтные члены полосы в выбор не берём (dropConflicts) — их выбрать нельзя. + setSelectedKeys((current) => dropConflicts(selectStripRange(current, members, anchorKey, targetKey))); + lastShiftEndRef.current = targetKey; + } + // Якорь НЕ двигаем: остаётся первым кликом, чтобы повторный Shift+клик задавал диапазон + // от того же якоря — ближе к нему «сжимал» выделение, дальше — расширял. + return; + } + // Обычный клик по конфликтному слоту — выбрать нельзя (снять выбор не блокируем: конфликтные + // слоты в выбор не попадают, значит targetKey тут заведомо не выбран). + if (conflictKeysRef.current.has(targetKey)) { + setNotice("Слот конфликтует с выбором (не хватает времени на манёвр того же КА) — выбрать нельзя."); + return; + } + setSelectedKeys((current) => + selectionAfterCheck(current, (i) => slotKey(sortedSlots[i]), index, false, null) + ); + setAnchorKey(targetKey); + lastShiftEndRef.current = null; + lastMapEndRef.current = null; + }; + + // Чекбокс шапки — выбрать/снять все ВИДИМЫЕ (по окну суток) выбираемые слоты. BUSY (занятые + // бронью) бронировать нельзя — в массовый выбор не берём. + const selectableVisible = useMemo(() => filteredSlots.filter((slot) => !slotIsBusy(slot)), [filteredSlots]); const allVisibleSelected = - filteredSlots.length > 0 && filteredSlots.every((slot) => selectedKeys.has(slotKey(slot))); + selectableVisible.length > 0 && selectableVisible.every((slot) => selectedKeys.has(slotKey(slot))); const toggleAllVisible = () => { setSelectedKeys((current) => { const next = new Set(current); - if (allVisibleSelected) for (const slot of filteredSlots) next.delete(slotKey(slot)); - else for (const slot of filteredSlots) next.add(slotKey(slot)); + if (allVisibleSelected) { + for (const slot of selectableVisible) next.delete(slotKey(slot)); + } else { + for (const slot of selectableVisible) next.add(slotKey(slot)); + dropConflicts(next); // конфликтные видимые слоты не добавляем + } return next; }); - anchorIndexRef.current = null; + setAnchorKey(null); }; - // Выбрать все слоты расчёта (по всем суткам), снять весь выбор. - const selectAllDays = () => setSelectedKeys(new Set(slots.map(slotKey))); + + // Выбрать все слоты расчёта (по всем суткам, кроме занятых бронью и конфликтных видимых), снять весь + // выбор. Конфликты считаются по видимому набору, поэтому режем известные (dropConflicts) — бэкенд + // остаётся финальным арбитром выполнимости. + const selectAllDays = () => + setSelectedKeys(dropConflicts(new Set(displaySlots.filter((slot) => !slotIsBusy(slot)).map(slotKey)))); const clearSelection = () => { setSelectedKeys(new Set()); - anchorIndexRef.current = null; + setAnchorKey(null); + }; + + // Тогглы фильтров видимости (selected = visible: клик добавляет/убирает значение из множества). + const toggleInSet = (set: ReadonlySet, value: T): Set => { + const next = new Set(set); + if (next.has(value)) next.delete(value); + else next.add(value); + return next; + }; + // Смена сортировки меняет позиции строк — якорь Shift-выбора больше не валиден, сбрасываем. + const changeSort = (mode: SlotSortMode) => { + setSortMode(mode); + setAnchorKey(null); + }; + const toggleStatus = (key: SlotStatusKey) => setStatusFilter((prev) => toggleInSet(prev, key)); + const toggleSat = (id: number) => setSatFilter((prev) => toggleInSet(prev, id)); + const toggleBranch = (branch: RevolutionSign) => setBranchFilter((prev) => toggleInSet(prev, branch)); + // Фильтры в дефолте — всё видно (на всех трёх осях выбраны все значения). + const filtersDefault = + statusFilter.size === 3 && branchFilter.size === 2 && satFilter.size === satellites.length; + const resetFilters = () => { + setStatusFilter(new Set(["free", "booked", "occupied"])); + setBranchFilter(new Set(["ASC", "DESC"])); + setSatFilter(new Set(satellites)); }; // Сколько выбрано из видимых суток и сколько — на других сутках (для прозрачности охвата). @@ -490,6 +1036,48 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) { focusOn({ lat: slot.latitude, lon: slot.longitude }); }; + // Переход на прохождение по индексу: ставит курсор и подтягивает окно суток к проходу (сохраняя + // ширину). Никакой слот НЕ подсвечиваем — само прохождение изолирует таблицу, а активная подсветка + // одной строки появляется только по явному клику оператора. Карту НЕ двигаем — оператор листает + // прохождения, не теряя текущий обзор/зум; центрирование осталось только на клике по слоту. + const goToPass = useCallback( + (target: number) => { + if (passes.length === 0) return; + const idx = Math.max(0, Math.min(target, passes.length - 1)); + setPassCursor(idx); + const pass = passes[idx]; + // Окно суток следует за курсором: если проход вне окна — сдвигаем, сохраняя ширину диапазона. + if (days.length > 0 && Number.isFinite(pass.startMs)) { + const di = days.findIndex((d) => d.dayMs === dayStartMs(pass.startMs)); + if (di >= 0) { + const dayIndex = di + 1; + const width = effDayTo - effDayFrom; + if (dayIndex < effDayFrom) { + setDayFrom(dayIndex); + setDayTo(Math.min(days.length, dayIndex + width)); + } else if (dayIndex > effDayTo) { + setDayTo(dayIndex); + setDayFrom(Math.max(1, dayIndex - width)); + } + } + } + // Снимаем активную подсветку строки с прошлого прохождения: при переходе не должен светиться + // никакой слот (прокрутку к началу делает отдельный эффект по effPassCursor, без подсветки). + setActiveSlotId(undefined); + }, + [passes, days, effDayFrom, effDayTo] + ); + + // ◀ из «не выбран» входит с конца, ▶ — с начала; дальше клампим в [0, last]. + const prevPass = () => goToPass(effPassCursor < 0 ? passes.length - 1 : effPassCursor - 1); + const nextPass = () => goToPass(effPassCursor < 0 ? 0 : effPassCursor + 1); + const clearPass = () => setPassCursor(-1); + const passLabel = currentPass + ? `КА ${currentPass.satelliteId} · прохождение ${currentPass.revolution} ${ + currentPass.revolutionSign === "DESC" ? "нисх" : "восх" + } · ${formatLocalDateTimeSeconds(currentPass.startMs)}` + : ""; + // Выбор заявки из инспектора — закрываем инспектор, переходим в режим расчёта слотов. const chooseRequest = useCallback((requestId: string) => { setSelectedRequestId(requestId); @@ -547,6 +1135,8 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) { }); } } + // Рисуем все отфильтрованные слоты без потолка: общую картину не режем, объём сужает + // оператор фильтрами (статус/КА/ветвь/сутки). Таблица виртуализирована, карта — canvas. for (const slot of filteredSlots) { const key = slotKey(slot); for (const ring of ringsFor(key, slot.contour)) { @@ -563,27 +1153,47 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) { }); } } - // Брони, которых нет в текущем расчёте (напр. до нажатия «Рассчитать»), — показываем отдельно. - 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 ringsFor(key, 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++ - }); + // Брони вне текущего окна суток (их нет в filteredSlots) — показываем на карте отдельным слоем, + // чтобы оператор видел все брони заявки. Те, что в окне, уже нарисованы циклом выше. НО слой + // подчиняем фильтрам видимости: выключен статус «бронь» (или скрыт КА/ветвь) — бронь не рисуем, + // иначе фильтр убирал бы бронь из таблицы, но не с карты. + if (statusFilter.has("booked")) { + const slotKeysShown = new Set(filteredSlots.map(slotKey)); + for (const slot of bookedSlots) { + const key = slotKey(slot); + if (slotKeysShown.has(key)) continue; + if (!satFilter.has(slot.satelliteId)) continue; + if (!branchFilter.has(slot.revolutionSign)) continue; + for (const ring of ringsFor(key, 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, ringsFor]); + return { polygons, lines: [], markers: [], coverageRings }; + }, [ + hasRequest, + requestItems, + selectedRequest, + slots, + filteredSlots, + bookedSlots, + bookedKeys, + ringsFor, + statusFilter, + satFilter, + branchFilter, + coverageRings + ]); const layers = useMemo( () => ({ @@ -679,16 +1289,17 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) { ? "Загрузка заявок…" : undefined : slots.length === 0 && bookedSlots.length === 0 - ? "Слоты не рассчитаны — нажмите «Рассчитать слоты»." + ? "Слоты не рассчитаны — нажмите «Показать все слоты» или «Подобрать покрытие»." : undefined; + // Число колонок таблицы (для colSpan распорок виртуализатора) — +1 на колонку «Вклад». + const colCount = showGain ? 8 : 7; + // Видимое окно строк виртуализатора и распорки сверху/снизу (держат суммарную высоту списка). const virtualRows = rowVirtualizer.getVirtualItems(); const virtualTotal = rowVirtualizer.getTotalSize(); const padTop = virtualRows.length > 0 ? virtualRows[0].start : 0; const padBottom = virtualRows.length > 0 ? virtualTotal - virtualRows[virtualRows.length - 1].end : 0; - // Слишком много полигонов в окне суток — подсказываем сузить (карту не блокируем). - const mapOverloaded = filteredSlots.length > MAP_HINT_THRESHOLD; return (
@@ -723,19 +1334,11 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) { - - -