Merge branch 'feat/tgu-slots-filters' into 'dev'
fix(tgu-ui): таблица слотов — слить виток/цикл, секунды в начале, длительность See merge request 2-6/2-6-4/missionis/observatio-terrae!3
This commit is contained in:
@@ -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
|
||||
|
||||
Executable
+24
@@ -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"
|
||||
@@ -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}
|
||||
|
||||
@@ -68,10 +68,12 @@ spring:
|
||||
codec:
|
||||
max-in-memory-size: 20MB
|
||||
|
||||
# Все обращения к другим сервисам — строго через gateway (StripPrefix снимает /api/pcp-<x>).
|
||||
# Напрямую по адресам сервисов 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-<x>).
|
||||
# Напрямую по адресам сервисов 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:
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
<!-- /autoplan restore point: /home/dasolovyev/.gstack/projects/missionis-observatio-terrae/feat-tgu-slots-filters-autoplan-restore-20260616-165024.md -->
|
||||
|
||||
# Эргономика выбора слотов: выбор «полосами» + правило выполнимости брони
|
||||
|
||||
## Проблема (как сформулировал оператор)
|
||||
Сделана работа по сортировке, подсветке брони, показу забронированных, расчёту %
|
||||
покрытия. Но **сам выбор слотов под бронь по-прежнему неудобен**: сейчас оператор
|
||||
отмечает слоты галочками поштучно (или 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<slotKey>` в [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<slotKey, StripId>` — максимальные 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 на модель карты |
|
||||
@@ -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-<x>` (код уже строит `$base/<path>`, gateway снимает префикс `StripPrefix=2`). Аналогично проверить и перевести прочие сервисы, которые ходят к чужим API напрямую по `pcp.services.<x>` (кандидаты: mission-planing, tgu, complex-mission, dynamic-plan и т.п.) — для каждого целевого API завести gateway-маршрут `/api/pcp-<x>/**` и репойнтить их `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 не пересчитывает — корректно. <br>_Исходный отчёт:_ При бронировании набора слотов фактическое время брони расходится с тем, что оператор видел/выбирал в таблице расчёта. Корень — та же рассинхронизация 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`. Док: интеракции обновлены. Известный мелкий зазор: заявка, созданная уже просроченной, события не шлёт (вырожденный кейс)._
|
||||
|
||||
+28
@@ -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,
|
||||
)
|
||||
+4
-1
@@ -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<SlotHolderDTO>? = null,
|
||||
)
|
||||
+12
@@ -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,
|
||||
)
|
||||
Executable
+258
@@ -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/<name>.log, pid: logs/<name>.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/<name>.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/<name>.log`, pid — в `logs/<name>.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"
|
||||
@@ -0,0 +1 @@
|
||||
requests.json
|
||||
+1
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<number> {
|
||||
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<number> {
|
||||
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<number> {
|
||||
* Загружает уже забронированные слоты заявки (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<SlotDTO[]> {
|
||||
export async function fetchBookedSlotsByRequest(requestId: string): Promise<BookedSlotView[]> {
|
||||
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<Slot
|
||||
throw new Error(await readErrorMessage(response, 'Не удалось загрузить брони заявки'))
|
||||
}
|
||||
|
||||
return response.json() as Promise<SlotDTO[]>
|
||||
return response.json() as Promise<BookedSlotView[]>
|
||||
}
|
||||
|
||||
@@ -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<HTMLDivElement>) {
|
||||
e.stopPropagation();
|
||||
dragRef.current = which;
|
||||
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
|
||||
},
|
||||
onPointerMove(e: React.PointerEvent<HTMLDivElement>) {
|
||||
if (!(e.currentTarget as HTMLDivElement).hasPointerCapture(e.pointerId)) return;
|
||||
const idx = indexAt(e.clientX);
|
||||
if (isFrom) {
|
||||
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<HTMLDivElement>) {
|
||||
dragRef.current = null;
|
||||
(e.currentTarget as HTMLDivElement).releasePointerCapture(e.pointerId);
|
||||
},
|
||||
});
|
||||
@@ -111,13 +124,13 @@ export function MapPassSelector({ passes, fromIndex, toIndex, onFromChange, onTo
|
||||
<div
|
||||
className="map-pass-selector__thumb"
|
||||
style={{ left: `${fromPct}%` }}
|
||||
{...makeHandlers(true)}
|
||||
{...makeHandlers("from")}
|
||||
/>
|
||||
{/* To handle */}
|
||||
<div
|
||||
className="map-pass-selector__thumb"
|
||||
style={{ left: `${toPct}%` }}
|
||||
{...makeHandlers(false)}
|
||||
{...makeHandlers("to")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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<string>;
|
||||
/** Приглушённые объекты — напр. слоты, конфликтующие с текущим выбором (подсказка «недоступно»). */
|
||||
dimmedObjectIds?: ReadonlySet<string>;
|
||||
/** Точка, на которую центрировать карту; перецентрирование срабатывает при смене 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<HTMLCanvasElement | null>(null);
|
||||
const dragRef = useRef<DragState | undefined>(undefined);
|
||||
const hasDraggedRef = useRef(false);
|
||||
// Активен жест «выбор полосы» (Shift+drag по слотам): пока true — панорама и обычный клик подавлены.
|
||||
const stripGestureRef = useRef(false);
|
||||
const hoverRef = useRef<MapPolygon | undefined>(undefined);
|
||||
const hoverMarkerRef = useRef<MapMarker | undefined>(undefined);
|
||||
const selectedMarkerRef = useRef<MapMarker | undefined>(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({
|
||||
>
|
||||
<canvas className="tgu-2d-map__canvas" ref={canvasRef} />
|
||||
<svg className="tgu-2d-map__overlay" width={size.width} height={size.height} aria-hidden="true">
|
||||
{dimmedPolygons.flatMap((polygon) => {
|
||||
const raw = polygon.points.map((point) => projection.project(point));
|
||||
const pts = unwrapX(raw, projection.worldSize);
|
||||
return ([0, projection.worldSize, -projection.worldSize] as const).flatMap((dx) => {
|
||||
const visible = pts.some((p) => {
|
||||
const x = p.x + dx;
|
||||
return x >= -64 && x <= size.width + 64 && p.y >= -64 && p.y <= size.height + 64;
|
||||
});
|
||||
if (!visible) return [];
|
||||
return (
|
||||
<polygon
|
||||
key={`dimmed:${polygon.id}:${dx}`}
|
||||
points={pts.map((p) => `${p.x + dx},${p.y}`).join(" ")}
|
||||
className="is-dimmed"
|
||||
/>
|
||||
);
|
||||
});
|
||||
})}
|
||||
{markedPolygons.flatMap((polygon) => {
|
||||
const raw = polygon.points.map((point) => projection.project(point));
|
||||
const pts = unwrapX(raw, projection.worldSize);
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { MapProjection } from "../geometry/mapProjection";
|
||||
import type { GeoPoint } from "../model/mapTypes";
|
||||
|
||||
// Слой следа покрытия (вкладка «Слоты»): заливка union контуров выбранных/забронированных слотов.
|
||||
// Отдельный draw-пасс (D3) — НЕ MapPolygon, не в spatialIndex/hit-тесте. Цвет — violet flat .22
|
||||
// без обводки (выбран на /plan-design-review: отличим от swath/complexPlan/requests + CVD).
|
||||
//
|
||||
// «Flat» (а не additive): все кольца кладём в ОДИН путь и заливаем одним ctx.fill() на проход —
|
||||
// перекрытия не темнеют (это одна операция заливки при одной opacity). Чтобы перекрывающиеся кольца
|
||||
// при nonzero-намотке не «вычитались» в дыры, нормализуем ориентацию колец к одному знаку площади.
|
||||
|
||||
type Pt = { x: number; y: number };
|
||||
|
||||
const FILL = "rgba(167, 139, 219, 0.22)";
|
||||
|
||||
function unwrapX(points: Pt[], worldSize: number): Pt[] {
|
||||
if (points.length === 0) return [];
|
||||
const out: Pt[] = [points[0]!];
|
||||
let prevX = points[0]!.x;
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
let x = points[i]!.x;
|
||||
while (x - prevX > worldSize / 2) x -= worldSize;
|
||||
while (prevX - x > worldSize / 2) x += worldSize;
|
||||
out.push({ x, y: points[i]!.y });
|
||||
prevX = x;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function signedArea(points: Pt[]): number {
|
||||
let area = 0;
|
||||
for (let i = 0, j = points.length - 1; i < points.length; j = i++) {
|
||||
area += (points[j]!.x - points[i]!.x) * (points[j]!.y + points[i]!.y);
|
||||
}
|
||||
return area;
|
||||
}
|
||||
|
||||
function inView(points: Pt[], dx: number, vw: number, vh: number): boolean {
|
||||
return points.some((p) => {
|
||||
const x = p.x + dx;
|
||||
return x >= -64 && x <= vw + 64 && p.y >= -64 && p.y <= vh + 64;
|
||||
});
|
||||
}
|
||||
|
||||
export function drawCoverage(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
rings: GeoPoint[][],
|
||||
projection: MapProjection,
|
||||
viewport: { width: number; height: number }
|
||||
) {
|
||||
if (rings.length === 0) return;
|
||||
|
||||
// Готовим экранные кольца с единой ориентацией (CW), чтобы nonzero-заливка объединяла, а не вычитала.
|
||||
const screenRings: Pt[][] = [];
|
||||
for (const ring of rings) {
|
||||
if (ring.length < 3) continue;
|
||||
const raw = ring.map((p) => projection.project(p));
|
||||
const pts = unwrapX(raw, projection.worldSize);
|
||||
if (signedArea(pts) < 0) pts.reverse();
|
||||
screenRings.push(pts);
|
||||
}
|
||||
if (screenRings.length === 0) return;
|
||||
|
||||
ctx.save();
|
||||
ctx.fillStyle = FILL;
|
||||
// Один путь со всеми кольцами как подпути + одна заливка на сдвиг мира → плоская opacity.
|
||||
for (const dx of [0, projection.worldSize, -projection.worldSize]) {
|
||||
let any = false;
|
||||
ctx.beginPath();
|
||||
for (const pts of screenRings) {
|
||||
if (!inView(pts, dx, viewport.width, viewport.height)) continue;
|
||||
any = true;
|
||||
for (let i = 0; i < pts.length; i++) {
|
||||
if (i === 0) ctx.moveTo(pts[i]!.x + dx, pts[i]!.y);
|
||||
else ctx.lineTo(pts[i]!.x + dx, pts[i]!.y);
|
||||
}
|
||||
ctx.closePath();
|
||||
}
|
||||
if (any) ctx.fill("nonzero");
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
@@ -76,5 +76,11 @@ export type Tgu2DMapScene = {
|
||||
polygons: MapPolygon[];
|
||||
lines: MapLine[];
|
||||
markers: MapMarker[];
|
||||
/**
|
||||
* Кольца следа покрытия (вкладка «Слоты») — рисуются отдельным пассом drawCoverage заливкой,
|
||||
* НЕ как MapPolygon: вне polygons/spatialIndex, поэтому не участвуют в hit-тесте/z-порядке.
|
||||
* Пусто/не задано — слой не рисуется (тумблер выключен либо нечего показывать).
|
||||
*/
|
||||
coverageRings?: GeoPoint[][];
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
// Визуальный прогресс-бар чанковой операции (бронь и снятие). Общий компонент, чтобы обе
|
||||
// операции показывали прогресс одинаково (DRY) — раньше прогресс брони был только текстом в кнопке.
|
||||
|
||||
type ChunkProgressProps = {
|
||||
/** Подпись операции, напр. «Бронирование» / «Снятие брони». */
|
||||
label: string;
|
||||
/** Сколько элементов обработано. */
|
||||
done: number;
|
||||
/** Всего элементов в операции. */
|
||||
total: number;
|
||||
};
|
||||
|
||||
export function ChunkProgress({ label, done, total }: ChunkProgressProps) {
|
||||
const pct = total > 0 ? Math.min(100, Math.round((done / total) * 100)) : 0;
|
||||
return (
|
||||
<div
|
||||
className="tgu-chunk-progress"
|
||||
role="progressbar"
|
||||
aria-valuenow={done}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={total}
|
||||
aria-label={label}
|
||||
>
|
||||
<div className="tgu-chunk-progress__head">
|
||||
<span>{label}…</span>
|
||||
<span className="tgu-chunk-progress__count">
|
||||
{done}/{total}
|
||||
</span>
|
||||
</div>
|
||||
<div className="tgu-chunk-progress__track">
|
||||
<div className="tgu-chunk-progress__fill" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="tgu-coverage">
|
||||
<div className="tgu-coverage__head">
|
||||
<span className="tgu-coverage__title">Покрытие заявки</span>
|
||||
<div className="tgu-coverage__toggles">
|
||||
<label className="tgu-coverage__toggle" title="Показывать след покрытия на карте">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={coverageOn}
|
||||
onChange={(event) => onToggleCoverage(event.target.checked)}
|
||||
/>
|
||||
След на карте
|
||||
</label>
|
||||
<label
|
||||
className="tgu-coverage__toggle"
|
||||
title="Гасить и блокировать выбор слотов, недоступных из-за текущего выбора (тот же КА, не хватает времени на манёвр). Выкл. — выбирать можно любые."
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showConflicts}
|
||||
onChange={(event) => onToggleConflicts(event.target.checked)}
|
||||
/>
|
||||
Конфликты
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!available ? (
|
||||
<div className="tgu-coverage__unavailable">Покрытие недоступно — у заявки нет геометрии.</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="tgu-coverage__bar" role="presentation">
|
||||
<div className="tgu-coverage__seg tgu-coverage__seg--booked" style={{ width: `${bookedPct}%` }} />
|
||||
{hasSelection && gainPct > 0 && (
|
||||
<div className="tgu-coverage__seg tgu-coverage__seg--sel" style={{ width: `${gainPct}%` }} />
|
||||
)}
|
||||
</div>
|
||||
<div className="tgu-coverage__legend">
|
||||
<span className="tgu-coverage__booked">забронировано {bookedPct}%</span>
|
||||
{hasSelection && (
|
||||
<span className="tgu-coverage__sel"> · с выбором {withPct}%</span>
|
||||
)}
|
||||
{lowPrecision && (
|
||||
<span className="tgu-coverage__low" title="Заявка слишком мала для надёжной оценки">
|
||||
{" "}
|
||||
· низкая точность
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="map-pass-selector tgu-pass-stepper">
|
||||
<div className="map-pass-selector__header">
|
||||
<span className="map-pass-selector__title">Прохождение</span>
|
||||
<span className="map-pass-selector__nums">{active ? `${index + 1} / ${total}` : `— / ${total}`}</span>
|
||||
<span className="map-pass-selector__times">{active ? "" : "выберите прохождение ◀ ▶"}</span>
|
||||
{active && (
|
||||
<button type="button" className="map-pass-selector__reset" title="Показать все прохождения" onClick={onClear}>
|
||||
Все
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{active && <div className="tgu-pass-stepper__label">{label}</div>}
|
||||
|
||||
<div className="tgu-pass-stepper__nav">
|
||||
<button
|
||||
type="button"
|
||||
className="tgu-pass-stepper__btn"
|
||||
onClick={onPrev}
|
||||
disabled={!canPrev}
|
||||
title="Предыдущее прохождение"
|
||||
aria-label="Предыдущее прохождение"
|
||||
>
|
||||
◀
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="tgu-pass-stepper__btn"
|
||||
onClick={onNext}
|
||||
disabled={!canNext}
|
||||
title="Следующее прохождение"
|
||||
aria-label="Следующее прохождение"
|
||||
>
|
||||
▶
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<HTMLDivElement>) {
|
||||
e.stopPropagation();
|
||||
dragRef.current = which;
|
||||
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
|
||||
},
|
||||
onPointerMove(e: React.PointerEvent<HTMLDivElement>) {
|
||||
if (!(e.currentTarget as HTMLDivElement).hasPointerCapture(e.pointerId)) return;
|
||||
const idx = indexAt(e.clientX);
|
||||
if (isFrom) {
|
||||
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<HTMLDivElement>) {
|
||||
dragRef.current = null;
|
||||
(e.currentTarget as HTMLDivElement).releasePointerCapture(e.pointerId);
|
||||
},
|
||||
});
|
||||
@@ -101,12 +114,12 @@ export function SlotDaySelector({ days, fromIndex, toIndex, onFromChange, onToCh
|
||||
<div
|
||||
className="map-pass-selector__thumb"
|
||||
style={{ left: `${fromPct}%` }}
|
||||
{...makeHandlers(true)}
|
||||
{...makeHandlers("from")}
|
||||
/>
|
||||
<div
|
||||
className="map-pass-selector__thumb"
|
||||
style={{ left: `${toPct}%` }}
|
||||
{...makeHandlers(false)}
|
||||
{...makeHandlers("to")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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<SlotStatusKey, number>;
|
||||
statusFilter: ReadonlySet<SlotStatusKey>;
|
||||
onToggleStatus: (key: SlotStatusKey) => void;
|
||||
/** Список КА в наборе (отсортирован). Чип КА не показываем, если спутник один. */
|
||||
satellites: number[];
|
||||
satFilter: ReadonlySet<number>;
|
||||
onToggleSat: (id: number) => void;
|
||||
branchFilter: ReadonlySet<RevolutionSign>;
|
||||
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 (
|
||||
<div className="tgu-slots-filterbar">
|
||||
<div className="tgu-slots-filterbar__group">
|
||||
<span className="tgu-slots-filterbar__label">Статус</span>
|
||||
{STATUS_META.map(({ key, label, variant }) => {
|
||||
const active = statusFilter.has(key);
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
className={`tgu-slots-chip${variant}${active ? " is-active" : ""}`}
|
||||
onClick={() => onToggleStatus(key)}
|
||||
>
|
||||
{label} {statusCounts[key]}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{satellites.length > 1 && (
|
||||
<div className="tgu-slots-filterbar__group">
|
||||
<span className="tgu-slots-filterbar__label">КА</span>
|
||||
{satellites.map((id) => {
|
||||
const active = satFilter.has(id);
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
className={`tgu-slots-chip${active ? " is-active" : ""}`}
|
||||
onClick={() => onToggleSat(id)}
|
||||
>
|
||||
{id}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="tgu-slots-filterbar__group">
|
||||
<span className="tgu-slots-filterbar__label">Ветвь</span>
|
||||
{(["ASC", "DESC"] as RevolutionSign[]).map((branch) => {
|
||||
const active = branchFilter.has(branch);
|
||||
return (
|
||||
<button
|
||||
key={branch}
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
className={`tgu-slots-chip${active ? " is-active" : ""}`}
|
||||
onClick={() => onToggleBranch(branch)}
|
||||
>
|
||||
{branch === "ASC" ? "восх" : "нисх"}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="tgu-slots-filterbar__group">
|
||||
<span className="tgu-slots-filterbar__label">Сортировка</span>
|
||||
{SORT_META.map(({ key, label }) => {
|
||||
const active = sortMode === key;
|
||||
const disabled = key === "gain" && !gainAvailable;
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
className={`tgu-slots-chip${active ? " is-active" : ""}`}
|
||||
disabled={disabled}
|
||||
title={disabled ? "Сортировка по вкладу недоступна — у заявки нет геометрии" : undefined}
|
||||
onClick={() => onSortMode(key)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{!isDefault && (
|
||||
<button type="button" className="tgu-button tgu-button--sm tgu-slots-filterbar__reset" onClick={onReset}>
|
||||
Сбросить фильтры
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,201 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseWktPolygon } from "../tgu-map-2d/geometry/wktParser";
|
||||
import type { GeoPoint } from "../tgu-map-2d/model/mapTypes";
|
||||
import {
|
||||
COVERAGE_GRID_N,
|
||||
COVERAGE_MIN_SAMPLES,
|
||||
buildCoverageGrid,
|
||||
coverageOf,
|
||||
coverageOfMasks,
|
||||
rankByMarginalGain,
|
||||
sampleSlotMask,
|
||||
unionMasks,
|
||||
} from "./coverage";
|
||||
|
||||
/** Прямоугольное кольцо (замкнутое) — основной строительный блок фикстур. */
|
||||
function rect(minLon: number, minLat: number, maxLon: number, maxLat: number): GeoPoint[] {
|
||||
return [
|
||||
{ lon: minLon, lat: minLat },
|
||||
{ lon: maxLon, lat: minLat },
|
||||
{ lon: maxLon, lat: maxLat },
|
||||
{ lon: minLon, lat: maxLat },
|
||||
{ lon: minLon, lat: minLat },
|
||||
];
|
||||
}
|
||||
|
||||
describe("buildCoverageGrid", () => {
|
||||
it("квадрат-заявка → все центры ячеек внутри (sampleCount = N²)", () => {
|
||||
const grid = buildCoverageGrid([rect(0, 0, 10, 10)]);
|
||||
expect(grid).not.toBeNull();
|
||||
expect(grid!.n).toBe(COVERAGE_GRID_N);
|
||||
expect(grid!.sampleCount).toBe(COVERAGE_GRID_N * COVERAGE_GRID_N);
|
||||
expect(grid!.requestWeight).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("пустой набор колец → null", () => {
|
||||
expect(buildCoverageGrid([])).toBeNull();
|
||||
});
|
||||
|
||||
it("кольцо <3 точек → null (дегенерат)", () => {
|
||||
expect(buildCoverageGrid([[{ lon: 0, lat: 0 }, { lon: 1, lat: 1 }]])).toBeNull();
|
||||
});
|
||||
|
||||
it("нулевая площадь (коллинеарные точки) → 0 сэмплов → null", () => {
|
||||
expect(
|
||||
buildCoverageGrid([[{ lon: 0, lat: 0 }, { lon: 10, lat: 0 }, { lon: 5, lat: 0 }]]),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sampleSlotMask + coverageOf", () => {
|
||||
const grid = buildCoverageGrid([rect(0, 0, 10, 10)])!;
|
||||
|
||||
it("слот = заявка → ~100% (центр-сэмплинг не теряет краевые ячейки)", () => {
|
||||
const mask = sampleSlotMask(grid, [rect(0, 0, 10, 10)]);
|
||||
expect(coverageOf(grid, mask)!.percent).toBeGreaterThanOrEqual(99);
|
||||
});
|
||||
|
||||
it("слот = левая половина → ~50%", () => {
|
||||
const mask = sampleSlotMask(grid, [rect(0, 0, 5, 10)]);
|
||||
const pct = coverageOf(grid, mask)!.percent;
|
||||
expect(pct).toBeGreaterThanOrEqual(45);
|
||||
expect(pct).toBeLessThanOrEqual(55);
|
||||
});
|
||||
|
||||
it("слот вне bbox заявки (bbox-прунинг) → 0%", () => {
|
||||
const mask = sampleSlotMask(grid, [rect(100, 100, 110, 110)]);
|
||||
expect(coverageOf(grid, mask)!.percent).toBe(0);
|
||||
expect(mask.every((v) => v === 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("битый контур (кольцо <3 точек) → пустая маска, слот не участвует", () => {
|
||||
const mask = sampleSlotMask(grid, [[{ lon: 0, lat: 0 }, { lon: 5, lat: 5 }]]);
|
||||
expect(mask.every((v) => v === 0)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("coverageOfMasks (booked∪selected)", () => {
|
||||
const grid = buildCoverageGrid([rect(0, 0, 10, 10)])!;
|
||||
|
||||
it("пустой набор → 0%", () => {
|
||||
expect(coverageOfMasks(grid, [])!.percent).toBe(0);
|
||||
});
|
||||
|
||||
it("две одинаковые маски (OR de-dup) → ~100%, не 200%", () => {
|
||||
const full = sampleSlotMask(grid, [rect(0, 0, 10, 10)]);
|
||||
const res = coverageOfMasks(grid, [full, full])!;
|
||||
expect(res.percent).toBeLessThanOrEqual(100);
|
||||
expect(res.percent).toBeGreaterThanOrEqual(99);
|
||||
});
|
||||
|
||||
it("левая ∪ правая половины → ~100%", () => {
|
||||
const left = sampleSlotMask(grid, [rect(0, 0, 5, 10)]);
|
||||
const right = sampleSlotMask(grid, [rect(5, 0, 10, 10)]);
|
||||
expect(coverageOfMasks(grid, [left, right])!.percent).toBeGreaterThanOrEqual(99);
|
||||
});
|
||||
|
||||
it("union частично перекрывающихся слотов не двойного счёта", () => {
|
||||
const a = sampleSlotMask(grid, [rect(0, 0, 6, 10)]);
|
||||
const b = sampleSlotMask(grid, [rect(4, 0, 10, 10)]);
|
||||
const union = unionMasks(grid, [a, b]);
|
||||
expect(coverageOf(grid, union)!.percent).toBeGreaterThanOrEqual(99);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rankByMarginalGain (weighted)", () => {
|
||||
const grid = buildCoverageGrid([rect(0, 0, 10, 10)])!;
|
||||
const big = sampleSlotMask(grid, [rect(0, 0, 6, 10)]); // 60%
|
||||
const small = sampleSlotMask(grid, [rect(0, 0, 4, 10)]); // 40%
|
||||
|
||||
it("жадный порядок: больший вклад — первым", () => {
|
||||
const ranked = rankByMarginalGain(grid, [
|
||||
{ key: "small", mask: small },
|
||||
{ key: "big", mask: big },
|
||||
]);
|
||||
expect(ranked[0]!.key).toBe("big");
|
||||
expect(ranked[0]!.gainPercent).toBeGreaterThan(ranked[1]!.gainPercent);
|
||||
});
|
||||
|
||||
it("тай-брейк: равный вклад → исходный порядок", () => {
|
||||
const left = sampleSlotMask(grid, [rect(0, 0, 5, 10)]);
|
||||
const right = sampleSlotMask(grid, [rect(5, 0, 10, 10)]);
|
||||
const ranked = rankByMarginalGain(grid, [
|
||||
{ key: "left", mask: left },
|
||||
{ key: "right", mask: right },
|
||||
]);
|
||||
expect(ranked.map((r) => r.key)).toEqual(["left", "right"]);
|
||||
});
|
||||
|
||||
it("вклад 0: кандидат — подмножество уже покрытого → gainPercent 0 («—»)", () => {
|
||||
const base = sampleSlotMask(grid, [rect(0, 0, 10, 10)]); // всё покрыто
|
||||
const ranked = rankByMarginalGain(grid, [{ key: "redundant", mask: small }], base);
|
||||
expect(ranked[0]!.gainPercent).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("антимеридиан (собственный unwrap-фрейм)", () => {
|
||||
it("заявка и слот через 180° → корректные ~100%", () => {
|
||||
// Кольцо пересекает антимеридиан: долготы 170 и -170 (=190 в unwrap-фрейме).
|
||||
const ring: GeoPoint[] = [
|
||||
{ lon: 170, lat: 0 },
|
||||
{ lon: -170, lat: 0 },
|
||||
{ lon: -170, lat: 10 },
|
||||
{ lon: 170, lat: 10 },
|
||||
{ lon: 170, lat: 0 },
|
||||
];
|
||||
const grid = buildCoverageGrid([ring]);
|
||||
expect(grid).not.toBeNull();
|
||||
expect(grid!.sampleCount).toBe(COVERAGE_GRID_N * COVERAGE_GRID_N);
|
||||
const mask = sampleSlotMask(grid!, [ring]);
|
||||
expect(coverageOf(grid!, mask)!.percent).toBeGreaterThanOrEqual(99);
|
||||
});
|
||||
});
|
||||
|
||||
describe("WKT holes — лимит MVP-парсера зафиксирован", () => {
|
||||
it("полигон с дырой парсится только во внешнее кольцо (1 кольцо)", () => {
|
||||
const rings = parseWktPolygon(
|
||||
"POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0), (2 2, 4 2, 4 4, 2 4, 2 2))",
|
||||
);
|
||||
expect(rings).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("дыра игнорируется → покрытие считает её закрытой (~100%)", () => {
|
||||
const rings = parseWktPolygon(
|
||||
"POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0), (2 2, 4 2, 4 4, 2 4, 2 2))",
|
||||
);
|
||||
const grid = buildCoverageGrid(rings)!;
|
||||
const mask = sampleSlotMask(grid, rings);
|
||||
expect(coverageOf(grid, mask)!.percent).toBeGreaterThanOrEqual(99);
|
||||
});
|
||||
});
|
||||
|
||||
describe("низкая точность (тонкий полигон)", () => {
|
||||
it("крошечные фигуры в большом bbox → мало сэмплов → lowPrecision", () => {
|
||||
// Два маленьких треугольника в противоположных углах: bbox 40×40, но суммарная площадь
|
||||
// ничтожна → < COVERAGE_MIN_SAMPLES сэмплов внутри (тонкий полигон → абсурдный %).
|
||||
const grid = buildCoverageGrid([
|
||||
[
|
||||
{ lon: 0, lat: 0 },
|
||||
{ lon: 2, lat: 0 },
|
||||
{ lon: 0, lat: 2 },
|
||||
{ lon: 0, lat: 0 },
|
||||
],
|
||||
[
|
||||
{ lon: 40, lat: 40 },
|
||||
{ lon: 40, lat: 38 },
|
||||
{ lon: 38, lat: 40 },
|
||||
{ lon: 40, lat: 40 },
|
||||
],
|
||||
]);
|
||||
expect(grid).not.toBeNull();
|
||||
expect(grid!.sampleCount).toBeGreaterThan(0);
|
||||
expect(grid!.sampleCount).toBeLessThan(COVERAGE_MIN_SAMPLES);
|
||||
const res = coverageOf(grid!, new Uint8Array(grid!.lons.length));
|
||||
expect(res!.lowPrecision).toBe(true);
|
||||
});
|
||||
|
||||
it("полный квадрат → точность нормальная", () => {
|
||||
const grid = buildCoverageGrid([rect(0, 0, 10, 10)])!;
|
||||
expect(coverageOf(grid, new Uint8Array(grid.lons.length))!.lowPrecision).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,264 @@
|
||||
// Растровое (Monte-Carlo) покрытие заявки на клиенте — ядро «слоя поддержки решений оператора».
|
||||
// Чистые функции под юнит-тесты (см. coverage.test.ts, образец slotFilters.test.ts).
|
||||
//
|
||||
// Идея: вместо точного векторного union контуров считаем % по детерминированной сетке сэмплов
|
||||
// внутри bbox заявки. Сэмпл «закрыт», если попал в union контуров выбранных/забронированных слотов.
|
||||
// % = Σ cos(lat)-вес закрытых сэмплов внутри заявки / Σ вес всех сэмплов внутри заявки. Стабильно,
|
||||
// дёшево на чек/снятие (per-slot маска кэшируется), ноль зависимостей.
|
||||
//
|
||||
// выбор/бронь слотов ─▶ [сетка сэмплов заявки, cos-lat вес] ─▶ маски слотов (OR) ─▶ % + слой coverage
|
||||
// [0 валидных→null] [битый WKT→skip] [bbox-прунинг] [<порога→низкая точность]
|
||||
|
||||
import { pointInPolygon } from "../tgu-map-2d/geometry/pointInPolygon";
|
||||
import type { GeoPoint } from "../tgu-map-2d/model/mapTypes";
|
||||
|
||||
/** Сторона сетки сэмплов (D1): фикс 64×64 ≈ 4096 точек по bbox заявки, независимо от размера. */
|
||||
export const COVERAGE_GRID_N = 64;
|
||||
/** Порог «низкой точности» (codex P1): тонкий полигон даёт мало сэмплов → % абсурдный. */
|
||||
export const COVERAGE_MIN_SAMPLES = 16;
|
||||
|
||||
/** Детерминированная сетка сэмплов по bbox заявки (кэш на заявку). */
|
||||
export type CoverageGrid = {
|
||||
readonly n: number;
|
||||
/** Опорная долгота фрейма unwrap (антимеридиан): все долготы приведены к [refLon-180, refLon+180]. */
|
||||
readonly refLon: number;
|
||||
/** Долготы сэмплов в unwrap-фрейме (length n*n). */
|
||||
readonly lons: Float64Array;
|
||||
/** Широты сэмплов (length n*n). */
|
||||
readonly lats: Float64Array;
|
||||
/** Вес сэмпла = max(cos(lat), 0) — компенсация искажения площади к полюсам. */
|
||||
readonly weights: Float64Array;
|
||||
/** 1, если сэмпл внутри заявки (union колец); только такие участвуют в %. */
|
||||
readonly inRequest: Uint8Array;
|
||||
/** Σ weight по сэмплам внутри заявки (знаменатель %). */
|
||||
readonly requestWeight: number;
|
||||
/** Кол-во сэмплов внутри заявки (для порога точности). */
|
||||
readonly sampleCount: number;
|
||||
};
|
||||
|
||||
/** Маска слота: 1, если сэмпл внутри заявки И закрыт контуром слота (length n*n). */
|
||||
export type SlotMask = Uint8Array;
|
||||
|
||||
/** Результат расчёта покрытия набора. */
|
||||
export type CoverageResult = {
|
||||
/** Доля 0..1. */
|
||||
fraction: number;
|
||||
/** Целый процент 0..100 (для панели). */
|
||||
percent: number;
|
||||
/** Кол-во сэмплов заявки (диагностика). */
|
||||
sampleCount: number;
|
||||
/** sampleCount < COVERAGE_MIN_SAMPLES — % ненадёжен, панель помечает «низкая точность». */
|
||||
lowPrecision: boolean;
|
||||
};
|
||||
|
||||
const DEG2RAD = Math.PI / 180;
|
||||
|
||||
/** Приводит долготу к непрерывному фрейму вокруг ref (унос через антимеридиан). */
|
||||
function unwrapLonTo(lon: number, ref: number): number {
|
||||
let x = lon;
|
||||
while (x - ref > 180) x -= 360;
|
||||
while (ref - x > 180) x += 360;
|
||||
return x;
|
||||
}
|
||||
|
||||
/** Оставляет кольца ≥3 точек и уносит их долготы в фрейм ref (битые/мелкие — отбрасываем). */
|
||||
function unwrapRings(rings: GeoPoint[][], ref: number): GeoPoint[][] {
|
||||
const out: GeoPoint[][] = [];
|
||||
for (const ring of rings) {
|
||||
if (ring.length < 3) continue;
|
||||
out.push(ring.map((p) => ({ lon: unwrapLonTo(p.lon, ref), lat: p.lat })));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
type Bounds = { minLon: number; minLat: number; maxLon: number; maxLat: number };
|
||||
|
||||
function boundsOfRings(rings: GeoPoint[][]): Bounds {
|
||||
let minLon = Number.POSITIVE_INFINITY;
|
||||
let minLat = Number.POSITIVE_INFINITY;
|
||||
let maxLon = Number.NEGATIVE_INFINITY;
|
||||
let maxLat = Number.NEGATIVE_INFINITY;
|
||||
for (const ring of rings) {
|
||||
for (const p of ring) {
|
||||
if (p.lon < minLon) minLon = p.lon;
|
||||
if (p.lon > maxLon) maxLon = p.lon;
|
||||
if (p.lat < minLat) minLat = p.lat;
|
||||
if (p.lat > maxLat) maxLat = p.lat;
|
||||
}
|
||||
}
|
||||
return { minLon, minLat, maxLon, maxLat };
|
||||
}
|
||||
|
||||
/**
|
||||
* Строит сетку сэмплов по bbox заявки. Дегенерат (нет валидных колец или 0 сэмплов внутри) → null
|
||||
* («покрытие недоступно»). Антимеридиан — собственный unwrap-фрейм (примитив spatialIndex приватный).
|
||||
*/
|
||||
export function buildCoverageGrid(requestRings: GeoPoint[][]): CoverageGrid | null {
|
||||
const valid0 = requestRings.filter((r) => r.length >= 3);
|
||||
if (valid0.length === 0) return null;
|
||||
|
||||
const refLon = valid0[0][0]!.lon;
|
||||
const rings = unwrapRings(valid0, refLon);
|
||||
if (rings.length === 0) return null;
|
||||
|
||||
const b = boundsOfRings(rings);
|
||||
if (!Number.isFinite(b.minLon) || !Number.isFinite(b.minLat)) return null;
|
||||
|
||||
const n = COVERAGE_GRID_N;
|
||||
const total = n * n;
|
||||
const lons = new Float64Array(total);
|
||||
const lats = new Float64Array(total);
|
||||
const weights = new Float64Array(total);
|
||||
const inRequest = new Uint8Array(total);
|
||||
|
||||
const spanLon = b.maxLon - b.minLon;
|
||||
const spanLat = b.maxLat - b.minLat;
|
||||
let requestWeight = 0;
|
||||
let sampleCount = 0;
|
||||
|
||||
for (let gy = 0; gy < n; gy++) {
|
||||
// Сэмплируем по центрам ячеек ((gy+0.5)/n), чтобы не попадать ровно на границу bbox/контура.
|
||||
const lat = b.minLat + ((gy + 0.5) / n) * spanLat;
|
||||
const w = Math.max(Math.cos(lat * DEG2RAD), 0);
|
||||
for (let gx = 0; gx < n; gx++) {
|
||||
const idx = gy * n + gx;
|
||||
const lon = b.minLon + ((gx + 0.5) / n) * spanLon;
|
||||
lons[idx] = lon;
|
||||
lats[idx] = lat;
|
||||
weights[idx] = w;
|
||||
const pt = { lon, lat };
|
||||
let inside = false;
|
||||
// union колец: нечётное число попаданий не требуется — это отдельные внешние контуры (MULTIPOLYGON),
|
||||
// достаточно попасть хотя бы в одно. Дыры (внутренние кольца WKT) игнорируются (лимит MVP-парсера).
|
||||
for (const ring of rings) {
|
||||
if (pointInPolygon(pt, ring)) {
|
||||
inside = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (inside) {
|
||||
inRequest[idx] = 1;
|
||||
requestWeight += w;
|
||||
sampleCount += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (sampleCount === 0) return null;
|
||||
|
||||
return { n, refLon, lons, lats, weights, inRequest, requestWeight, sampleCount };
|
||||
}
|
||||
|
||||
/**
|
||||
* Маска слота на сетке заявки: 1 там, где сэмпл внутри заявки И закрыт контуром слота.
|
||||
* bbox-прунинг (D2): сначала отбрасываем сэмплы вне bbox слота, потом точный pointInPolygon.
|
||||
* Битый WKT / кольца <3 → пустая маска (слот не участвует в покрытии, без падения).
|
||||
*/
|
||||
export function sampleSlotMask(grid: CoverageGrid, slotRings: GeoPoint[][]): SlotMask {
|
||||
const mask = new Uint8Array(grid.lons.length);
|
||||
const rings = unwrapRings(slotRings, grid.refLon);
|
||||
if (rings.length === 0) return mask;
|
||||
|
||||
const b = boundsOfRings(rings);
|
||||
if (!Number.isFinite(b.minLon)) return mask;
|
||||
|
||||
for (let idx = 0; idx < mask.length; idx++) {
|
||||
if (grid.inRequest[idx] === 0) continue;
|
||||
const lon = grid.lons[idx]!;
|
||||
const lat = grid.lats[idx]!;
|
||||
if (lon < b.minLon || lon > b.maxLon || lat < b.minLat || lat > b.maxLat) continue;
|
||||
const pt = { lon, lat };
|
||||
for (const ring of rings) {
|
||||
if (pointInPolygon(pt, ring)) {
|
||||
mask[idx] = 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return mask;
|
||||
}
|
||||
|
||||
/** OR нескольких масок в одну (union покрытия набора). */
|
||||
export function unionMasks(grid: CoverageGrid, masks: readonly SlotMask[]): SlotMask {
|
||||
const out = new Uint8Array(grid.lons.length);
|
||||
for (const mask of masks) {
|
||||
for (let i = 0; i < out.length; i++) if (mask[i]) out[i] = 1;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Покрытие по готовому union-маске. requestWeight<=0 (заявка у полюса) → null. */
|
||||
export function coverageOf(grid: CoverageGrid, union: SlotMask): CoverageResult | null {
|
||||
if (grid.requestWeight <= 0) return null;
|
||||
let covered = 0;
|
||||
for (let idx = 0; idx < union.length; idx++) {
|
||||
if (union[idx]) covered += grid.weights[idx]!;
|
||||
}
|
||||
const fraction = covered / grid.requestWeight;
|
||||
return {
|
||||
fraction,
|
||||
percent: Math.round(fraction * 100),
|
||||
sampleCount: grid.sampleCount,
|
||||
lowPrecision: grid.sampleCount < COVERAGE_MIN_SAMPLES,
|
||||
};
|
||||
}
|
||||
|
||||
/** Покрытие набора масок (booked∪selected): union → coverageOf. Пустой набор → 0%. */
|
||||
export function coverageOfMasks(grid: CoverageGrid, masks: readonly SlotMask[]): CoverageResult | null {
|
||||
return coverageOf(grid, unionMasks(grid, masks));
|
||||
}
|
||||
|
||||
/** Маргинальный вклад слота поверх текущего union: Σ cos-lat весов НОВЫХ закрытых сэмплов (не popcount). */
|
||||
export function marginalGainWeight(grid: CoverageGrid, union: SlotMask, mask: SlotMask): number {
|
||||
let gain = 0;
|
||||
for (let idx = 0; idx < mask.length; idx++) {
|
||||
if (mask[idx] && !union[idx]) gain += grid.weights[idx]!;
|
||||
}
|
||||
return gain;
|
||||
}
|
||||
|
||||
/** Кандидат для ранжирования по вкладу. */
|
||||
export type RankCandidate = { key: string; mask: SlotMask };
|
||||
|
||||
/** Ранжированный кандидат: вклад на момент ранжирования (жадный порядок), доля и целый %. */
|
||||
export type RankedCandidate = { key: string; gainFraction: number; gainPercent: number };
|
||||
|
||||
/**
|
||||
* Жадное ранжирование по маргинальному вкладу (взвешенному, codex P1): на каждом шаге берём
|
||||
* кандидата с максимальным приростом покрытия поверх уже отобранного union (старт — baseUnion,
|
||||
* напр. забронированное). Тай-брейк — исходный порядок (stable). Вклад фиксируется на момент
|
||||
* ранжирования (для колонки «+N%», подпись «вклад на момент ранжирования», не live).
|
||||
*/
|
||||
export function rankByMarginalGain(
|
||||
grid: CoverageGrid,
|
||||
candidates: readonly RankCandidate[],
|
||||
baseUnion?: SlotMask,
|
||||
): RankedCandidate[] {
|
||||
const denom = grid.requestWeight > 0 ? grid.requestWeight : 1;
|
||||
const union = baseUnion ? Uint8Array.from(baseUnion) : new Uint8Array(grid.lons.length);
|
||||
const remaining = candidates.map((c, order) => ({ ...c, order }));
|
||||
const ranked: RankedCandidate[] = [];
|
||||
|
||||
while (remaining.length > 0) {
|
||||
let bestIdx = 0;
|
||||
let bestGain = -1;
|
||||
for (let i = 0; i < remaining.length; i++) {
|
||||
const gain = marginalGainWeight(grid, union, remaining[i]!.mask);
|
||||
// Строгое > сохраняет исходный порядок при равенстве (стабильный тай-брейк).
|
||||
if (gain > bestGain) {
|
||||
bestGain = gain;
|
||||
bestIdx = i;
|
||||
}
|
||||
}
|
||||
const [picked] = remaining.splice(bestIdx, 1);
|
||||
const gainFraction = bestGain / denom;
|
||||
ranked.push({
|
||||
key: picked!.key,
|
||||
gainFraction,
|
||||
gainPercent: Math.round(gainFraction * 100),
|
||||
});
|
||||
for (let idx = 0; idx < union.length; idx++) if (picked!.mask[idx]) union[idx] = 1;
|
||||
}
|
||||
|
||||
return ranked;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { runChunked } from "./runChunked";
|
||||
|
||||
describe("runChunked", () => {
|
||||
it("бьёт набор на чанки и шлёт их по очереди", async () => {
|
||||
const items = Array.from({ length: 450 }, (_, i) => i);
|
||||
const sizes: number[] = [];
|
||||
const fn = vi.fn(async (part: number[]) => {
|
||||
sizes.push(part.length);
|
||||
return part.length; // снятие: число снятых в чанке
|
||||
});
|
||||
|
||||
const results = await runChunked(items, 200, fn);
|
||||
|
||||
expect(fn).toHaveBeenCalledTimes(3);
|
||||
expect(sizes).toEqual([200, 200, 50]);
|
||||
expect(results).toEqual([200, 200, 50]);
|
||||
// суммирование на стороне вызова
|
||||
expect(results.reduce((a, b) => a + b, 0)).toBe(450);
|
||||
});
|
||||
|
||||
it("сообщает прогресс (обработано/всего) после каждого чанка", async () => {
|
||||
const items = Array.from({ length: 450 }, (_, i) => i);
|
||||
const progress: Array<[number, number]> = [];
|
||||
|
||||
await runChunked(items, 200, async () => 0, (done, total) => progress.push([done, total]));
|
||||
|
||||
expect(progress).toEqual([
|
||||
[200, 450],
|
||||
[400, 450],
|
||||
[450, 450],
|
||||
]);
|
||||
});
|
||||
|
||||
it("пустой набор — не зовёт fn и возвращает пусто", async () => {
|
||||
const fn = vi.fn(async () => 0);
|
||||
const results = await runChunked([], 200, fn);
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
it("ошибка чанка пробрасывается наружу", async () => {
|
||||
const items = [1, 2, 3];
|
||||
await expect(
|
||||
runChunked(items, 2, async (part) => {
|
||||
if (part.includes(3)) throw new Error("boom");
|
||||
return part.length;
|
||||
}),
|
||||
).rejects.toThrow("boom");
|
||||
});
|
||||
|
||||
it("аккумулирует списки (бронь) с сохранением порядка", async () => {
|
||||
const items = [1, 2, 3, 4, 5];
|
||||
const results = await runChunked(items, 2, async (part) => part.map((n) => n * 10));
|
||||
expect(results.flat()).toEqual([10, 20, 30, 40, 50]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
// Общий чанковый раннер для крупных операций над слотами (бронь и снятие). Бэк работает по
|
||||
// одному слоту в транзакции, поэтому крупный набор бьём на части (см. BOOK_CHUNK) и шлём
|
||||
// последовательно, сообщая прогресс. Выделено из TguSlotsTab, чтобы покрыть юнит-тестами и не
|
||||
// дублировать цикл в performBook/performCancel (DRY).
|
||||
|
||||
import { chunk } from "./slotSelection";
|
||||
|
||||
/**
|
||||
* Прогоняет `fn` по чанкам `items` последовательно, после каждого чанка зовёт `onProgress`.
|
||||
* Возвращает по одному результату на чанк (порядок сохраняется): для брони это списки
|
||||
* забронированных слотов, для снятия — числа снятых. Сведение (flat/сумма) — на стороне вызова.
|
||||
*
|
||||
* @param items Полный набор элементов операции.
|
||||
* @param size Размер чанка; <= 0 — один чанк со всем набором (см. chunk()).
|
||||
* @param fn Операция над одним чанком; ошибка пробрасывается наружу (частичный прогресс уже учтён).
|
||||
* @param onProgress Коллбек прогресса: (обработано, всего) после каждого успешного чанка.
|
||||
*/
|
||||
export async function runChunked<TItem, TChunk>(
|
||||
items: readonly TItem[],
|
||||
size: number,
|
||||
fn: (part: TItem[]) => Promise<TChunk>,
|
||||
onProgress?: (done: number, total: number) => void,
|
||||
): Promise<TChunk[]> {
|
||||
const total = items.length;
|
||||
const results: TChunk[] = [];
|
||||
let done = 0;
|
||||
for (const part of chunk(items, size)) {
|
||||
results.push(await fn(part));
|
||||
done += part.length;
|
||||
onProgress?.(done, total);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { computeConflicts } from "./slotConflicts";
|
||||
|
||||
/** Минимальный слот: КА + окно [tn,tk] + крен + ключ. */
|
||||
type S = { satelliteId: number; n: number; tn: string; tk: string; roll: number; state?: string };
|
||||
|
||||
const keyOf = (s: S) => `${s.satelliteId}:${s.n}`;
|
||||
|
||||
const pad = (v: number) => String(v).padStart(2, "0");
|
||||
/** ISO-момент 10:MM:SS от секунд смещения относительно 10:00:00. */
|
||||
function fmt(sec: number): string {
|
||||
return `2026-06-17T10:${pad(Math.floor(sec / 60))}:${pad(sec % 60)}`;
|
||||
}
|
||||
/** Слот КА на окне [startSec, endSec] (секунды от 10:00:00), с креном roll. */
|
||||
function slot(satelliteId: number, n: number, startSec: number, endSec: number, roll: number, state = "AVAILABLE"): S {
|
||||
return { satelliteId, n, tn: fmt(startSec), tk: fmt(endSec), roll, state };
|
||||
}
|
||||
|
||||
const notBlocked = () => false;
|
||||
// Выбран ровно слот A (sat 1, n 1) — источник занятого интервала во всех кейсах.
|
||||
const selectedIsA = (s: S) => s.satelliteId === 1 && s.n === 1;
|
||||
|
||||
// A: [0,30], крен 10.
|
||||
const A = slot(1, 1, 0, 30, 10);
|
||||
|
||||
describe("computeConflicts (зазор на манёвр)", () => {
|
||||
it("без выбора — конфликтов нет", () => {
|
||||
const arr = [A, slot(1, 2, 38, 60, 10)];
|
||||
expect(computeConflicts(arr, arr, keyOf, () => false, notBlocked).size).toBe(0);
|
||||
});
|
||||
|
||||
it("тот же крен: зазор < 10 с → конфликт, ≥ 10 с → ок", () => {
|
||||
const close = slot(1, 2, 38, 60, 10); // зазор 8 с → конфликт
|
||||
const ok = slot(1, 3, 41, 60, 10); // зазор 11 с → ок
|
||||
const arr = [A, close, ok];
|
||||
const res = computeConflicts(arr, arr, keyOf, selectedIsA, notBlocked);
|
||||
expect(res.has("1:2")).toBe(true);
|
||||
expect(res.has("1:3")).toBe(false);
|
||||
});
|
||||
|
||||
it("смена крена: тот же зазор 25 с конфликтует (нужно 30), при том же крене — нет (хватает 10)", () => {
|
||||
const diffRoll = slot(1, 4, 55, 80, 20); // зазор 25 с, крен сменился → конфликт
|
||||
const sameRoll = slot(1, 5, 55, 80, 10); // зазор 25 с, крен тот же → ок
|
||||
const arr = [A, diffRoll, sameRoll];
|
||||
const res = computeConflicts(arr, arr, keyOf, selectedIsA, notBlocked);
|
||||
expect(res.has("1:4")).toBe(true);
|
||||
expect(res.has("1:5")).toBe(false);
|
||||
});
|
||||
|
||||
it("смена крена: зазор ≥ 30 с → ок", () => {
|
||||
const ok = slot(1, 6, 61, 90, 20); // зазор 31 с → ок
|
||||
const arr = [A, ok];
|
||||
expect(computeConflicts(arr, arr, keyOf, selectedIsA, notBlocked).has("1:6")).toBe(false);
|
||||
});
|
||||
|
||||
it("пересечение по времени → конфликт; сам выбранный не помечается", () => {
|
||||
const overlap = slot(1, 7, 20, 50, 20); // перекрывает A
|
||||
const arr = [A, overlap];
|
||||
const res = computeConflicts(arr, arr, keyOf, selectedIsA, notBlocked);
|
||||
expect(res.has("1:7")).toBe(true);
|
||||
expect(res.has("1:1")).toBe(false);
|
||||
});
|
||||
|
||||
it("другой КА не конфликтует", () => {
|
||||
const other = slot(2, 8, 38, 60, 10); // тот же зазор, но КА 2
|
||||
const arr = [A, other];
|
||||
expect(computeConflicts(arr, arr, keyOf, selectedIsA, notBlocked).has("2:8")).toBe(false);
|
||||
});
|
||||
|
||||
it("кандидат ПЕРЕД выбранным тоже ловится (тот же крен, зазор < 10 с)", () => {
|
||||
const later = slot(1, 10, 100, 130, 10); // выбран, позже по времени
|
||||
const before = slot(1, 11, 88, 95, 10); // зазор 5 с до later → конфликт
|
||||
const farBefore = slot(1, 12, 50, 70, 10); // зазор 30 с до later → ок
|
||||
const arr = [later, before, farBefore];
|
||||
const res = computeConflicts(arr, arr, keyOf, (s) => s.n === 10, notBlocked);
|
||||
expect(res.has("1:11")).toBe(true);
|
||||
expect(res.has("1:12")).toBe(false);
|
||||
});
|
||||
|
||||
it("бронь/BUSY исключены из конфликтов", () => {
|
||||
const busy = slot(1, 9, 38, 60, 10, "BUSY"); // зазор 8 с, но BUSY
|
||||
const arr = [A, busy];
|
||||
const res = computeConflicts(arr, arr, keyOf, selectedIsA, (s) => s.state === "BUSY");
|
||||
expect(res.has("1:9")).toBe(false);
|
||||
});
|
||||
|
||||
it("слоты ОДНОЙ полосы между собой не конфликтуют (groupOf)", () => {
|
||||
const sameStrip = slot(1, 2, 38, 60, 10); // зазор 8 с, тот же крен — конфликт по умолчанию
|
||||
const arr = [A, sameStrip];
|
||||
// Без groupOf — конфликт.
|
||||
expect(computeConflicts(arr, arr, keyOf, selectedIsA, notBlocked).has("1:2")).toBe(true);
|
||||
// A и sameStrip в одной полосе — конфликт снимается.
|
||||
const sameGroup = () => "polosa:1";
|
||||
expect(computeConflicts(arr, arr, keyOf, selectedIsA, notBlocked, sameGroup).has("1:2")).toBe(false);
|
||||
});
|
||||
|
||||
it("groupOf: разные полосы того же КА по-прежнему конфликтуют", () => {
|
||||
const other = slot(1, 2, 38, 60, 10); // зазор 8 с, тот же крен
|
||||
const arr = [A, other];
|
||||
const groupOf = (s: S) => (s.n === 1 ? "polosa:a" : "polosa:b");
|
||||
expect(computeConflicts(arr, arr, keyOf, selectedIsA, notBlocked, groupOf).has("1:2")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
// Приближённая клиентская подсказка «конфликтует с текущим выбором». Слот гаснет, если он того же
|
||||
// КА, что и какой-то выбранный под бронь слот, и не выдержан зазор на манёвр между ними:
|
||||
// • крен НЕ меняется → между концом одного и началом другого нужно ≥ 10 с;
|
||||
// • крен меняется → нужно ≥ 30 с (на перенацеливание дольше).
|
||||
// Меньший зазор (вплоть до пересечения) → конфликт. «Крен меняется» = расхождение крена > ROLL_EPSILON
|
||||
// (тот же допуск, по которому слоты кластеризуются в полосы).
|
||||
//
|
||||
// Это ПРИБЛИЖЁННАЯ клиентская оценка, не полная проверка выполнимости: реальная физика (профиль slew,
|
||||
// maxChainLength, maxSurveyDuration) живёт в slots-service по-КА и проверяется бэкенд-эндпоинтом
|
||||
// (итерация 2). Слоты ОДНОЙ полосы (одна непрерывная съёмка) между собой не конфликтуют — см. groupOf.
|
||||
//
|
||||
// Конфликтные слоты не только гаснут, но и НЕ ДОБАВЛЯЮТСЯ в выбор под бронь (жёсткая блокировка на
|
||||
// стороне UI). Уже выбранные слоты не трогаем (конфликт считается только для НЕвыбранных кандидатов),
|
||||
// поэтому снять выбор можно всегда. Окончательный арбитр выполнимости — бэкенд.
|
||||
|
||||
import type { SlotDTO } from "../../model/slotTypes"
|
||||
import { parseLocal } from "../../utils/localTime"
|
||||
import { ROLL_EPSILON } from "./slotStrips"
|
||||
|
||||
type Conflictable = Pick<SlotDTO, "satelliteId" | "tn" | "tk" | "roll">
|
||||
|
||||
/** Требуемый зазор между концом одного и началом следующего слота того же КА (мс). */
|
||||
export const SAME_ROLL_GAP_MS = 10_000
|
||||
export const DIFF_ROLL_GAP_MS = 30_000
|
||||
|
||||
/** Выбранный интервал слота: окно [start,end) (мс), крен и id полосы — для выбора нужного зазора. */
|
||||
type SelInterval = { start: number; end: number; roll: number; group?: string }
|
||||
|
||||
/** Занятые интервалы выбранных слотов одного КА: отсортированы по началу + макс. длительность. */
|
||||
type SatSelection = { intervals: SelInterval[]; maxDur: number }
|
||||
|
||||
/**
|
||||
* Конфликтует ли кандидат [cStart,cEnd) с креном cRoll с выбранным интервалом s того же КА:
|
||||
* упорядочиваем по началу и проверяем зазор (начало позднего − конец раннего) против требуемого
|
||||
* (10 с при том же крене, 30 с при смене крена). Пересечение даёт отрицательный зазор → конфликт.
|
||||
*/
|
||||
function conflictsPair(cStart: number, cEnd: number, cRoll: number, s: SelInterval): boolean {
|
||||
const required = Math.abs(cRoll - s.roll) <= ROLL_EPSILON ? SAME_ROLL_GAP_MS : DIFF_ROLL_GAP_MS
|
||||
const [earlierEnd, laterStart] = cStart <= s.start ? [cEnd, s.start] : [s.end, cStart]
|
||||
return laterStart - earlierEnd < required
|
||||
}
|
||||
|
||||
/** Первый индекс, у которого intervals[i].start >= target (бинарный поиск по отсортированному). */
|
||||
function lowerBound(intervals: SelInterval[], target: number): number {
|
||||
let lo = 0
|
||||
let hi = intervals.length
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >> 1
|
||||
if (intervals[mid].start < target) lo = mid + 1
|
||||
else hi = mid
|
||||
}
|
||||
return lo
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает множество ключей кандидатов, конфликтующих с текущим выбором (тот же КА + недостаточный
|
||||
* зазор на манёвр). Выбранные и «заблокированные» (бронь/BUSY) слоты в результат не попадают.
|
||||
*
|
||||
* Производительность: занятые интервалы строим из source (полный набор; выбор обычно мал), сортируем
|
||||
* по началу и для каждого кандидата проверяем не всех выбранных, а лишь окно по времени (≤ зазора +
|
||||
* макс. длительности) через бинарный поиск → ~O(кандидаты·log(выбор)). За кандидатов берём видимый
|
||||
* набор, а не весь — невидимое всё равно не рисуется.
|
||||
*
|
||||
* @param source Полный набор слотов — источник занятых интервалов выбранных.
|
||||
* @param candidates Слоты, которые помечаем (обычно видимые: таблица/карта).
|
||||
* @param keyOf Аксессор ключа слота.
|
||||
* @param isSelected Входит ли слот в текущий набор под бронь.
|
||||
* @param isBlocked Бронь/BUSY — такой слот не помечаем как конфликт (у него своя индикация).
|
||||
* @param groupOf Id полосы слота (одна непрерывная съёмка): слоты ОДНОЙ полосы между собой не
|
||||
* конфликтуют (это один манёвр, без перенацеливания), поэтому такую пару пропускаем. Без аксессора
|
||||
* полосы не учитываются.
|
||||
*/
|
||||
export function computeConflicts<T extends Conflictable>(
|
||||
source: readonly T[],
|
||||
candidates: readonly T[],
|
||||
keyOf: (slot: T) => string,
|
||||
isSelected: (slot: T) => boolean,
|
||||
isBlocked: (slot: T) => boolean,
|
||||
groupOf?: (slot: T) => string | undefined,
|
||||
): Set<string> {
|
||||
const bySat = new Map<number, SatSelection>()
|
||||
for (const slot of source) {
|
||||
if (!isSelected(slot)) continue
|
||||
const start = parseLocal(slot.tn)
|
||||
const end = parseLocal(slot.tk)
|
||||
if (!Number.isFinite(start) || !Number.isFinite(end)) continue
|
||||
let bucket = bySat.get(slot.satelliteId)
|
||||
if (!bucket) {
|
||||
bucket = { intervals: [], maxDur: 0 }
|
||||
bySat.set(slot.satelliteId, bucket)
|
||||
}
|
||||
bucket.intervals.push({ start, end, roll: slot.roll, group: groupOf?.(slot) })
|
||||
bucket.maxDur = Math.max(bucket.maxDur, end - start)
|
||||
}
|
||||
|
||||
const conflicts = new Set<string>()
|
||||
if (bySat.size === 0) return conflicts
|
||||
for (const bucket of bySat.values()) bucket.intervals.sort((a, b) => a.start - b.start)
|
||||
|
||||
for (const slot of candidates) {
|
||||
if (isSelected(slot) || isBlocked(slot)) continue
|
||||
const bucket = bySat.get(slot.satelliteId)
|
||||
if (!bucket) continue
|
||||
const start = parseLocal(slot.tn)
|
||||
const end = parseLocal(slot.tk)
|
||||
if (!Number.isFinite(start) || !Number.isFinite(end)) continue
|
||||
const group = groupOf?.(slot)
|
||||
// Конфликтовать может только выбранный интервал в окне по времени: его start в диапазоне
|
||||
// [start − maxGap − maxDur, end + maxGap]. Вне окна зазор заведомо больше требуемого.
|
||||
const ints = bucket.intervals
|
||||
const highStart = end + DIFF_ROLL_GAP_MS
|
||||
for (let i = lowerBound(ints, start - DIFF_ROLL_GAP_MS - bucket.maxDur); i < ints.length; i++) {
|
||||
if (ints[i].start > highStart) break
|
||||
// Одна полоса — одна непрерывная съёмка: её слоты между собой не конфликтуют.
|
||||
if (group !== undefined && ints[i].group === group) continue
|
||||
if (conflictsPair(start, end, slot.roll, ints[i])) {
|
||||
conflicts.add(keyOf(slot))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return conflicts
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { SlotDTO } from "../../model/slotTypes";
|
||||
import { applySlotFilters, slotStatusKey, type SlotFilterState } from "./slotFilters";
|
||||
|
||||
function slot(over: Partial<SlotDTO>): SlotDTO {
|
||||
return {
|
||||
cycle: 1,
|
||||
satelliteId: 2,
|
||||
tn: "2026-06-15T10:00:00",
|
||||
tk: "2026-06-15T10:00:10",
|
||||
roll: 30,
|
||||
contour: "POLYGON EMPTY",
|
||||
revolution: 281,
|
||||
revolutionSign: "ASC",
|
||||
slotNumber: 1,
|
||||
state: "AVAILABLE",
|
||||
latitude: 55,
|
||||
longitude: 37,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
const allStatuses = new Set(["free", "booked", "occupied"] as const);
|
||||
const allBranches = new Set(["ASC", "DESC"] as const);
|
||||
|
||||
describe("slotStatusKey", () => {
|
||||
it("бронь важнее state (booked, даже если state=AVAILABLE)", () => {
|
||||
expect(slotStatusKey({ state: "AVAILABLE" }, true)).toBe("booked");
|
||||
});
|
||||
it("BUSY → occupied, когда не бронь", () => {
|
||||
expect(slotStatusKey({ state: "BUSY" }, false)).toBe("occupied");
|
||||
});
|
||||
it("AVAILABLE → free", () => {
|
||||
expect(slotStatusKey({ state: "AVAILABLE" }, false)).toBe("free");
|
||||
});
|
||||
});
|
||||
|
||||
describe("applySlotFilters", () => {
|
||||
const rows = [
|
||||
slot({ slotNumber: 1, satelliteId: 2, revolutionSign: "ASC", state: "AVAILABLE" }),
|
||||
slot({ slotNumber: 2, satelliteId: 2, revolutionSign: "DESC", state: "BUSY" }),
|
||||
slot({ slotNumber: 3, satelliteId: 5, revolutionSign: "ASC", state: "AVAILABLE" }),
|
||||
];
|
||||
const booked = new Set([1]); // слот №1 — бронь
|
||||
const isBooked = (s: SlotDTO) => booked.has(s.slotNumber);
|
||||
const base: SlotFilterState = {
|
||||
status: allStatuses,
|
||||
satellites: new Set([2, 5]),
|
||||
branches: allBranches,
|
||||
};
|
||||
|
||||
it("дефолт (все оси) пропускает всё", () => {
|
||||
expect(applySlotFilters(rows, base, isBooked)).toHaveLength(3);
|
||||
});
|
||||
it("статус free убирает бронь и занятый", () => {
|
||||
const out = applySlotFilters(rows, { ...base, status: new Set(["free"]) }, isBooked);
|
||||
expect(out.map((s) => s.slotNumber)).toEqual([3]);
|
||||
});
|
||||
it("фильтр по КА оставляет только выбранный спутник", () => {
|
||||
const out = applySlotFilters(rows, { ...base, satellites: new Set([5]) }, isBooked);
|
||||
expect(out.map((s) => s.slotNumber)).toEqual([3]);
|
||||
});
|
||||
it("ветвь ASC оставляет только восходящие", () => {
|
||||
const out = applySlotFilters(rows, { ...base, branches: new Set(["ASC"]) }, isBooked);
|
||||
expect(out.map((s) => s.slotNumber)).toEqual([1, 3]);
|
||||
});
|
||||
it("снятие последней галочки оси (пустое множество) → ничего не видно", () => {
|
||||
expect(applySlotFilters(rows, { ...base, status: new Set() }, isBooked)).toHaveLength(0);
|
||||
expect(applySlotFilters(rows, { ...base, satellites: new Set() }, isBooked)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
// Чистая логика фильтрации отображения слотов — выделена из TguSlotsTab, чтобы покрыть
|
||||
// юнит-тестами без рендера компонента. Едина для таблицы и карты: обе читают результат
|
||||
// applySlotFilters (filteredSlots).
|
||||
|
||||
import type { RevolutionSign, SlotDTO } from "../../model/slotTypes"
|
||||
|
||||
/** Производный статус слота для фильтра видимости. */
|
||||
export type SlotStatusKey = "free" | "booked" | "occupied"
|
||||
|
||||
/** Состояние фильтров отображения. Единая модель «selected = visible» на всех осях:
|
||||
* множество = что ВИДНО; пустое множество = не видно ничего (одинаково для всех осей). */
|
||||
export type SlotFilterState = {
|
||||
status: ReadonlySet<SlotStatusKey>
|
||||
satellites: ReadonlySet<number>
|
||||
branches: ReadonlySet<RevolutionSign>
|
||||
}
|
||||
|
||||
/**
|
||||
* Производный статус слота. Бронь определяется снаружи (isBooked), а не по slot.state,
|
||||
* потому что свежезабронированный кандидат приходит со state===AVAILABLE до перечитывания
|
||||
* (бронь важнее state). Иначе BUSY (занят бронью на проходе КА) → occupied, остальное → free.
|
||||
*/
|
||||
export function slotStatusKey(slot: Pick<SlotDTO, "state">, isBooked: boolean): SlotStatusKey {
|
||||
if (isBooked) return "booked"
|
||||
if (slot.state === "BUSY") return "occupied"
|
||||
return "free"
|
||||
}
|
||||
|
||||
/**
|
||||
* Фильтрует слоты по статусу/КА/ветви одним проходом (selected=visible на каждой оси).
|
||||
* @param rows Уже суженные по окну суток слоты.
|
||||
* @param filters Множества видимых значений по каждой оси.
|
||||
* @param isBooked Признак брони по слоту (bookedKeys в компоненте).
|
||||
*/
|
||||
export function applySlotFilters(
|
||||
rows: readonly SlotDTO[],
|
||||
filters: SlotFilterState,
|
||||
isBooked: (slot: SlotDTO) => boolean,
|
||||
): SlotDTO[] {
|
||||
return rows.filter(
|
||||
(slot) =>
|
||||
filters.status.has(slotStatusKey(slot, isBooked(slot))) &&
|
||||
filters.satellites.has(slot.satelliteId) &&
|
||||
filters.branches.has(slot.revolutionSign),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { RevolutionSign } from "../../model/slotTypes";
|
||||
import { groupPasses } from "./slotPasses";
|
||||
|
||||
/** Минимальный слот для группировки в витки. */
|
||||
type S = {
|
||||
satelliteId: number;
|
||||
slotNumber: number;
|
||||
cycle: number;
|
||||
revolution: number;
|
||||
revolutionSign: RevolutionSign;
|
||||
tn: string;
|
||||
};
|
||||
|
||||
const keyOf = (s: S) => `${s.satelliteId}:${s.slotNumber}:${s.cycle}`;
|
||||
|
||||
/** Слот на минуту mm от базовой даты; виток задаётся (revolution, sign, cycle). */
|
||||
function slot(
|
||||
satelliteId: number,
|
||||
slotNumber: number,
|
||||
mm: number,
|
||||
revolution: number,
|
||||
revolutionSign: RevolutionSign = "ASC",
|
||||
cycle = 1,
|
||||
): S {
|
||||
return {
|
||||
satelliteId,
|
||||
slotNumber,
|
||||
cycle,
|
||||
revolution,
|
||||
revolutionSign,
|
||||
tn: `2026-06-17T10:${String(mm).padStart(2, "0")}:00`,
|
||||
};
|
||||
}
|
||||
|
||||
describe("groupPasses", () => {
|
||||
it("пустой вход → пустой список", () => {
|
||||
expect(groupPasses([], keyOf)).toEqual([]);
|
||||
});
|
||||
|
||||
it("веер кренов одного оборота → один виток (крен не дробит)", () => {
|
||||
// Три нацеливания одного прохода КА 1, оборот 100, восх — все в один виток.
|
||||
const passes = groupPasses(
|
||||
[slot(1, 1, 0, 100), slot(1, 2, 0, 100), slot(1, 3, 1, 100)],
|
||||
keyOf,
|
||||
);
|
||||
expect(passes).toHaveLength(1);
|
||||
expect(passes[0].id).toBe("1:1:100:ASC");
|
||||
expect(passes[0].keys).toEqual(["1:1:1", "1:2:1", "1:3:1"]);
|
||||
expect(passes[0].satelliteId).toBe(1);
|
||||
expect(passes[0].revolution).toBe(100);
|
||||
});
|
||||
|
||||
it("разные обороты / ветви / КА → разные витки", () => {
|
||||
const passes = groupPasses(
|
||||
[
|
||||
slot(1, 1, 0, 100, "ASC"),
|
||||
slot(1, 2, 5, 100, "DESC"), // та же revolution, другая ветвь
|
||||
slot(1, 3, 10, 101, "ASC"), // другой оборот
|
||||
slot(2, 4, 2, 100, "ASC"), // другой КА
|
||||
],
|
||||
keyOf,
|
||||
);
|
||||
expect(passes.map((p) => p.id)).toEqual([
|
||||
"1:1:100:ASC", // 10:00
|
||||
"2:1:100:ASC", // 10:02
|
||||
"1:1:100:DESC", // 10:05
|
||||
"1:1:101:ASC", // 10:10
|
||||
]);
|
||||
});
|
||||
|
||||
it("витки упорядочены по началу прохода (хронология по всем КА)", () => {
|
||||
const passes = groupPasses(
|
||||
[slot(1, 1, 30, 100), slot(2, 2, 5, 200), slot(1, 3, 15, 101)],
|
||||
keyOf,
|
||||
);
|
||||
expect(passes.map((p) => p.startMs)).toEqual(
|
||||
[...passes.map((p) => p.startMs)].sort((a, b) => a - b),
|
||||
);
|
||||
expect(passes[0].satelliteId).toBe(2); // самый ранний (10:05)
|
||||
});
|
||||
|
||||
it("ключи внутри витка отсортированы по времени", () => {
|
||||
const passes = groupPasses(
|
||||
[slot(1, 3, 2, 100), slot(1, 1, 0, 100), slot(1, 2, 1, 100)],
|
||||
keyOf,
|
||||
);
|
||||
expect(passes[0].keys).toEqual(["1:1:1", "1:2:1", "1:3:1"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
// Группировка слотов в «витки» (проходы) для пошаговой навигации курсором ◀/▶.
|
||||
//
|
||||
// Виток (проход над районом) = слоты ОДНОГО КА на ОДНОМ обороте: ключ (satelliteId, cycle,
|
||||
// revolution, revolutionSign) — ровно тот кортеж, что показан в колонке «Виток» таблицы. В отличие
|
||||
// от полосы (slotStrips.ts: один КА + один крен + непрерывность по времени) виток НЕ дробится по
|
||||
// крену — внутри одного прохода «веер» нацеливаний на разных кренах принадлежит одному витку.
|
||||
//
|
||||
// Это identity-группировка для навигации (встать на проход, шагнуть на следующий), НЕ проверка
|
||||
// выполнимости брони. Порядок витков — хронологический по началу прохода (по всем КА вперемешку),
|
||||
// чтобы «от первого к последнему» работало при нескольких КА.
|
||||
|
||||
import type { RevolutionSign, SlotDTO } from "../../model/slotTypes"
|
||||
import { parseLocal } from "../../utils/localTime"
|
||||
|
||||
/** Минимальный набор полей слота, нужный для группировки в витки. */
|
||||
type Passable = Pick<SlotDTO, "satelliteId" | "revolution" | "revolutionSign" | "cycle" | "tn">
|
||||
|
||||
/** Один виток (проход): кортеж витка + время начала и ключи слотов (упорядочены по времени). */
|
||||
export type Pass = {
|
||||
/** Стабильный id витка: `${sat}:${cycle}:${revolution}:${sign}`. */
|
||||
id: string
|
||||
satelliteId: number
|
||||
revolution: number
|
||||
revolutionSign: RevolutionSign
|
||||
cycle: number
|
||||
/** Начало прохода — минимальный tn среди слотов витка (мс, локально). */
|
||||
startMs: number
|
||||
/** Ключи слотов витка, отсортированы по времени начала. */
|
||||
keys: string[]
|
||||
}
|
||||
|
||||
/** Ключ витка из полей слота. */
|
||||
function passId(slot: Passable): string {
|
||||
return `${slot.satelliteId}:${slot.cycle}:${slot.revolution}:${slot.revolutionSign}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Размечает слоты по виткам и возвращает витки в хронологическом порядке (по началу прохода).
|
||||
*
|
||||
* Детерминизм: порядок не зависит от входного массива — внутри сортируем ключи витка по времени,
|
||||
* сами витки — по (startMs, id). Слоты без валидного времени игнорируем для startMs (берут +∞ при
|
||||
* сравнении внутри витка), но в keys попадают, чтобы виток не «потерял» слот.
|
||||
*
|
||||
* @param slots Слоты для разметки (любой порядок).
|
||||
* @param keyOf Аксессор ключа слота (как slotKey в TguSlotsTab) — чтобы не дублировать формулу.
|
||||
*/
|
||||
export function groupPasses<T extends Passable>(slots: readonly T[], keyOf: (slot: T) => string): Pass[] {
|
||||
type Bucket = { slot: Passable; entries: { key: string; tnMs: number }[] }
|
||||
const buckets = new Map<string, Bucket>()
|
||||
for (const slot of slots) {
|
||||
const id = passId(slot)
|
||||
let bucket = buckets.get(id)
|
||||
if (!bucket) {
|
||||
bucket = { slot, entries: [] }
|
||||
buckets.set(id, bucket)
|
||||
}
|
||||
bucket.entries.push({ key: keyOf(slot), tnMs: parseLocal(slot.tn) })
|
||||
}
|
||||
|
||||
const passes: Pass[] = []
|
||||
for (const [id, bucket] of buckets) {
|
||||
bucket.entries.sort((a, b) => a.tnMs - b.tnMs || (a.key < b.key ? -1 : a.key > b.key ? 1 : 0))
|
||||
const startMs = bucket.entries.reduce(
|
||||
(min, e) => (Number.isFinite(e.tnMs) ? Math.min(min, e.tnMs) : min),
|
||||
Number.POSITIVE_INFINITY,
|
||||
)
|
||||
passes.push({
|
||||
id,
|
||||
satelliteId: bucket.slot.satelliteId,
|
||||
revolution: bucket.slot.revolution,
|
||||
revolutionSign: bucket.slot.revolutionSign,
|
||||
cycle: bucket.slot.cycle,
|
||||
startMs,
|
||||
keys: bucket.entries.map((e) => e.key),
|
||||
})
|
||||
}
|
||||
|
||||
passes.sort((a, b) => a.startMs - b.startMs || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
|
||||
return passes
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { chunk, selectionAfterCheck } from "./slotSelection";
|
||||
import { chunk, deselectStripRange, selectionAfterCheck, selectStripRange } from "./slotSelection";
|
||||
|
||||
const keyAt = (i: number) => `k${i}`;
|
||||
|
||||
@@ -42,6 +42,83 @@ describe("selectionAfterCheck", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectStripRange", () => {
|
||||
const members = ["a", "b", "c", "d"]; // в порядке времени
|
||||
|
||||
it("выделяет членов полосы от первого к последнему включительно", () => {
|
||||
const next = selectStripRange(new Set(), members, "b", "d");
|
||||
expect([...next].sort()).toEqual(["b", "c", "d"]);
|
||||
});
|
||||
|
||||
it("работает при обратном порядке кликов (последний → первый)", () => {
|
||||
const next = selectStripRange(new Set(), members, "d", "b");
|
||||
expect([...next].sort()).toEqual(["b", "c", "d"]);
|
||||
});
|
||||
|
||||
it("один и тот же ключ → один член", () => {
|
||||
const next = selectStripRange(new Set(), members, "c", "c");
|
||||
expect([...next]).toEqual(["c"]);
|
||||
});
|
||||
|
||||
it("сжимает выделение: повторный диапазон ближе к якорю снимает хвост полосы", () => {
|
||||
// якорь "a", сначала растянули до "d" (a..d), потом Shift+клик по "b" → остаётся a..b, c и d сняты.
|
||||
const expanded = selectStripRange(new Set(), members, "a", "d");
|
||||
expect([...expanded].sort()).toEqual(["a", "b", "c", "d"]);
|
||||
const shrunk = selectStripRange(expanded, members, "a", "b");
|
||||
expect([...shrunk].sort()).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("снимает хвост полосы, но не трогает выбор в других полосах", () => {
|
||||
const current = new Set(["a", "b", "c", "d", "x"]);
|
||||
const next = selectStripRange(current, members, "a", "b");
|
||||
expect([...next].sort()).toEqual(["a", "b", "x"]);
|
||||
});
|
||||
|
||||
it("сохраняет уже выбранное за пределами полосы", () => {
|
||||
const next = selectStripRange(new Set(["x"]), members, "a", "b");
|
||||
expect([...next].sort()).toEqual(["a", "b", "x"]);
|
||||
});
|
||||
|
||||
it("ключ вне полосы → выбор без изменений", () => {
|
||||
const next = selectStripRange(new Set(["x"]), members, "a", "zzz");
|
||||
expect([...next]).toEqual(["x"]);
|
||||
});
|
||||
|
||||
it("не мутирует исходное множество", () => {
|
||||
const current = new Set(["x"]);
|
||||
selectStripRange(current, members, "a", "b");
|
||||
expect([...current]).toEqual(["x"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deselectStripRange", () => {
|
||||
const members = ["a", "b", "c", "d"]; // в порядке времени
|
||||
|
||||
it("снимает ровно члены диапазона, остальной выбор не трогает", () => {
|
||||
const current = new Set(["a", "b", "c", "d", "x"]);
|
||||
const next = deselectStripRange(current, members, "b", "d");
|
||||
expect([...next].sort()).toEqual(["a", "x"]);
|
||||
});
|
||||
|
||||
it("повторный Shift+клик по тому же концу снимает то, что выделил selectStripRange", () => {
|
||||
const selected = selectStripRange(new Set(["x"]), members, "a", "c"); // a,b,c,x
|
||||
const cleared = deselectStripRange(selected, members, "a", "c");
|
||||
expect([...cleared].sort()).toEqual(["x"]);
|
||||
});
|
||||
|
||||
it("работает при обратном порядке (toKey раньше fromKey)", () => {
|
||||
const next = deselectStripRange(new Set(["a", "b", "c", "d"]), members, "d", "b");
|
||||
expect([...next].sort()).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("ключ вне полосы → выбор без изменений; вход не мутируется", () => {
|
||||
const current = new Set(["a", "b"]);
|
||||
const next = deselectStripRange(current, members, "a", "zzz");
|
||||
expect([...next].sort()).toEqual(["a", "b"]);
|
||||
expect([...current].sort()).toEqual(["a", "b"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("chunk", () => {
|
||||
it("бьёт массив на части заданного размера, последняя — остаток", () => {
|
||||
expect(chunk([1, 2, 3, 4, 5], 2)).toEqual([[1, 2], [3, 4], [5]]);
|
||||
|
||||
@@ -39,6 +39,65 @@ export function selectionAfterCheck(
|
||||
return next
|
||||
}
|
||||
|
||||
/**
|
||||
* Новое множество выбора после жеста «полоса от первого к последнему»: выбор полосы становится
|
||||
* РОВНО диапазоном [fromKey..toKey] (в порядке времени). Члены полосы вне диапазона снимаются —
|
||||
* так Shift+клик по слоту ближе к якорю «сжимает» выделение. Выбор за пределами полосы (другие
|
||||
* полосы/КА) сохраняется: снимаем/добавляем только ключи из members. Жест ограничен одной полосой:
|
||||
* вызывающий передаёт members именно её (один КА, один крен, подряд по времени); граница полосы —
|
||||
* гарантия «нельзя выделить не полосу». Если fromKey или toKey не из members — выбор не меняется
|
||||
* (страховка). Вход не мутируется.
|
||||
*
|
||||
* @param current Текущее множество выбранных ключей.
|
||||
* @param members Ключи членов полосы в порядке времени.
|
||||
* @param fromKey Ключ якоря (первый клик).
|
||||
* @param toKey Ключ второго клика.
|
||||
*/
|
||||
export function selectStripRange(
|
||||
current: ReadonlySet<string>,
|
||||
members: readonly string[],
|
||||
fromKey: string,
|
||||
toKey: string,
|
||||
): Set<string> {
|
||||
const next = new Set(current)
|
||||
const from = members.indexOf(fromKey)
|
||||
const to = members.indexOf(toKey)
|
||||
if (from < 0 || to < 0) return next
|
||||
const lo = Math.min(from, to)
|
||||
const hi = Math.max(from, to)
|
||||
members.forEach((key, i) => {
|
||||
if (i >= lo && i <= hi) next.add(key)
|
||||
else next.delete(key)
|
||||
})
|
||||
return next
|
||||
}
|
||||
|
||||
/**
|
||||
* Снимает выбор с диапазона полосы [fromKey..toKey] (в порядке времени). Зеркало selectStripRange:
|
||||
* убирает ровно члены диапазона, не трогая остальной выбор. Нужно для повторного Shift+клика по тому
|
||||
* же концу — он снимает выделенный диапазон. Если fromKey/toKey не из members — выбор не меняется.
|
||||
*
|
||||
* @param current Текущее множество выбранных ключей.
|
||||
* @param members Ключи членов полосы в порядке времени.
|
||||
* @param fromKey Ключ якоря (первый клик).
|
||||
* @param toKey Ключ второго клика.
|
||||
*/
|
||||
export function deselectStripRange(
|
||||
current: ReadonlySet<string>,
|
||||
members: readonly string[],
|
||||
fromKey: string,
|
||||
toKey: string,
|
||||
): Set<string> {
|
||||
const next = new Set(current)
|
||||
const from = members.indexOf(fromKey)
|
||||
const to = members.indexOf(toKey)
|
||||
if (from < 0 || to < 0) return next
|
||||
const lo = Math.min(from, to)
|
||||
const hi = Math.max(from, to)
|
||||
for (let i = lo; i <= hi; i++) next.delete(members[i])
|
||||
return next
|
||||
}
|
||||
|
||||
/**
|
||||
* Разбивает массив на чанки заданного размера (для чанкованной брони крупных наборов).
|
||||
* @param items Исходный массив.
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
ROLL_EPSILON,
|
||||
groupStrips,
|
||||
pickStripAnchor,
|
||||
pickStripEnd,
|
||||
type StripGrouping,
|
||||
} from "./slotStrips";
|
||||
|
||||
/** Минимальный слот для группировки. tn задаём как локальную ISO-строку (как отдаёт бэкенд). */
|
||||
type S = {
|
||||
satelliteId: number;
|
||||
slotNumber: number;
|
||||
cycle: number;
|
||||
tn: string;
|
||||
tk: string;
|
||||
roll: number;
|
||||
};
|
||||
|
||||
const keyOf = (s: S) => `${s.satelliteId}:${s.slotNumber}:${s.cycle}`;
|
||||
|
||||
/** Слот на минуту mm (от базовой даты) с длительностью 30 c. cycle ≈ «виток» (в границу не входит). */
|
||||
function slot(satelliteId: number, slotNumber: number, roll: number, mm: number, cycle = 1): S {
|
||||
const tn = `2026-06-17T10:${String(mm).padStart(2, "0")}:00`;
|
||||
const tk = `2026-06-17T10:${String(mm).padStart(2, "0")}:30`;
|
||||
return { satelliteId, slotNumber, cycle, tn, tk, roll };
|
||||
}
|
||||
|
||||
/** Разбиение на полосы как множество множеств ключей — для сравнения, не зависящего от имён PolosaId. */
|
||||
function partition(g: StripGrouping): string[][] {
|
||||
const byStrip = new Map<string, string[]>();
|
||||
for (const [key, strip] of g.idByKey) {
|
||||
const arr = byStrip.get(strip) ?? [];
|
||||
arr.push(key);
|
||||
byStrip.set(strip, arr);
|
||||
}
|
||||
return [...byStrip.values()].map((keys) => keys.slice().sort()).sort((a, b) => (a[0]! < b[0]! ? -1 : 1));
|
||||
}
|
||||
|
||||
describe("groupStrips", () => {
|
||||
it("пустой вход → пустая разметка", () => {
|
||||
const g = groupStrips([], keyOf);
|
||||
expect(g.idByKey.size).toBe(0);
|
||||
expect(g.membersOf.size).toBe(0);
|
||||
});
|
||||
|
||||
it("один слот → своя полоса", () => {
|
||||
const g = groupStrips([slot(1, 1, 5, 0)], keyOf);
|
||||
expect(g.idByKey.get("1:1:1")).toBe("polosa:1:1:1");
|
||||
expect(g.membersOf.get("polosa:1:1:1")).toEqual(["1:1:1"]);
|
||||
});
|
||||
|
||||
it("веер кренов в один момент → разные полосы; равный крен вдоль времени → одна полоса", () => {
|
||||
// КА 1: два нацеливания (крен 36.5 и 39.5, шаг 3° > ε), каждое снято в моменты mm=0 и mm=1.
|
||||
const slots = [
|
||||
slot(1, 1, 36.5, 0),
|
||||
slot(1, 2, 39.5, 0),
|
||||
slot(1, 3, 36.5, 1),
|
||||
slot(1, 4, 39.5, 1),
|
||||
];
|
||||
expect(partition(groupStrips(slots, keyOf))).toEqual([
|
||||
["1:1:1", "1:3:1"], // крен 36.5 вдоль времени
|
||||
["1:2:1", "1:4:1"], // крен 39.5 вдоль времени
|
||||
]);
|
||||
});
|
||||
|
||||
it("смена витка (cycle) внутри одного крена и каденции НЕ рвёт полосу", () => {
|
||||
const slots = [
|
||||
slot(1, 1, 5, 0, 1),
|
||||
slot(1, 2, 5, 1, 1),
|
||||
slot(1, 3, 5, 2, 2), // виток сменился у экватора — но это та же полоса
|
||||
slot(1, 4, 5, 3, 2),
|
||||
];
|
||||
expect(partition(groupStrips(slots, keyOf))).toEqual([
|
||||
["1:1:1", "1:2:1", "1:3:2", "1:4:2"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("большой разрыв по времени рвёт полосу на две (другой проход)", () => {
|
||||
const slots = [
|
||||
slot(1, 1, 5, 0),
|
||||
slot(1, 2, 5, 1),
|
||||
slot(1, 3, 5, 2),
|
||||
slot(1, 4, 5, 3),
|
||||
slot(1, 5, 5, 30), // разрыв ≫ каденции (1 мин) → новый проход
|
||||
slot(1, 6, 5, 31),
|
||||
slot(1, 7, 5, 32),
|
||||
];
|
||||
expect(partition(groupStrips(slots, keyOf))).toEqual([
|
||||
["1:1:1", "1:2:1", "1:3:1", "1:4:1"],
|
||||
["1:5:1", "1:6:1", "1:7:1"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("разные КА — разные полосы (даже при одном крене и времени)", () => {
|
||||
const slots = [slot(1, 1, 5, 0), slot(2, 1, 5, 0), slot(1, 2, 5, 1), slot(2, 2, 5, 1)];
|
||||
expect(partition(groupStrips(slots, keyOf))).toEqual([
|
||||
["1:1:1", "1:2:1"],
|
||||
["2:1:1", "2:2:1"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("граница ε по крену: разрыв == 0.5 склеивает, 0.6 рвёт", () => {
|
||||
const same = groupStrips([slot(1, 1, 5.0, 0), slot(1, 2, 5 + ROLL_EPSILON, 1)], keyOf);
|
||||
expect(partition(same)).toEqual([["1:1:1", "1:2:1"]]);
|
||||
|
||||
const broken = groupStrips([slot(1, 1, 5.0, 0), slot(1, 2, 5 + ROLL_EPSILON + 0.1, 1)], keyOf);
|
||||
expect(partition(broken)).toEqual([["1:1:1"], ["1:2:1"]]);
|
||||
});
|
||||
|
||||
it("устойчивость к порядку входа: разбиение одинаково при перемешивании", () => {
|
||||
const slots = [slot(1, 1, 5, 0), slot(1, 2, 5, 1), slot(1, 3, 20, 0), slot(2, 1, 5, 0)];
|
||||
const a = partition(groupStrips(slots, keyOf));
|
||||
const b = partition(groupStrips([...slots].reverse(), keyOf));
|
||||
const c = partition(groupStrips([slots[2]!, slots[0]!, slots[3]!, slots[1]!], keyOf));
|
||||
expect(b).toEqual(a);
|
||||
expect(c).toEqual(a);
|
||||
});
|
||||
|
||||
it("PolosaId = polosa:${ключ первого по времени члена}; membersOf — в порядке времени", () => {
|
||||
const g = groupStrips([slot(1, 2, 5, 1), slot(1, 1, 5, 0)], keyOf);
|
||||
expect(g.idByKey.get("1:1:1")).toBe("polosa:1:1:1");
|
||||
expect(g.idByKey.get("1:2:1")).toBe("polosa:1:1:1");
|
||||
expect(g.membersOf.get("polosa:1:1:1")).toEqual(["1:1:1", "1:2:1"]);
|
||||
});
|
||||
|
||||
it("NaN tn → синглтон без падения, не клеит и не рвёт валидных соседей", () => {
|
||||
const broken: S = { satelliteId: 1, slotNumber: 9, cycle: 1, tn: "битый", tk: "битый", roll: 5 };
|
||||
const slots = [slot(1, 1, 5, 0), broken, slot(1, 2, 5, 1)];
|
||||
const g = groupStrips(slots, keyOf);
|
||||
expect(g.idByKey.get("1:9:1")).toBe("polosa:1:9:1");
|
||||
expect(g.idByKey.get("1:1:1")).toBe("polosa:1:1:1");
|
||||
expect(g.idByKey.get("1:2:1")).toBe("polosa:1:1:1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("pickStripAnchor", () => {
|
||||
const tn = (m: Record<string, number>) => (key: string) => m[key] ?? Number.NaN;
|
||||
|
||||
it("берёт самый ранний по времени слот под курсором", () => {
|
||||
const got = pickStripAnchor(["b", "a", "c"], tn({ a: 200, b: 100, c: 300 }));
|
||||
expect(got).toBe("b");
|
||||
});
|
||||
|
||||
it("равное время (веер кренов в один момент) → тай-брейк по ключу", () => {
|
||||
const got = pickStripAnchor(["b", "a"], tn({ a: 100, b: 100 }));
|
||||
expect(got).toBe("a");
|
||||
});
|
||||
|
||||
it("слоты без валидного времени игнорируются", () => {
|
||||
const got = pickStripAnchor(["bad", "x"], tn({ x: 500 }));
|
||||
expect(got).toBe("x");
|
||||
});
|
||||
|
||||
it("пустой / полностью невалидный вход → null", () => {
|
||||
expect(pickStripAnchor([], tn({}))).toBeNull();
|
||||
expect(pickStripAnchor(["bad"], tn({}))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("pickStripEnd", () => {
|
||||
// Полоса 1:1:1 = [1:1:1, 1:2:1, 1:3:1] по времени; другой КА 2 — отдельная полоса.
|
||||
const g = groupStrips(
|
||||
[slot(1, 1, 5, 0), slot(1, 2, 5, 1), slot(1, 3, 5, 2), slot(2, 1, 5, 0)],
|
||||
keyOf,
|
||||
);
|
||||
const anchorId = g.idByKey.get("1:1:1")!;
|
||||
|
||||
it("берёт самого позднего по времени члена полосы под курсором", () => {
|
||||
expect(pickStripEnd(g, anchorId, ["1:1:1", "1:3:1"])).toBe("1:3:1");
|
||||
expect(pickStripEnd(g, anchorId, ["1:1:1", "1:2:1"])).toBe("1:2:1");
|
||||
});
|
||||
|
||||
it("игнорирует ключи не из своей полосы (граница полосы)", () => {
|
||||
expect(pickStripEnd(g, anchorId, ["2:1:1"])).toBeNull();
|
||||
expect(pickStripEnd(g, anchorId, ["1:2:1", "2:1:1"])).toBe("1:2:1");
|
||||
});
|
||||
|
||||
it("никого из полосы под курсором → null (клампим к прежнему концу)", () => {
|
||||
expect(pickStripEnd(g, anchorId, [])).toBeNull();
|
||||
expect(pickStripEnd(g, "polosa:нет", ["1:1:1"])).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
// Группировка слотов в «полосы» (strips) для ручного выбора полосы мышью.
|
||||
//
|
||||
// Полоса = слоты ОДНОГО КА + ОДИН крен (с допуском ROLL_EPSILON) + ПОДРЯД по времени (без разрыва
|
||||
// каденции). Виток/цикл в границу НЕ входят: у экватора номер витка меняется, а слоты остаются в
|
||||
// одной полосе, поэтому группируем только по (КА, крен, непрерывность по времени).
|
||||
//
|
||||
// Это ТОЛЬКО identity-группировка (какой слот к какой полосе относится), НЕ проверка выполнимости
|
||||
// брони. Проверка манёвренности КА (mmi/maxSurveyDuration/maxChainLength/паузы) живёт в slots-service
|
||||
// по-КА из каталога и будет вынесена в бэкенд validate-эндпоинт (iteration 2), чтобы не дублировать
|
||||
// физику на клиенте.
|
||||
|
||||
import type { SlotDTO } from "../../model/slotTypes";
|
||||
import { parseLocal } from "../../utils/localTime";
|
||||
|
||||
/**
|
||||
* Допуск сравнения крена (градусы): слоты считаются «одним креном», если разрыв по крену между
|
||||
* соседями кластера ≤ ROLL_EPSILON. Подобран по реальным данным — «веер» нацеливаний идёт с шагом
|
||||
* ~3°, поэтому 0.5° надёжно разделяет соседние нацеливания и терпит дрейф крена внутри полосы.
|
||||
*/
|
||||
export const ROLL_EPSILON = 0.5;
|
||||
|
||||
/**
|
||||
* Во сколько раз разрыв по времени должен превысить типичную каденцию полосы, чтобы считаться
|
||||
* пропуском и разорвать полосу на две. Полоса — это слоты подряд (через равные шаги по времени);
|
||||
* заметно больший разрыв означает «другой проход», т.е. другую полосу.
|
||||
*/
|
||||
export const CADENCE_GAP_FACTOR = 1.5;
|
||||
|
||||
/** Идентификатор полосы. Стабилен и читаем: `polosa:${slotKey первого по времени члена}`. */
|
||||
export type PolosaId = string;
|
||||
|
||||
/**
|
||||
* Результат разметки: id полосы по ключу слота и упорядоченные по времени члены каждой полосы
|
||||
* (членение нужно жесту «выбрать от первого к последнему» — взять срез между двумя кликами).
|
||||
*/
|
||||
export type StripGrouping = {
|
||||
idByKey: Map<string, PolosaId>;
|
||||
membersOf: Map<PolosaId, string[]>;
|
||||
};
|
||||
|
||||
/** Минимальный набор полей слота, нужный для группировки. */
|
||||
type Stripable = Pick<SlotDTO, "satelliteId" | "slotNumber" | "cycle" | "tn" | "tk" | "roll">;
|
||||
|
||||
type Item = { key: string; sat: number; roll: number; tnMs: number };
|
||||
|
||||
function cmpStr(a: string, b: string): number {
|
||||
return a < b ? -1 : a > b ? 1 : 0;
|
||||
}
|
||||
|
||||
/** Медиана массива (для типичной каденции полосы). Пустой массив → 0. */
|
||||
function median(values: readonly number[]): number {
|
||||
if (values.length === 0) return 0;
|
||||
const s = [...values].sort((a, b) => a - b);
|
||||
const mid = Math.floor(s.length / 2);
|
||||
return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Делит кластер слотов одного крена (одного КА) на полосы по непрерывности времени: сортирует по
|
||||
* времени и режет там, где разрыв заметно больше типичной каденции (медиана шагов × CADENCE_GAP_FACTOR).
|
||||
* До двух членов каденцию определить нельзя — считаем их одной полосой.
|
||||
*/
|
||||
function splitByCadence(cluster: readonly Item[]): Item[][] {
|
||||
const sorted = [...cluster].sort((a, b) => a.tnMs - b.tnMs || cmpStr(a.key, b.key));
|
||||
if (sorted.length <= 2) return [sorted];
|
||||
|
||||
const gaps: number[] = [];
|
||||
for (let i = 1; i < sorted.length; i++) gaps.push(sorted[i].tnMs - sorted[i - 1].tnMs);
|
||||
const cadence = median(gaps.filter((g) => g > 0));
|
||||
const threshold = cadence > 0 ? cadence * CADENCE_GAP_FACTOR : Infinity;
|
||||
|
||||
const runs: Item[][] = [];
|
||||
let run: Item[] = [sorted[0]];
|
||||
for (let i = 1; i < sorted.length; i++) {
|
||||
if (sorted[i].tnMs - sorted[i - 1].tnMs > threshold) {
|
||||
runs.push(run);
|
||||
run = [];
|
||||
}
|
||||
run.push(sorted[i]);
|
||||
}
|
||||
runs.push(run);
|
||||
return runs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Размечает слоты по полосам. Полоса — слоты одного КА с одним креном (кластеризация по крену с
|
||||
* допуском ROLL_EPSILON), идущие подряд по времени (разрыв каденции рвёт полосу). Виток/цикл не
|
||||
* учитываются — полоса может пересекать смену витка.
|
||||
*
|
||||
* Детерминизм: результат не зависит от порядка входного массива — внутри сортируем по крену и времени,
|
||||
* id полосы = ключ её первого по времени члена.
|
||||
*
|
||||
* @param slots Слоты для разметки (любой порядок).
|
||||
* @param keyOf Аксессор ключа слота (как `keyAt` в selectionAfterCheck) — чтобы не дублировать
|
||||
* формулу `${satelliteId}:${slotNumber}:${cycle}`.
|
||||
*/
|
||||
export function groupStrips<T extends Stripable>(
|
||||
slots: readonly T[],
|
||||
keyOf: (slot: T) => string,
|
||||
): StripGrouping {
|
||||
const idByKey = new Map<string, PolosaId>();
|
||||
const membersOf = new Map<PolosaId, string[]>();
|
||||
if (slots.length === 0) return { idByKey, membersOf };
|
||||
|
||||
const assign = (members: readonly Item[]) => {
|
||||
const keys = members.map((m) => m.key);
|
||||
const id: PolosaId = `polosa:${keys[0]}`;
|
||||
membersOf.set(id, keys);
|
||||
for (const k of keys) idByKey.set(k, id);
|
||||
};
|
||||
|
||||
const bySat = new Map<number, Item[]>();
|
||||
for (const slot of slots) {
|
||||
const item: Item = {
|
||||
key: keyOf(slot),
|
||||
sat: slot.satelliteId,
|
||||
roll: slot.roll,
|
||||
tnMs: parseLocal(slot.tn),
|
||||
};
|
||||
// Слот без валидного времени не встроить в полосу по непрерывности — отдельная полоса-синглтон.
|
||||
if (Number.isNaN(item.tnMs)) {
|
||||
assign([item]);
|
||||
continue;
|
||||
}
|
||||
const arr = bySat.get(item.sat) ?? [];
|
||||
arr.push(item);
|
||||
bySat.set(item.sat, arr);
|
||||
}
|
||||
|
||||
for (const sat of Array.from(bySat.keys()).sort((a, b) => a - b)) {
|
||||
const items = bySat.get(sat)!;
|
||||
// Кластеризация по крену: сортируем по roll, начинаем новый кластер при разрыве > ROLL_EPSILON.
|
||||
items.sort((a, b) => a.roll - b.roll || a.tnMs - b.tnMs || cmpStr(a.key, b.key));
|
||||
let cluster: Item[] = [];
|
||||
let prevRoll = Number.NaN;
|
||||
const flush = () => {
|
||||
if (cluster.length > 0) for (const run of splitByCadence(cluster)) assign(run);
|
||||
};
|
||||
for (const item of items) {
|
||||
if (cluster.length > 0 && item.roll - prevRoll > ROLL_EPSILON) {
|
||||
flush();
|
||||
cluster = [];
|
||||
}
|
||||
cluster.push(item);
|
||||
prevRoll = item.roll;
|
||||
}
|
||||
flush();
|
||||
}
|
||||
|
||||
return { idByKey, membersOf };
|
||||
}
|
||||
|
||||
/**
|
||||
* Якорь полосы для жеста на карте: из ключей слотов под курсором берём самый ранний по времени —
|
||||
* это «первый слот полосы» (правило оператора: при наложении начало = первый по времени). Слоты без
|
||||
* валидного времени игнорируем; тай-брейк при равном времени (веер кренов в один момент) — по ключу
|
||||
* (детерминизм). Пустой/полностью невалидный вход → null.
|
||||
*
|
||||
* @param candidateKeys Ключи слотов под курсором (порядок не важен).
|
||||
* @param tnMsOf Время начала слота в мс (как parseLocal(tn)); NaN — слот пропускаем.
|
||||
*/
|
||||
export function pickStripAnchor(
|
||||
candidateKeys: readonly string[],
|
||||
tnMsOf: (key: string) => number,
|
||||
): string | null {
|
||||
let best: string | null = null;
|
||||
let bestTn = Number.POSITIVE_INFINITY;
|
||||
for (const key of candidateKeys) {
|
||||
const tn = tnMsOf(key);
|
||||
if (Number.isNaN(tn)) continue;
|
||||
if (best === null || tn < bestTn || (tn === bestTn && key < best)) {
|
||||
best = key;
|
||||
bestTn = tn;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* Конец полосы для жеста на карте: из ключей под курсором берём члена полосы anchorId, самого
|
||||
* ПОЗДНЕГО по времени (правило оператора: при наложении конец = последний по времени). Время = индекс
|
||||
* в membersOf (он уже отсортирован по времени), поэтому берём максимальный индекс. Ключи не из этой
|
||||
* полосы игнорируем — так выделение не уезжает за границу полосы. Никого из полосы под курсором → null
|
||||
* (вызывающий оставляет прежний конец — «клампим» к последнему валидному).
|
||||
*
|
||||
* @param grouping Разметка полос (из groupStrips).
|
||||
* @param anchorId Полоса якоря.
|
||||
* @param candidateKeys Ключи слотов под курсором.
|
||||
*/
|
||||
export function pickStripEnd(
|
||||
grouping: StripGrouping,
|
||||
anchorId: PolosaId,
|
||||
candidateKeys: readonly string[],
|
||||
): string | null {
|
||||
const members = grouping.membersOf.get(anchorId);
|
||||
if (!members) return null;
|
||||
let best: string | null = null;
|
||||
let bestIdx = -1;
|
||||
for (const key of candidateKeys) {
|
||||
if (grouping.idByKey.get(key) !== anchorId) continue;
|
||||
const idx = members.indexOf(key);
|
||||
if (idx > bestIdx) {
|
||||
bestIdx = idx;
|
||||
best = key;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
@@ -10,8 +10,19 @@ export type RevolutionSign = "ASC" | "DESC";
|
||||
*/
|
||||
export type SlotCoverageStrategy = "GREEDY" | "CONTINUOUS";
|
||||
|
||||
/** Статус слота из расчёта покрытия. */
|
||||
export type SlotStatus = "AVAILABLE" | "BOOKED" | "OCCUPIED" | string;
|
||||
/** Статус слота из расчёта покрытия (slots-service: SlotStatus). BUSY — слот занят чужой/этой
|
||||
* бронью на этом проходе КА: показываем для обзора, но забронировать нельзя. */
|
||||
export type SlotStatus = "AVAILABLE" | "BOOKED" | "BUSY" | "PROCESSED" | string;
|
||||
|
||||
/**
|
||||
* Держатель занятого (BUSY) слота — заявка, чья бронь блокирует слот (slots-service: SlotHolderDTO).
|
||||
* name/importance могут отсутствовать, если request-service недоступен (деградация — показываем id).
|
||||
*/
|
||||
export type SlotHolder = {
|
||||
requestId: string;
|
||||
name?: string | null;
|
||||
importance?: number | null;
|
||||
};
|
||||
|
||||
/** Слот покрытия заявки — кандидат на бронирование (slots-service: SlotDTO). */
|
||||
export type SlotDTO = {
|
||||
@@ -28,6 +39,8 @@ export type SlotDTO = {
|
||||
state: SlotStatus;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
/** Держатели BUSY-слота (обзор конфликтов, all=true). Пусто/нет для свободных и брони. */
|
||||
holders?: SlotHolder[] | null;
|
||||
};
|
||||
|
||||
/** Забронированный слот (slots-service: BookedSlotDTO) — ответ на POST booking/request. */
|
||||
@@ -38,6 +51,13 @@ export type BookedSlotDTO = {
|
||||
cycle: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Забронированный слот для отображения и снятия (slots-service: BookedSlotViewDTO,
|
||||
* GET booking/by-request). Поля слота (контур, координаты) + bookedSlotId для снятия по id.
|
||||
* cycle здесь — цикл БРОНИ, поэтому slotKey совпадает с ответом POST booking/request.
|
||||
*/
|
||||
export type BookedSlotView = SlotDTO & { bookedSlotId: number };
|
||||
|
||||
/** Идентификатор слота для бронирования (slots-service: SlotBookingRequestDTO). */
|
||||
export type SlotBookingRequest = {
|
||||
satelliteId: number;
|
||||
@@ -60,4 +80,6 @@ export type RequestSlotsParams = {
|
||||
sun?: number;
|
||||
/** Стратегия автоформирования покрытия; не задано — все слоты-кандидаты. */
|
||||
coverageStrategy?: SlotCoverageStrategy;
|
||||
/** true — вернуть и занятые бронью слоты (BUSY) для обзора («Показать все слоты»). */
|
||||
all?: boolean;
|
||||
};
|
||||
|
||||
@@ -468,6 +468,65 @@
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Контейнер контролов поверх карты слотов: фильтр «Сутки» + степпер витков столбиком.
|
||||
Нейтрализуем абсолютное позиционирование вложенных .map-pass-selector — стопку держит контейнер. */
|
||||
.tgu-slots-map-controls {
|
||||
position: absolute;
|
||||
top: 0.55rem;
|
||||
left: 0.55rem;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.tgu-slots-map-controls .map-pass-selector {
|
||||
position: static;
|
||||
}
|
||||
|
||||
/* Подпись прохождения (КА · оборот · ветвь · дата-время) во всю ширину, без обрезки —
|
||||
дата/время чувствительны для оператора, должны быть видны целиком. */
|
||||
.tgu-pass-stepper__label {
|
||||
padding: 0 0.65rem;
|
||||
color: var(--text);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.35;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* Степпер витков: ряд навигации ◀ ▶ под шапкой-подписью. */
|
||||
.tgu-pass-stepper__nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1.2rem;
|
||||
padding: 0.1rem 0.65rem 0.5rem;
|
||||
}
|
||||
|
||||
.tgu-pass-stepper__btn {
|
||||
flex-shrink: 0;
|
||||
width: 2rem;
|
||||
height: 1.7rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 5px;
|
||||
background: var(--bg-elevated);
|
||||
color: var(--text);
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tgu-pass-stepper__btn:hover:not(:disabled) {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.tgu-pass-stepper__btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.tgu-editor-map-stage .tgu-2d-map {
|
||||
min-height: 6rem;
|
||||
}
|
||||
|
||||
@@ -963,12 +963,162 @@
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
/* Панель покрытия над списком (D-D1). Цвет = слой coverage на карте (violet) — единый язык «покрыто». */
|
||||
.tgu-coverage {
|
||||
padding: 0.45rem 0.55rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: var(--bg-panel);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.tgu-coverage__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.tgu-coverage__title {
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tgu-coverage__toggles {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.tgu-coverage__toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tgu-coverage__toggle input {
|
||||
accent-color: rgb(167, 139, 219);
|
||||
}
|
||||
|
||||
.tgu-coverage__bar {
|
||||
position: relative;
|
||||
display: flex;
|
||||
height: 0.5rem;
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
background: rgba(167, 139, 219, 0.12);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.tgu-coverage__seg--booked {
|
||||
background: rgba(167, 139, 219, 0.65);
|
||||
}
|
||||
|
||||
.tgu-coverage__seg--sel {
|
||||
background: rgba(167, 139, 219, 0.32);
|
||||
}
|
||||
|
||||
.tgu-coverage__legend {
|
||||
margin-top: 0.3rem;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.tgu-coverage__booked {
|
||||
color: rgb(186, 164, 230);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tgu-coverage__sel {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.tgu-coverage__low {
|
||||
color: var(--text-dim);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.tgu-coverage__unavailable {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.tgu-slots-empty {
|
||||
padding: 1rem;
|
||||
color: var(--text-dim);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.tgu-slots-empty__action {
|
||||
margin-top: 0.6rem;
|
||||
}
|
||||
|
||||
/* Панель фильтров видимости (статус/КА/ветвь) — над строкой выбора. Сужает таблицу и карту. */
|
||||
.tgu-slots-filterbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.4rem 0.7rem;
|
||||
padding: 0.4rem 0.55rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: var(--bg-panel);
|
||||
}
|
||||
|
||||
.tgu-slots-filterbar__group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.tgu-slots-filterbar__label {
|
||||
color: var(--text-dim);
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.tgu-slots-filterbar__reset {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* Чип фильтра — паттерн пилюли тулбара (.tgu-map-toolbar__toggles). Неактивный = скрыто
|
||||
фильтром (приглушён). Статус-чипы в активном виде повторяют цвет бейджа статуса в таблице. */
|
||||
.tgu-slots-chip {
|
||||
min-height: 1.7rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
padding: 0.18rem 0.6rem;
|
||||
background: #101214;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.74rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
cursor: pointer;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.tgu-slots-chip:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.tgu-slots-chip.is-active {
|
||||
opacity: 1;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.tgu-slots-chip.is-booked.is-active {
|
||||
border-color: rgba(104, 195, 189, 0.58);
|
||||
background: rgba(104, 195, 189, 0.16);
|
||||
color: var(--accent-strong);
|
||||
}
|
||||
|
||||
.tgu-slots-chip.is-occupied.is-active {
|
||||
border-color: rgba(214, 69, 69, 0.5);
|
||||
background: rgba(214, 69, 69, 0.14);
|
||||
color: #e98a8a;
|
||||
}
|
||||
|
||||
/* Имя выбранной заявки в тулбаре (на месте прежнего выпадающего списка). */
|
||||
.tgu-slots-request {
|
||||
align-items: center;
|
||||
@@ -1099,6 +1249,8 @@
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.78rem;
|
||||
/* Моноширинные цифры — чтобы время, длительность и крен выравнивались по разрядам. */
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.tgu-slots-table th,
|
||||
@@ -1109,12 +1261,32 @@
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
/* Числовые колонки (Длит., Крен) — по правому краю, чтобы значения сравнивались взглядом. */
|
||||
.tgu-slots-table th:nth-child(5),
|
||||
.tgu-slots-table td:nth-child(5),
|
||||
.tgu-slots-table th:nth-child(6),
|
||||
.tgu-slots-table td:nth-child(6) {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Колонка «Вклад» (+N%) — по правому краю, цвет совпадает со слоем покрытия (violet). */
|
||||
.tgu-slots-gain-col,
|
||||
.tgu-slots-gain {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.tgu-slots-gain {
|
||||
color: rgb(167, 139, 219);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tgu-slots-table thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background: var(--bg-panel);
|
||||
color: var(--text-dim);
|
||||
/* --text-muted (#a4b0b1) даёт ~6:1 на панели — заголовки читаются (AA), --text-dim был ~4.3:1. */
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -1140,6 +1312,37 @@
|
||||
background: rgba(104, 195, 189, 0.36);
|
||||
}
|
||||
|
||||
/* Строки таблицы — поверхность жеста выбора полосы (клик + Shift+клик), поэтому гасим выделение
|
||||
текста, чтобы Shift+клик не подсвечивал текст между строками. */
|
||||
.tgu-slots-table tbody {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Рельс полосы текущего якоря: приглушённая цветная полоса слева у всех членов полосы, в которой
|
||||
стоит якорь жеста «полоса от первого к последнему». Оттенок детерминирован PolosaId (--strip-hue).
|
||||
Так оператор видит границу полосы — что выделит Shift+клик и за что выйти нельзя. У активной
|
||||
строки рельс перекрывается акцентным (is-active), это норма. */
|
||||
.tgu-slots-table tbody tr.tgu-slots-row--strip {
|
||||
box-shadow: inset 3px 0 0 hsl(var(--strip-hue, 0) 45% 55% / 0.55);
|
||||
}
|
||||
|
||||
/* Строка-якорь полосы (первый клик жеста) — чуть заметнее остальных членов полосы. */
|
||||
.tgu-slots-table tbody tr.is-strip-anchor {
|
||||
background: rgba(104, 195, 189, 0.12);
|
||||
}
|
||||
|
||||
/* Подсветка строк текущего витка (курсор ◀/▶ степпера): мягкая заливка прохода целиком, чтобы
|
||||
оператор видел все слоты одного прохода. Активная строка (is-active) перекрывает её — это норма. */
|
||||
.tgu-slots-table tbody tr.tgu-slots-row--pass {
|
||||
background: rgba(120, 150, 220, 0.14);
|
||||
}
|
||||
|
||||
/* Конфликт с текущим выбором (тот же КА, пересечение по времени): строка тусклая — оператор видит,
|
||||
что выбор делает её недоступной. Слот остаётся выбираемым (правило приближённое, запрет — за бэком). */
|
||||
.tgu-slots-table tbody tr.tgu-slots-row--conflict td {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.tgu-slots-badge {
|
||||
display: inline-block;
|
||||
padding: 0.1rem 0.45rem;
|
||||
@@ -1156,6 +1359,37 @@
|
||||
color: var(--accent-strong);
|
||||
}
|
||||
|
||||
/* Занятый слот — приглушённо-красный: оператор сразу видит, что бронировать его нельзя.
|
||||
Свободные остаются нейтральными (их большинство), цветом выделяем именно исключения. */
|
||||
.tgu-slots-badge--occupied {
|
||||
border-color: rgba(214, 69, 69, 0.5);
|
||||
background: rgba(214, 69, 69, 0.14);
|
||||
color: #e98a8a;
|
||||
}
|
||||
|
||||
/* Держатель BUSY-слота (M3): кто блокирует слот. Тот же приглушённо-красный регистр, что и
|
||||
бейдж «недоступен» (--occupied), но мягче — это уточнение к статусу, не отдельный сигнал. */
|
||||
.tgu-slots-holder {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(214, 69, 69, 0.32);
|
||||
background: rgba(214, 69, 69, 0.08);
|
||||
color: #e98a8a;
|
||||
font-size: 0.7rem;
|
||||
max-width: 12rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tgu-slots-holder__imp {
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.tgu-map-stage {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
@@ -1216,6 +1450,14 @@
|
||||
stroke-dasharray: 6 4;
|
||||
}
|
||||
|
||||
/* Конфликтующие с выбором слоты — приглушённый серый «налёт»: слот гаснет, оператор видит, что он
|
||||
стал недоступен из-за текущего выбора. Отличается и от зелёной брони, и от красного BUSY. */
|
||||
.tgu-2d-map__overlay polygon.is-dimmed {
|
||||
fill: rgba(20, 24, 28, 0.55);
|
||||
stroke: rgba(120, 130, 140, 0.5);
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.tgu-2d-map__overlay polyline.is-selected {
|
||||
stroke: #f0ad2e;
|
||||
stroke-width: 7;
|
||||
@@ -1385,7 +1627,8 @@
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
top: 0.85rem;
|
||||
left: 0.85rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
max-width: 23rem;
|
||||
border: 1px solid rgba(240, 173, 46, 0.42);
|
||||
border-radius: 7px;
|
||||
@@ -1402,23 +1645,6 @@
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
/* Подсказка о перегрузе карты слотами (карту не блокируем). */
|
||||
.tgu-slots-map-hint {
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
left: 50%;
|
||||
bottom: 0.85rem;
|
||||
transform: translateX(-50%);
|
||||
max-width: 90%;
|
||||
padding: 0.4rem 0.7rem;
|
||||
border: 1px solid rgba(240, 173, 46, 0.42);
|
||||
border-radius: 7px;
|
||||
background: rgba(17, 19, 21, 0.88);
|
||||
color: #ffe7b5;
|
||||
font-size: 0.78rem;
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
/* Баннер подтверждения крупной брони. */
|
||||
.tgu-slots-confirm {
|
||||
position: absolute;
|
||||
@@ -1443,6 +1669,83 @@
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
/* Прогресс-бар чанковой операции (бронь и снятие). В одной палитре и позиции с оверлеем
|
||||
«Расчёт слотов…»: сверху по центру, янтарный на тёмном — чтобы не перекрывать нижнюю подсказку
|
||||
«много слотов в окне» и читаться как одно семейство уведомлений. */
|
||||
.tgu-chunk-progress {
|
||||
position: absolute;
|
||||
z-index: 6;
|
||||
top: 0.85rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: min(23rem, 80%);
|
||||
padding: 0.65rem 0.75rem;
|
||||
border: 1px solid rgba(240, 173, 46, 0.42);
|
||||
border-radius: 7px;
|
||||
background: rgba(17, 19, 21, 0.88);
|
||||
color: #ffe7b5;
|
||||
font-size: 0.82rem;
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
.tgu-chunk-progress__head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.tgu-chunk-progress__count {
|
||||
color: rgba(255, 231, 181, 0.7);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.tgu-chunk-progress__track {
|
||||
height: 5px;
|
||||
border-radius: 3px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tgu-chunk-progress__fill {
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
background: #f0ad2e;
|
||||
transition: width 0.15s ease;
|
||||
}
|
||||
|
||||
/* Ячейка статуса брони с кнопкой снятия одного слота. */
|
||||
.tgu-slots-status-cell {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.tgu-slots-cancel-one {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
padding: 0;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.7rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tgu-slots-cancel-one:hover:not(:disabled) {
|
||||
border-color: var(--danger, #c0524f);
|
||||
color: var(--danger, #c0524f);
|
||||
}
|
||||
|
||||
.tgu-slots-cancel-one:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.tgu-map-errors {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
|
||||
@@ -31,6 +31,18 @@ export function formatLocalDateTime(ms: number): string {
|
||||
});
|
||||
}
|
||||
|
||||
/** Дата-время с секундами (дд.мм.гггг, чч:мм:сс) — для коротких слотов, где важны секунды. */
|
||||
export function formatLocalDateTimeSeconds(ms: number): string {
|
||||
return new Date(ms).toLocaleString("ru-RU", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit"
|
||||
});
|
||||
}
|
||||
|
||||
/** Дата (дд.мм) в локальной зоне. */
|
||||
export function formatLocalDate(ms: number): string {
|
||||
return new Date(ms).toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit" });
|
||||
|
||||
+9
-1
@@ -50,8 +50,16 @@ class BookingController {
|
||||
|
||||
@PostMapping()
|
||||
fun bookSlot(@RequestBody req : SlotBookingRequestDTO) = slotService.bookSlot(req)
|
||||
// Снятие одной брони: request-aware (см. SlotService.cancelSlot) — нужен requestId,
|
||||
// чтобы при шаренном слоте порвать только связь этой заявки, а не снести бронь у других.
|
||||
@DeleteMapping
|
||||
fun cancelSlot(@RequestParam("id") id : Long) = slotService.cancelSlot(id)
|
||||
fun cancelSlot(@RequestParam("id") id : Long, @RequestParam requestId : String) =
|
||||
slotService.cancelSlot(requestId, id)
|
||||
// Снятие выбранных броней пачкой (фронт чанкует по 200). Список id в query (?ids=1&ids=2),
|
||||
// а не в теле — тело у DELETE через gateway ненадёжно.
|
||||
@DeleteMapping("batch")
|
||||
fun cancelBatch(@RequestParam requestId : String, @RequestParam ids : List<Long>) =
|
||||
slotService.cancelReqSlots(requestId, ids)
|
||||
@PostMapping("request")
|
||||
fun bookReq(@RequestBody req : BookingRequestDTO) =
|
||||
slotService.bookReq(req)
|
||||
|
||||
+3
-1
@@ -103,8 +103,10 @@ class SlotController {
|
||||
@RequestParam("sun") sun : Double?,
|
||||
@Parameter(description = "Вариант расчета покрытия")
|
||||
@RequestParam("coverageStrategy", required = false) coverageStrategy: SlotCoverageStrategy?,
|
||||
@Parameter(description = "Вернуть и занятые бронью слоты (BUSY) — для обзора; по умолчанию только доступные/забронированные")
|
||||
@RequestParam("all", required = false) all: Boolean?,
|
||||
) : List<SlotDTO>
|
||||
= slotService.reqCover(requestId, timeStart, timeStop, sign, cov, satellites, sun, coverageStrategy).toList()
|
||||
= slotService.reqCover(requestId, timeStart, timeStop, sign, cov, satellites, sun, coverageStrategy, all == true).toList()
|
||||
|
||||
@GetMapping("interval")
|
||||
@Operation(summary = "Получение всех слотов на заданном интервале времени")
|
||||
|
||||
+4
@@ -51,4 +51,8 @@ class BookedSlotEntity(
|
||||
slotNum = slot.slotNum,
|
||||
cycle = cycle
|
||||
)
|
||||
|
||||
// Представление брони для фронта (контур/координаты + bookedSlotId + cycle брони) строится в
|
||||
// SlotService.toShiftedViewDTO — там доступен КА для сдвига tn/tk/витка на cycle брони. В сущности
|
||||
// строить нельзя: без сдвига получался базовый cycle-фрейм и дата брони расходилась с расчётом.
|
||||
}
|
||||
|
||||
+4
-2
@@ -6,6 +6,7 @@ import org.hibernate.annotations.FetchMode
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotStatus
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotHolderDTO
|
||||
import space.nstart.pcp.pcp_types_lib.utils.wkt.WKTParser
|
||||
import space.nstart.pcp.slots_service.model.Mar
|
||||
import java.time.LocalDateTime
|
||||
@@ -104,7 +105,7 @@ class SlotEntity(
|
||||
longitude
|
||||
)
|
||||
|
||||
fun toDTO(booked : Boolean, band : Boolean) = SlotDTO(
|
||||
fun toDTO(booked : Boolean, band : Boolean, holders : List<SlotHolderDTO>? = null) = SlotDTO(
|
||||
cycle,
|
||||
satelliteId,
|
||||
tn,
|
||||
@@ -116,6 +117,7 @@ class SlotEntity(
|
||||
slotNum,
|
||||
state = if (booked) SlotStatus.BOOKED else if (band) SlotStatus.BUSY else SlotStatus.AVAILABLE,
|
||||
latitude,
|
||||
longitude
|
||||
longitude,
|
||||
holders = holders
|
||||
)
|
||||
}
|
||||
|
||||
+35
-1
@@ -5,6 +5,7 @@ import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.BookedSlotStatus
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotStatus
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotHolderDTO
|
||||
import space.nstart.pcp.slots_service.entity.BookedSlotEntity
|
||||
import space.nstart.pcp.slots_service.entity.SlotEntity
|
||||
import space.nstart.pcp.slots_service.model.BookedSlot
|
||||
@@ -38,11 +39,28 @@ class TestSatelliteImpl(
|
||||
states : List<SlotStatus>?
|
||||
): List<PreparedCoverageSlot> {
|
||||
class BookedInfo(val id : Long, val cycle : Long)
|
||||
// Держатель блокирующей брони: сдвинутый на cycle интервал + крен + заявки-держатели.
|
||||
// Нужен, чтобы у BUSY-слота показать, чья бронь его блокирует (M3 — обзор конфликтов).
|
||||
class HolderInterval(
|
||||
val start : LocalDateTime,
|
||||
val end : LocalDateTime,
|
||||
val roll : Double,
|
||||
val requestIds : List<String>
|
||||
)
|
||||
val selectedSlots = mutableListOf<PreparedCoverageSlot>()
|
||||
val cycleBegin = Duration.between(tnCalc, timeStart).toDays() / durationCalc
|
||||
val cycleEnd = Duration.between(tnCalc, timeStop).toDays() / durationCalc
|
||||
val activeBookedSlots = bookedSlots.filter { it.status in ACTIVE_BOOKED_STATUSES }
|
||||
val booked = activeBookedSlots.map { BookedInfo(it.slot.slotId?:0, it.cycle) }
|
||||
val holderIntervals = activeBookedSlots.map { bookedSlot ->
|
||||
val offsetDays = bookedSlot.cycle * durationCalc
|
||||
HolderInterval(
|
||||
bookedSlot.slot.tn.plusDays(offsetDays),
|
||||
bookedSlot.slot.tk.plusDays(offsetDays),
|
||||
bookedSlot.slot.roll,
|
||||
bookedSlot.requests.orEmpty().map { it.requestId }.distinct()
|
||||
)
|
||||
}
|
||||
val bookedData = activeBookedSlots.map { bookedSlot ->
|
||||
val offsetDays = bookedSlot.cycle * durationCalc
|
||||
BookedSlot(
|
||||
@@ -94,11 +112,27 @@ class TestSatelliteImpl(
|
||||
}
|
||||
}
|
||||
}
|
||||
// Для BUSY-слота собираем держателей: активные брони, чей сдвинутый интервал
|
||||
// пересекается со слотом (с учётом mmi) — та же логика наложения, что и в band.
|
||||
// distinct по requestId: одну бронь могут держать несколько заявок (множ. держатель).
|
||||
val holders = if (band) {
|
||||
holderIntervals
|
||||
.filter { holder ->
|
||||
intersection(it.tn.minusSeconds(mmi), it.tk.plusSeconds(mmi), holder.start, holder.end)
|
||||
}
|
||||
.flatMap { holder -> holder.requestIds }
|
||||
.distinct()
|
||||
.map { reqId -> SlotHolderDTO(reqId) }
|
||||
.takeIf { holders -> holders.isNotEmpty() }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
PreparedCoverageSlot(
|
||||
sourceSlotId = it.slotId ?: 0L,
|
||||
slot = it.toDTO(
|
||||
isBooked,
|
||||
band
|
||||
band,
|
||||
holders
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
+27
@@ -9,6 +9,7 @@ import reactor.core.publisher.Flux
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.EarthCellDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.RequestDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.RequestWithCellsDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotHolderDTO
|
||||
import java.util.UUID
|
||||
|
||||
|
||||
@@ -67,6 +68,32 @@ class EarthService(
|
||||
return Flux.fromIterable(requests)
|
||||
}
|
||||
|
||||
/**
|
||||
* Имя/важность заявок-держателей по идентификаторам (M3 — обзор конфликтов BUSY-слотов).
|
||||
* Один батч-проход по списку заявок (summary уже несёт name+importance — без N+1 на деталь).
|
||||
* Деградация: при недоступности request-service возвращаем пустую карту — бейдж покажет
|
||||
* только идентификатор заявки, без падения request-cover.
|
||||
*/
|
||||
fun requestHolders(ids: Set<String>): Map<String, SlotHolderDTO> {
|
||||
if (ids.isEmpty()) return emptyMap()
|
||||
return runCatching {
|
||||
val firstPage = requestsPage(page = 0)
|
||||
val items = firstPage.items.toMutableList()
|
||||
for (page in 1 until firstPage.totalPages) {
|
||||
items += requestsPage(page).items
|
||||
}
|
||||
items.asSequence()
|
||||
.filter { ids.contains(it.id.toString()) }
|
||||
.associate { summary ->
|
||||
summary.id.toString() to SlotHolderDTO(
|
||||
requestId = summary.id.toString(),
|
||||
name = summary.name.takeIf { it.isNotBlank() },
|
||||
importance = summary.importance,
|
||||
)
|
||||
}
|
||||
}.getOrElse { emptyMap() }
|
||||
}
|
||||
|
||||
private fun requestsPage(page: Int): RequestsListResponseDto =
|
||||
webClientBuilder.build()
|
||||
.get()
|
||||
|
||||
+115
-19
@@ -1216,7 +1216,8 @@ class SlotService {
|
||||
states: List<SlotStatus>? = null,
|
||||
sats : List<Long>? = null,
|
||||
sun : Double? = null,
|
||||
coverageStrategy: SlotCoverageStrategy? = SlotCoverageStrategy.GREEDY
|
||||
coverageStrategy: SlotCoverageStrategy? = SlotCoverageStrategy.GREEDY,
|
||||
includeBusy: Boolean = false
|
||||
): Iterable<SlotDTO> {
|
||||
val target = buildCoverageTargets(
|
||||
items = listOf(SlotCoverageTargetDTO(targetId = 1, wkt = wkt))
|
||||
@@ -1235,7 +1236,8 @@ class SlotService {
|
||||
batchContext = batchContext,
|
||||
cov = cov,
|
||||
sun = sun,
|
||||
coverageStrategy = coverageStrategy
|
||||
coverageStrategy = coverageStrategy,
|
||||
includeBusy = includeBusy
|
||||
)
|
||||
logger.info(
|
||||
"polySlots finish: targetId={}, candidateSlots={}, sunFilteredSlots={}, returnedSlots={}, sun={}",
|
||||
@@ -1668,10 +1670,14 @@ class SlotService {
|
||||
batchContext: CoverageBatchContext,
|
||||
cov: Boolean?,
|
||||
sun: Double?,
|
||||
coverageStrategy: SlotCoverageStrategy?
|
||||
coverageStrategy: SlotCoverageStrategy?,
|
||||
// true — добавить в результат и занятые бронью слоты (BUSY) для обзора. В расчёт покрытия
|
||||
// (solver) они не попадают: selectedSlots остаётся только из AVAILABLE/BOOKED.
|
||||
includeBusy: Boolean = false
|
||||
): CoverageSelectionResult {
|
||||
val totalStartedAtMs = System.currentTimeMillis()
|
||||
val selectedSlots = mutableListOf<SlotDTO>()
|
||||
val busySlots = mutableListOf<SlotDTO>()
|
||||
val targetSlotIdsBySatelliteId = batchContext.rawSlotIdsByTargetIdAndSatelliteId[target.targetId].orEmpty()
|
||||
val sourceSlotIds = targetSlotIdsBySatelliteId.values.sumOf { it.size }
|
||||
var preparedSlotsBeforeOccupiedFilter = 0
|
||||
@@ -1710,6 +1716,12 @@ class SlotService {
|
||||
val sunFilterResult = filterBySunAngle(filteredSlots, sun)
|
||||
sunFilteredSlots += sunFilterResult.filteredCount
|
||||
selectedSlots.addAll(sunFilterResult.slots)
|
||||
// «Показать все слоты»: добавляем и занятые бронью (BUSY) для обзора — тот же
|
||||
// sun-фильтр, чтобы не показывать заведомо непригодные по Солнцу.
|
||||
if (includeBusy) {
|
||||
val busyPrepared = preparedSlotsIncludingBlocked.filter { it.state == SlotStatus.BUSY }
|
||||
busySlots.addAll(filterBySunAngle(busyPrepared, sun).slots)
|
||||
}
|
||||
}
|
||||
}
|
||||
logger.debug(
|
||||
@@ -1815,15 +1827,17 @@ class SlotService {
|
||||
candidateSelectionMs,
|
||||
totalMs
|
||||
)
|
||||
// Без покрытия («Показать все слоты»): при includeBusy возвращаем и занятые бронью слоты.
|
||||
val resultSlots = if (includeBusy) selectedSlots + busySlots else selectedSlots
|
||||
return CoverageSelectionResult(
|
||||
slots = selectedSlots,
|
||||
slots = resultSlots,
|
||||
sourceSlotIds = sourceSlotIds,
|
||||
preparedSlotsBeforeOccupiedFilter = preparedSlotsBeforeOccupiedFilter,
|
||||
occupiedFilteredSlots = occupiedFilteredSlots,
|
||||
candidateSlotsBeforeSunFilter = candidateSlotsBeforeSunFilter,
|
||||
sunFilteredSlots = sunFilteredSlots,
|
||||
solverInputSlots = selectedSlots.size,
|
||||
solverOutputSlots = selectedSlots.size,
|
||||
solverOutputSlots = resultSlots.size,
|
||||
candidateSelectionDurationMs = candidateSelectionMs,
|
||||
solverDurationMs = 0L,
|
||||
totalDurationMs = totalMs
|
||||
@@ -1870,22 +1884,46 @@ class SlotService {
|
||||
cov: Boolean?,
|
||||
sats : List<Long>? = null,
|
||||
sun : Double? = null,
|
||||
coverageStrategy: SlotCoverageStrategy? = SlotCoverageStrategy.GREEDY
|
||||
coverageStrategy: SlotCoverageStrategy? = SlotCoverageStrategy.GREEDY,
|
||||
// true — вернуть и занятые бронью слоты (BUSY) для обзора («Показать все слоты»).
|
||||
// Бронировать их нельзя, в расчёт покрытия они не идут.
|
||||
includeBusy: Boolean = false
|
||||
): Iterable<SlotDTO> {
|
||||
logger.info("Запрос покрытия заявки $id")
|
||||
logger.info("Запрос покрытия заявки $id (includeBusy=$includeBusy)")
|
||||
val req = earthService.reqcells(id)?.request?.contour ?: throw CustomErrorException("Нет такой заявки!!!!")
|
||||
logger.info("Получена заявка : $req")
|
||||
return polySlots(
|
||||
val slots = polySlots(
|
||||
req,
|
||||
timeStart,
|
||||
timeStop,
|
||||
sign,
|
||||
cov,
|
||||
listOf(SlotStatus.AVAILABLE, SlotStatus.BOOKED),
|
||||
if (includeBusy) listOf(SlotStatus.AVAILABLE, SlotStatus.BOOKED, SlotStatus.BUSY)
|
||||
else listOf(SlotStatus.AVAILABLE, SlotStatus.BOOKED),
|
||||
sats,
|
||||
sun,
|
||||
coverageStrategy
|
||||
coverageStrategy,
|
||||
includeBusy
|
||||
)
|
||||
return if (includeBusy) enrichHolders(slots) else slots
|
||||
}
|
||||
|
||||
// Обогащает держателей BUSY-слотов именем/важностью заявки одним батч-запросом (M3 — обзор
|
||||
// конфликтов). prepareCoverageSlots проставляет держателям только requestId; здесь добавляем
|
||||
// name/importance. Деградация (request-service недоступен) — держатель остаётся с одним id.
|
||||
private fun enrichHolders(slots: Iterable<SlotDTO>): Iterable<SlotDTO> {
|
||||
val slotList = slots.toList()
|
||||
val holderIds = slotList
|
||||
.flatMap { it.holders.orEmpty() }
|
||||
.map { it.requestId }
|
||||
.toSet()
|
||||
if (holderIds.isEmpty()) return slotList
|
||||
val infoById = earthService.requestHolders(holderIds)
|
||||
if (infoById.isEmpty()) return slotList
|
||||
return slotList.map { slot ->
|
||||
val holders = slot.holders ?: return@map slot
|
||||
slot.copy(holders = holders.map { infoById[it.requestId] ?: it })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2439,8 +2477,17 @@ class SlotService {
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun bookedByReqId(id : String) : List<SlotDTO> {
|
||||
return requestRepository.findByRequestId(id).map { req -> req.bookedSlot.slot.toDTO(SlotStatus.valueOf(req.bookedSlot.status)) }
|
||||
fun bookedByReqId(id : String) : List<BookedSlotViewDTO> {
|
||||
// Время/виток брони сдвигаем на cycle БРОНИ (как resolveBookedSlotStartTime / shiftedToCycle /
|
||||
// toDetails). Иначе by-request отдаёт слот в БАЗОВОМ cycle-фрейме, а request-cover перечисляет
|
||||
// слоты уже сдвинутыми — отсюда баг «слоты бронируются на не те даты»: дата брони, восстановленная
|
||||
// из by-request, на cycle*durationCalc суток раньше расчётной. cycle в DTO остаётся циклом брони —
|
||||
// чтобы ключ sat:slotNum:cycle на фронте совпал с ответом POST booking/request.
|
||||
return requestRepository.findByRequestId(id).map { req ->
|
||||
val booked = req.bookedSlot
|
||||
val satellite = runCatching { satelliteById(booked.slot.satelliteId) }.getOrNull()
|
||||
booked.toShiftedViewDTO(satellite)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2485,24 +2532,50 @@ class SlotService {
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Снимает одну бронь-связь заявки. Если слот забронирован только под эту заявку
|
||||
* (requests.size == 1) — удаляем сам слот; иначе рвём только связь заявки, а слот
|
||||
* остаётся у других заявок. Единый источник правды для cancelReq / cancelReqSlots /
|
||||
* cancelSlot — нельзя удалять BookedSlot по id напрямую, иначе снос брони у чужой заявки.
|
||||
*/
|
||||
private fun removeBookedRequest(link: BookedRequestEntity) {
|
||||
if (link.bookedSlot.requests?.size == 1)
|
||||
bookedSlotsRepository.deleteByBookedSlotId(link.bookedSlot.bookedSlotId ?: -1)
|
||||
else
|
||||
requestRepository.deleteByBookedSlotRequestId(link.bookedSlotRequestId ?: -1)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun cancelReq(req : String) : Int{
|
||||
logger.info("Запрос на отмену бронирование по заявке ${req}")
|
||||
var cnt = 0
|
||||
requestRepository.findByRequestId(req).forEach { booked ->
|
||||
if (booked.bookedSlot.requests?.size == 1)
|
||||
bookedSlotsRepository.deleteByBookedSlotId(booked.bookedSlot.bookedSlotId?:-1)
|
||||
else
|
||||
requestRepository.deleteByBookedSlotRequestId( booked.bookedSlotRequestId?:-1)
|
||||
requestRepository.findByRequestId(req).forEach { link ->
|
||||
removeBookedRequest(link)
|
||||
++cnt
|
||||
}
|
||||
return cnt
|
||||
}
|
||||
|
||||
/**
|
||||
* Снимает брони заявки по списку bookedSlotId (фронт «Снять выбранные», чанк 200).
|
||||
* request-aware через removeBookedRequest — безопасно для броней, шаренных между заявками.
|
||||
* id без брони у этой заявки молча пропускаются; возвращает число фактически снятых.
|
||||
*/
|
||||
@Transactional
|
||||
fun cancelReqSlots(requestId : String, bookedSlotIds : List<Long>) : Int {
|
||||
logger.info("Запрос на отмену ${bookedSlotIds.size} броней заявки ${requestId}")
|
||||
var cnt = 0
|
||||
for (id in bookedSlotIds) {
|
||||
val link = requestRepository.findByBookedSlot_BookedSlotIdAndRequestId(id, requestId) ?: continue
|
||||
removeBookedRequest(link)
|
||||
++cnt
|
||||
}
|
||||
return cnt
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun cancelSlot(bookedSlotId : Long) =
|
||||
bookedSlotsRepository.deleteByBookedSlotId(bookedSlotId)
|
||||
fun cancelSlot(requestId : String, bookedSlotId : Long) : Int =
|
||||
cancelReqSlots(requestId, listOf(bookedSlotId))
|
||||
|
||||
fun resolveBookedSlotStartTime(bookedSlot: BookedSlotEntity): LocalDateTime? {
|
||||
val satellite = runCatching { satelliteById(bookedSlot.slot.satelliteId) }.getOrNull() ?: return null
|
||||
@@ -2582,6 +2655,29 @@ class SlotService {
|
||||
f81 = f81 ?: InitialConditionsDTO().f81
|
||||
)
|
||||
|
||||
// Бронь для фронта со сдвигом на cycle брони (виток + tn/tk). satellite == null (КА не в каталоге) —
|
||||
// отдаём базовый фрейм без сдвига: лучше показать слот, чем уронить весь список by-request. Контур и
|
||||
// координаты не сдвигаем — наземный след повторяется каждый цикл (симметрично shiftedToCycle).
|
||||
private fun BookedSlotEntity.toShiftedViewDTO(satellite: AbstractSatellite?): BookedSlotViewDTO {
|
||||
val shiftDays = satellite?.let { cycle * it.durationCalc } ?: 0L
|
||||
val revShift = satellite?.let { cycle * it.cycleRevs } ?: 0L
|
||||
return BookedSlotViewDTO(
|
||||
bookedSlotId = bookedSlotId ?: 0,
|
||||
cycle = cycle,
|
||||
satelliteId = slot.satelliteId,
|
||||
tn = slot.tn.plusDays(shiftDays),
|
||||
tk = slot.tk.plusDays(shiftDays),
|
||||
roll = slot.roll,
|
||||
contour = slot.contour,
|
||||
revolution = slot.revolution + revShift,
|
||||
revolutionSign = RevolutionSign.valueOf(slot.revolutionSign),
|
||||
slotNumber = slot.slotNum,
|
||||
state = SlotStatus.BOOKED,
|
||||
latitude = slot.latitude,
|
||||
longitude = slot.longitude
|
||||
)
|
||||
}
|
||||
|
||||
private fun SlotEntity.shiftedToCycle(cycle: Long, satellite: AbstractSatellite): SlotDTO =
|
||||
SlotDTO(
|
||||
cycle = cycle,
|
||||
|
||||
+47
@@ -3,10 +3,13 @@ package space.nstart.pcp.slots_service.model.satellite
|
||||
import org.junit.jupiter.api.Test
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.BookedSlotStatus
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotStatus
|
||||
import space.nstart.pcp.slots_service.entity.BookedRequestEntity
|
||||
import space.nstart.pcp.slots_service.entity.BookedSlotEntity
|
||||
import space.nstart.pcp.slots_service.entity.SlotEntity
|
||||
import java.time.LocalDateTime
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class TestSatelliteImplBookedBlockingTest {
|
||||
|
||||
@@ -40,6 +43,50 @@ class TestSatelliteImplBookedBlockingTest {
|
||||
assertEquals(SlotStatus.BUSY, result.getValue(2L).state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `busy slot carries holders from blocking booking, booked and available slots carry none`() {
|
||||
val baseTime = LocalDateTime.of(2026, 5, 21, 0, 0)
|
||||
val satellite = TestSatelliteImpl(
|
||||
id = 1L,
|
||||
tnCalc = baseTime,
|
||||
durationCalc = 1,
|
||||
maxChainLength = 3,
|
||||
maxSurveyDuration = 300,
|
||||
mmi = 10
|
||||
)
|
||||
val bookedRawSlot = slot(slotId = 1L, slotNum = 1L, baseTime = baseTime, startSecond = 0, endSecond = 10)
|
||||
val candidateInsideBooked = slot(slotId = 2L, slotNum = 2L, baseTime = baseTime, startSecond = 2, endSecond = 8)
|
||||
// Свободный кандидат вне интервала и mmi-окна брони — держателей быть не должно.
|
||||
val freeSlot = slot(slotId = 3L, slotNum = 3L, baseTime = baseTime, startSecond = 100, endSecond = 110)
|
||||
// Множественный держатель: одну бронь держат две заявки.
|
||||
val bookedWithRequests = BookedSlotEntity(
|
||||
slot = bookedRawSlot,
|
||||
cycle = 0,
|
||||
requests = mutableListOf(
|
||||
BookedRequestEntity(requestId = "req-A"),
|
||||
BookedRequestEntity(requestId = "req-B"),
|
||||
),
|
||||
status = BookedSlotStatus.PROCESSED.name,
|
||||
)
|
||||
|
||||
val result = satellite.prepareCoverageSlots(
|
||||
slots = listOf(bookedRawSlot, candidateInsideBooked, freeSlot),
|
||||
bookedSlots = listOf(bookedWithRequests),
|
||||
timeStart = baseTime,
|
||||
timeStop = baseTime.plusSeconds(120),
|
||||
sign = null,
|
||||
states = null
|
||||
).map { it.slot }.associateBy { it.slotNumber }
|
||||
|
||||
val busy = result.getValue(2L)
|
||||
assertEquals(SlotStatus.BUSY, busy.state)
|
||||
assertEquals(listOf("req-A", "req-B"), busy.holders.orEmpty().map { it.requestId }.sorted())
|
||||
// На уровне прогноза держателям проставлен только requestId — name/importance добавит reqCover.
|
||||
assertTrue(busy.holders.orEmpty().all { it.name == null && it.importance == null })
|
||||
assertNull(result.getValue(1L).holders, "у самой брони (BOOKED) держателей не показываем")
|
||||
assertNull(result.getValue(3L).holders, "у свободного слота держателей нет")
|
||||
}
|
||||
|
||||
private fun slot(
|
||||
slotId: Long,
|
||||
slotNum: Long,
|
||||
|
||||
+180
@@ -0,0 +1,180 @@
|
||||
package space.nstart.pcp.slots_service.service
|
||||
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito.mock
|
||||
import org.mockito.Mockito.never
|
||||
import org.mockito.Mockito.verify
|
||||
import org.mockito.Mockito.`when`
|
||||
import org.springframework.test.util.ReflectionTestUtils
|
||||
import space.nstart.pcp.slots_service.entity.BookedRequestEntity
|
||||
import space.nstart.pcp.slots_service.entity.BookedSlotEntity
|
||||
import space.nstart.pcp.slots_service.entity.SlotEntity
|
||||
import space.nstart.pcp.slots_service.model.satellite.TestSatelliteImpl
|
||||
import space.nstart.pcp.slots_service.repository.BookedRequestRepository
|
||||
import space.nstart.pcp.slots_service.repository.BookedSlotsRepository
|
||||
import java.time.LocalDateTime
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
/**
|
||||
* Снятие брони request-aware. Ключевой инвариант: снятие НИКОГДА не удаляет BookedSlot,
|
||||
* шаренный между заявками, по id напрямую — рвёт только связь текущей заявки.
|
||||
*/
|
||||
class SlotServiceCancelTest {
|
||||
|
||||
private val bookedSlotsRepository = mock(BookedSlotsRepository::class.java)
|
||||
private val requestRepository = mock(BookedRequestRepository::class.java)
|
||||
private val satelliteCatalogClient = mock(SatelliteCatalogClient::class.java)
|
||||
|
||||
private fun service(): SlotService {
|
||||
val service = SlotService()
|
||||
ReflectionTestUtils.setField(service, "bookedSlotsRepository", bookedSlotsRepository)
|
||||
ReflectionTestUtils.setField(service, "requestRepository", requestRepository)
|
||||
ReflectionTestUtils.setField(service, "satelliteCatalogClient", satelliteCatalogClient)
|
||||
return service
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cancelReqSlots deletes the slot when booked by a single request`() {
|
||||
val link = soleLink(bookedSlotId = 10L, linkId = 100L, requestId = "R1")
|
||||
`when`(requestRepository.findByBookedSlot_BookedSlotIdAndRequestId(10L, "R1")).thenReturn(link)
|
||||
|
||||
val cnt = service().cancelReqSlots("R1", listOf(10L))
|
||||
|
||||
assertEquals(1, cnt)
|
||||
verify(bookedSlotsRepository).deleteByBookedSlotId(10L)
|
||||
verify(requestRepository, never()).deleteByBookedSlotRequestId(org.mockito.ArgumentMatchers.anyLong())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cancelReqSlots only unlinks when slot is shared across requests`() {
|
||||
// Один и тот же слот забронирован под R1 и R2 — снятие для R1 не должно трогать сам слот.
|
||||
val link = sharedLink(bookedSlotId = 20L, linkId = 200L, otherLinkId = 201L, requestId = "R1")
|
||||
`when`(requestRepository.findByBookedSlot_BookedSlotIdAndRequestId(20L, "R1")).thenReturn(link)
|
||||
|
||||
val cnt = service().cancelReqSlots("R1", listOf(20L))
|
||||
|
||||
assertEquals(1, cnt)
|
||||
verify(requestRepository).deleteByBookedSlotRequestId(200L)
|
||||
verify(bookedSlotsRepository, never()).deleteByBookedSlotId(org.mockito.ArgumentMatchers.anyLong())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cancelReqSlots skips ids not booked by the request`() {
|
||||
`when`(requestRepository.findByBookedSlot_BookedSlotIdAndRequestId(99L, "R1")).thenReturn(null)
|
||||
val link = soleLink(bookedSlotId = 10L, linkId = 100L, requestId = "R1")
|
||||
`when`(requestRepository.findByBookedSlot_BookedSlotIdAndRequestId(10L, "R1")).thenReturn(link)
|
||||
|
||||
val cnt = service().cancelReqSlots("R1", listOf(10L, 99L))
|
||||
|
||||
assertEquals(1, cnt) // 99 пропущен
|
||||
verify(bookedSlotsRepository).deleteByBookedSlotId(10L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cancelSlot delegates to request-aware single removal`() {
|
||||
val link = sharedLink(bookedSlotId = 20L, linkId = 200L, otherLinkId = 201L, requestId = "R1")
|
||||
`when`(requestRepository.findByBookedSlot_BookedSlotIdAndRequestId(20L, "R1")).thenReturn(link)
|
||||
|
||||
val cnt = service().cancelSlot("R1", 20L)
|
||||
|
||||
assertEquals(1, cnt)
|
||||
verify(requestRepository).deleteByBookedSlotRequestId(200L) // только связь, слот цел
|
||||
verify(bookedSlotsRepository, never()).deleteByBookedSlotId(org.mockito.ArgumentMatchers.anyLong())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `bookedByReqId uses booking cycle and carries id plus contour`() {
|
||||
// slot.cycle (5) != booking cycle (3) — view ДОЛЖЕН вернуть 3, иначе ключ на фронте не сойдётся.
|
||||
val link = soleLink(
|
||||
bookedSlotId = 10L, linkId = 100L, requestId = "R1",
|
||||
bookingCycle = 3L, slotCycle = 5L, contour = "POLYGON ((0 0, 1 0, 1 1, 0 0))"
|
||||
)
|
||||
`when`(requestRepository.findByRequestId("R1")).thenReturn(listOf(link))
|
||||
|
||||
val view = service().bookedByReqId("R1")
|
||||
|
||||
assertEquals(1, view.size)
|
||||
assertEquals(10L, view[0].bookedSlotId)
|
||||
assertEquals(3L, view[0].cycle) // cycle БРОНИ, не slot.cycle (5)
|
||||
assertEquals("POLYGON ((0 0, 1 0, 1 1, 0 0))", view[0].contour)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `bookedByReqId shifts tn-tk and revolution by booking cycle`() {
|
||||
// Регрессия на «слоты бронируются на не те даты»: by-request обязан отдавать слот, сдвинутый
|
||||
// на cycle брони (как request-cover), а не в базовом cycle-фрейме. cycle=2, durationCalc=16,
|
||||
// cycleRevs=243 -> сдвиг +32 суток и +486 витков относительно базового slot.tn/revolution.
|
||||
val link = soleLink(bookedSlotId = 10L, linkId = 100L, requestId = "R1", bookingCycle = 2L)
|
||||
`when`(requestRepository.findByRequestId("R1")).thenReturn(listOf(link))
|
||||
`when`(satelliteCatalogClient.satellite(77L))
|
||||
.thenReturn(TestSatelliteImpl(id = 77L, cycleRevs = 243L, durationCalc = 16L))
|
||||
|
||||
val view = service().bookedByReqId("R1")
|
||||
|
||||
assertEquals(1, view.size)
|
||||
assertEquals(2L, view[0].cycle)
|
||||
// базовый slot.tn/tk из builder slot(): 2026-03-31 10:00 / 10:10, базовый revolution = 0.
|
||||
assertEquals(LocalDateTime.of(2026, 3, 31, 10, 0).plusDays(32), view[0].tn)
|
||||
assertEquals(LocalDateTime.of(2026, 3, 31, 10, 10).plusDays(32), view[0].tk)
|
||||
assertEquals(486L, view[0].revolution)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `bookedByReqId without satellite in catalog falls back to base frame`() {
|
||||
// КА нет в каталоге -> сдвиг невозможен, отдаём базовый фрейм без падения (лучше слот, чем 500).
|
||||
val link = soleLink(bookedSlotId = 10L, linkId = 100L, requestId = "R1", bookingCycle = 2L)
|
||||
`when`(requestRepository.findByRequestId("R1")).thenReturn(listOf(link))
|
||||
`when`(satelliteCatalogClient.satellite(77L)).thenThrow(RuntimeException("нет в каталоге"))
|
||||
|
||||
val view = service().bookedByReqId("R1")
|
||||
|
||||
assertEquals(1, view.size)
|
||||
assertEquals(LocalDateTime.of(2026, 3, 31, 10, 0), view[0].tn) // без сдвига
|
||||
assertEquals(2L, view[0].cycle) // cycle брони сохранён даже без КА
|
||||
}
|
||||
|
||||
// --- builders ---
|
||||
|
||||
private fun slot(
|
||||
slotId: Long, slotCycle: Long = 0L, contour: String = ""
|
||||
) = SlotEntity(
|
||||
slotId = slotId,
|
||||
slotNum = slotId,
|
||||
cycle = slotCycle,
|
||||
satelliteId = 77L,
|
||||
contour = contour,
|
||||
tn = LocalDateTime.of(2026, 3, 31, 10, 0),
|
||||
tk = LocalDateTime.of(2026, 3, 31, 10, 10)
|
||||
)
|
||||
|
||||
private fun soleLink(
|
||||
bookedSlotId: Long, linkId: Long, requestId: String,
|
||||
bookingCycle: Long = 0L, slotCycle: Long = 0L, contour: String = ""
|
||||
): BookedRequestEntity {
|
||||
val booked = BookedSlotEntity(
|
||||
bookedSlotId = bookedSlotId,
|
||||
slot = slot(bookedSlotId, slotCycle, contour),
|
||||
cycle = bookingCycle,
|
||||
requests = mutableListOf()
|
||||
)
|
||||
val link = BookedRequestEntity(bookedSlotRequestId = linkId, bookedSlot = booked, requestId = requestId)
|
||||
booked.requests!!.add(link)
|
||||
return link
|
||||
}
|
||||
|
||||
private fun sharedLink(
|
||||
bookedSlotId: Long, linkId: Long, otherLinkId: Long, requestId: String
|
||||
): BookedRequestEntity {
|
||||
val booked = BookedSlotEntity(
|
||||
bookedSlotId = bookedSlotId,
|
||||
slot = slot(bookedSlotId),
|
||||
cycle = 0L,
|
||||
requests = mutableListOf()
|
||||
)
|
||||
val link = BookedRequestEntity(bookedSlotRequestId = linkId, bookedSlot = booked, requestId = requestId)
|
||||
val other = BookedRequestEntity(bookedSlotRequestId = otherLinkId, bookedSlot = booked, requestId = "R2")
|
||||
booked.requests!!.add(link)
|
||||
booked.requests!!.add(other)
|
||||
return link
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user