Merge branch 'tgu-ops-ui' into 'dev'

Сервис pgu-ops-ui

See merge request 2-6/2-6-4/missionis/observatio-terrae!2
This commit is contained in:
Дмитрий Соловьев
2026-06-08 11:58:27 +00:00
307 changed files with 22995 additions and 3253 deletions
+1
View File
@@ -56,6 +56,7 @@ out/
/deploy/local-bundle/pcp-local-bundle-*.tar.gz
### Node / Vite ###
/node_modules/
/services/pcp-tgu-ops-ui/node_modules/
/services/pcp-tgu-ops-ui/dist/
/services/pcp-tgu-ops-ui/.cache/
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env bash
set -euo pipefail
if [ "$#" -ne 2 ]; then
echo "Usage: $0 <old-ip> <new-ip>"
echo "Example: $0 192.168.60.146 192.168.60.146"
exit 1
fi
OLD_IP="$1"
NEW_IP="$2"
is_valid_ip() {
local ip="$1"
[[ "$ip" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] || return 1
IFS='.' read -r a b c d <<< "$ip"
for part in "$a" "$b" "$c" "$d"; do
(( part >= 0 && part <= 255 )) || return 1
done
}
if ! is_valid_ip "$OLD_IP"; then
echo "Invalid old IP: $OLD_IP"
exit 1
fi
if ! is_valid_ip "$NEW_IP"; then
echo "Invalid new IP: $NEW_IP"
exit 1
fi
export OLD_IP
export NEW_IP
changed_files=0
skipped_files=0
while IFS= read -r -d '' file; do
# Пропускаем бинарные файлы
if ! grep -Iq . "$file"; then
skipped_files=$((skipped_files + 1))
continue
fi
if perl -0ne '
BEGIN { $old = quotemeta($ENV{"OLD_IP"}); }
exit 0 if /(?<![0-9.])$old(?![0-9.])/;
exit 1;
' "$file"; then
perl -0pi -e '
BEGIN {
$old = quotemeta($ENV{"OLD_IP"});
$new = $ENV{"NEW_IP"};
}
s/(?<![0-9.])$old(?![0-9.])/$new/g;
' "$file"
echo "changed: $file"
changed_files=$((changed_files + 1))
fi
done < <(
find . \
-path './.git' -prune -o \
-type f \
-print0
)
echo ""
echo "Done."
echo "IP replaced: $OLD_IP -> $NEW_IP"
echo "Files updated: $changed_files"
echo "Files skipped: $skipped_files"
+4 -4
View File
@@ -1,16 +1,16 @@
# TODO before merge request: вернуть старые адреса окружения.
# pcp.infra.postgres.host: 192.168.1.8
# pcp.infra.postgres.host: localhost
pcp:
infra:
postgres:
host: 192.168.1.8
host: localhost
port: 35400
kafka:
host: 192.168.1.8
host: localhost
port: 19092
network:
host: 192.168.1.8
host: localhost
ports:
config: 8888
+1 -1
View File
@@ -56,7 +56,7 @@ spring:
camunda:
client:
enabled: ${CAMUNDA_CLIENT_ENABLED:false}
grpc-address: ${CAMUNDA_GRPC_ADDRESS:http://192.168.60.201:26500}
grpc-address: ${CAMUNDA_GRPC_ADDRESS:http://localhost:26500}
auth:
method: none
prefer-rest-over-grpc: false
+2 -4
View File
@@ -3,10 +3,8 @@
pcp:
infra:
postgres:
# host: ${PCP_POSTGRES_HOST:localhost}
# port: ${PCP_POSTGRES_PORT:5432}
host: ${PCP_POSTGRES_HOST:192.168.100.160}
port: ${PCP_POSTGRES_PORT:35400}
host: ${PCP_POSTGRES_HOST:localhost}
port: ${PCP_POSTGRES_PORT:5432}
kafka:
host: ${PCP_KAFKA_HOST:localhost}
port: ${PCP_KAFKA_PORT:29092}
+2
View File
@@ -78,6 +78,7 @@ app:
settings:
calculation-interval: 7
stations-service: ${pcp.services.stations}
server:
port: 8080
@@ -127,6 +128,7 @@ app:
settings:
calculation-interval: 28
ic-service: http://${pcp.network.host}:9080
stations-service: ${pcp.services.stations}
server:
port: 7003
+7 -1
View File
@@ -24,6 +24,12 @@ logging:
level:
root: INFO
# Возвращаем текст причины ошибки клиенту (reason/exception message в поле `message` тела ответа),
# иначе UI видит только HTTP-фразу и не понимает, почему сохранение съёмки/сброса отклонено.
server:
error:
include-message: always
management:
endpoints:
web:
@@ -75,7 +81,7 @@ spring:
camunda:
client:
mode: self-managed
grpc-address: ${CAMUNDA_GRPC_ADDRESS:http://192.168.60.201:26500}
grpc-address: ${CAMUNDA_GRPC_ADDRESS:http://localhost:26500}
auth:
method: none
prefer-rest-over-grpc: false
+16 -5
View File
@@ -3,9 +3,18 @@ server:
pcp:
outbox:
request-completed-topic: pcp.request.completed.v1
request-status-topic: pcp.request.status.v1
publish-batch-size: 50
publish-fixed-delay-ms: 5000
max-attempts: 10
initial-backoff-ms: 1000
max-backoff-ms: 60000
request:
expiry:
enabled: true
fixed-delay-ms: 60000
batch-size: 200
max-batches-per-run: 50
spring:
boot:
@@ -60,11 +69,13 @@ logging:
app:
kafka:
topics:
request: pcp.request.in.v1
route: pcp.route.georeference.v1
request:
cleanup-retention-days: 30
cleanup-cron: "0 0 3 * * *"
consumer:
route:
dlq-topic: pcp.route.georeference-dlq.v1
max-attempts: 5
backoff-ms: 1000
max-backoff-ms: 30000
grid:
enabled: true
+24 -4
View File
@@ -25,25 +25,41 @@ management:
exposure:
include: health,info,metrics,prometheus
# Возвращаем текст причины ошибки клиенту (reason из ResponseStatusException попадает в поле
# `message` тела ответа). Без этого UI видит только HTTP-фразу «Unprocessable Content» и не
# понимает, почему действие отклонено (напр. «план заложен — используйте fork-forward»).
server:
error:
include-message: always
springdoc:
swagger-ui:
path: /swagger/ui
planning:
notification-before-start-minutes: 263
target-plan-duration-minutes: 180
target-plan-duration-delta-minutes: 60
target-plan-duration-minutes: 1440
target-plan-duration-delta-minutes: 240
external-update-enabled: true
external-points-lookahead-days: 3
external-points-lookahead-days: 10
in-memory-history-days: 14
external-update-fixed-delay-ms: 600000
notification-check-fixed-delay-ms: 30000
min-reissue-lead-minutes: 5
reissue-prep-minutes: 30
worker-lock-ttl-seconds: 300
waiting-decision-grace-minutes: 30
# Заморозка перед стартом: ручная replace-перевыдача плана в свой слот запрещена,
# если до старта осталось меньше этого числа минут (часть «точки невозврата»).
layin-freeze-minutes: 10
topics:
visibility-windows-changed: pcp.tgu.visibility-windows-changed.v1
satellite-plan-decision: pcp.tgu.satellite-plan-decision.v1
layin-failed: pcp.tgu.layin-failed.v1
layin-confirmed: pcp.tgu.layin-confirmed.v1
plan-not-accepted: pcp.tgu.plan-not-accepted.v1
station-params-changed: pcp.tgu.station-params-changed.v1
tgu:
test-controller:
@@ -89,12 +105,16 @@ spring:
external-api:
base-url: ${pcp.services.ballistics}
mission-planing:
base-url: ${pcp.services.mission}
server:
port: ${pcp.ports.tgu:7011}
camunda:
client:
grpc-address: ${CAMUNDA_GRPC_ADDRESS:http://192.168.60.201:26500}
grpc-address: ${CAMUNDA_GRPC_ADDRESS:http://localhost:26500}
---
spring:
+1 -1
View File
@@ -3,7 +3,7 @@ set -euo pipefail
# PostgreSQL connection settings. Override via env if needed:
# PGHOST=... PGPORT=... PGUSER=... PGPASSWORD=... ./create_pcp_databases.sh
PGHOST="${PGHOST:-192.168.1.8}"
PGHOST="${PGHOST:-localhost}"
PGPORT="${PGPORT:-5432}"
PGUSER="${PGUSER:-postgres}"
PGPASSWORD="${PGPASSWORD:-password}"
+4 -4
View File
@@ -14,7 +14,7 @@
| Сервис | Зона ответственности | REST base | Хранит | Kafka (in / out) | Зрелость* |
|---|---|---|---|---|---|
| **pcp-request-service** | Приём и жизненный цикл заявок на съёмку; сетка Земли (earth-grid), геопривязка ячеек, сопоставление маршрутов с заявками | `/v1/requests`, `/v1/cells`, `/v1/grid` | заявки, ячейки сетки, outbox | in: `pcp.route.georeference.v1` · out: `pcp.request.completed.v1` (outbox) | 🟢 высокая |
| **pcp-request-service** | Приём и жизненный цикл заявок на съёмку; сетка Земли (earth-grid), геопривязка ячеек, сопоставление маршрутов с заявками | `/v1/requests`, `/v1/cells`, `/v1/grid` | заявки, ячейки сетки, outbox | in: `pcp.route.georeference.v1` · out: `pcp.request.status.v1` (outbox → geoportal) | 🟢 высокая |
| **pcp-route-processing-service** | Приём «паспортов маршрутов», нормализация/геопривязка, ретрансляция дальше; собственный outbox + DLQ | `/api/route-processing` (test) | outbox маршрутов | in: `pcp.request.survey-georeference.v1` · out: `pcp.route.georeference.v1`, `pcp.route.in.v1`, DLQ | 🟢 высокая |
| **pcp-satellite-catalog-service** | Каталог КА (источник истины): спутники, группы, observation/slot-профили | `/api/satellites`, `/api/satellite-groups` | каталог КА, профили | out: `pcp.satellites` (`SatelliteDeletedEvent`) | 🟡 средняя (мало тестов) |
| **pcp-ballistics-service** | Баллистика: орбиты, ЗРВ/RVA, узлы, трассы, time-extract по TLE | `/api/satellites`, `/api/obj-view`, `/api/tle`, `/api/rva` | орбитальные данные | in: `pcp.tle` (IC/ICRV/TLE фильтры), `pcp.satellites` | 🟡 средняя |
@@ -89,7 +89,7 @@ flowchart LR
EXT[Внешний источник / UI ⚠] -->|pcp.request.survey-georeference.v1| RP[route-processing]
RP -->|pcp.route.georeference.v1| REQ[request-service]
RP -->|pcp.route.in.v1| MP[mission-planing]
REQ -->|pcp.request.completed.v1 ⚠ потребитель| Q1{{?}}
REQ -->|pcp.request.status.v1 · COMPLETED/EXPIRED| GEO[geoportal · внешний]
CAT[satellite-catalog] -->|pcp.satellites / SatelliteDeletedEvent| BAL[ballistics]
CAT --> TLEM[tle-monitoring]
@@ -175,7 +175,7 @@ sequenceDiagram
| `pcp.route.georeference.v1` | route-processing | request-service | `RoutePassportDto` (геопривязанный) | ⚠ |
| `pcp.route.in.v1` | route-processing | mission-planing (`ModeStatusChangedEvent`) | processed route / mode-status | ⚠ |
| `pcp.request.in.v1` | request-service / UI ⚠ | ⚠ требует уточнения | `RequestPlacedEvent` | ⚠ |
| `pcp.request.completed.v1` | request-service (outbox) | ⚠ требует уточнения | RequestCompleted | `aggregateId` (UUID заявки) |
| `pcp.request.status.v1` | request-service (outbox) | **geoportal**нешний) | `REQUEST_TERMINAL_STATUS` (COMPLETED/EXPIRED) | `aggregateId` (UUID заявки) |
| `pcp.slots.status.v1` | slots-service | mission-planing, slots-service (self) | `BookedSlotsStatusChangedEvent` | ⚠ |
| `pcp.tgu.satellite-plan-decision.v1` | Mission Ops / КА ⚠ | tgu-service | `PlanDecisionMessage` (ACCEPTED/REJECTED, `attemptId`) | ⚠ |
| `pcp.tgu.visibility-windows-changed.v1` | tgu-service | tgu-service | `VisibilityWindowsChangedMessage` | ⚠ |
@@ -255,7 +255,7 @@ tgu : GET /api/plans/{spacecraftId} ; /api/platforms ;
Эти пункты помечены `⚠` выше — их стоит зафиксировать до изменений:
1. Producer топиков `pcp.request.survey-georeference.v1` и `pcp.request.in.v1` (внешняя система или UI?).
2. Consumer `pcp.request.completed.v1`кто слушает завершённые заявки (предположительно complex-mission/планирование).
2. ~~Consumer `pcp.request.completed.v1`~~уточнено: потребитель терминальных статусов заявки = внешний **geoportal**, топик переименован в `pcp.request.status.v1` (COMPLETED/EXPIRED, #35).
3. Producer `pcp.tgu.satellite-plan-decision.v1` — реальный Mission Ops/КА vs текущая имитация в `FlowService`.
4. Точные DTO payload для `pcp.tle`, `pcp.route.in.v1`, `pcp.slots.status.v1` — зафиксировать схемы.
5. Возможная циклическая зависимость `complex-mission ⇄ coverage-scheme` в рантайме.
+224
View File
@@ -0,0 +1,224 @@
# Карта взаимодействий сервисов PCP
> Составлено по исходникам (`services/*/src/main`) и конфигам (`config-repo/`).
> Три канала интеграции: **Kafka** (события), **HTTP/REST** (синхронные запросы), **Camunda 8 / Zeebe** (оркестрация процессов).
> Имена топиков и адреса сервисов берутся из Spring Cloud Config (`config-repo/*.yaml`,
> общая карта адресов — `application-dev.yaml` / `application-local.yaml`, ключи `pcp.services.*`).
## Содержание
1. [Перечень сервисов и порты](#1-перечень-сервисов-и-порты)
2. [Kafka — карта топиков и событий](#2-kafka--карта-топиков-и-событий)
3. [HTTP/REST — карта вызовов между сервисами](#3-httprest--карта-вызовов-между-сервисами)
4. [Camunda — процессы и воркеры](#4-camunda--процессы-и-воркеры)
---
## 1. Перечень сервисов и порты
Локальные порты — из `config-repo/application-local.yaml` (`pcp.ports.*`). В dev/docker все слушают `8080`.
| Сервис | Локальный порт | Роль |
|---|---|---|
| `spring-cloud-config-server` | 8888 | раздача конфигов |
| `pcp-srpring-boot-admin-server` | (admin) | мониторинг Spring Boot Admin |
| `tle-monitoring-service` | 7001 | загрузка/раздача TLE (CelesTrak) |
| `pcp-complex-mission-service` | 7002 | комплексные планы/миссии, режимы съёмки КА |
| `pcp-ballistics-service` | 7003 | баллистика, расчёт НУ/витков/трасс (он же `ic-service`) |
| `pcp-request-service` | 7005 | заявки, маршруты-привязки, outbox |
| `slots-service` | 7006 | слоты, бронирования, расчёт покрытия |
| `pcp-ui-service` | 7008 | BFF/агрегатор для старого UI |
| `pcp-stations-service` | 7009 | каталог наземных станций |
| `pcp-mission-planing-service` | 7010 | планирование миссий + **Camunda job-воркеры** |
| `pcp-tgu-service` | 7011 | ТГУ: выдача планов, **запуск Camunda-процессов** |
| `pcp-coverage-scheme-service` | 7012 | расчёт схем покрытия |
| `pcp-satellite-catalog-service` | 7013 | каталог КА |
| `pcp-dynamic-plan-service` | 7014 | динамические/комплексные планы (calcPlan) |
| `pcp-route-processing-service` | — | обработка маршрутных паспортов |
`pcp-tgu-ops-ui` — фронтенд (Vite), ходит в микросервисы напрямую через прокси, в этой карте не участвует как backend.
---
## 2. Kafka — карта топиков и событий
Событие — JSON-конверт `KafkaMessage` с заголовком `type` (значения из `PcpKafkaEvent`).
Консьюмеры фильтруют по этому заголовку.
### Топики
| Топик | Продюсер(ы) | Консьюмер(ы) | Событие / назначение |
|---|---|---|---|
| `pcp.tle` *(default-topic)* | `tle-monitoring-service` (TLE); `slots-service` (`ICRVPlacedEvent`) | `pcp-ballistics-service` (фильтры `placedIcFilter`, `updatedIcFilter`, `placedIcRvFilter`) | общий «шина TLE / НУ-РВ»: новые TLE и заложенные начальные условия |
| `pcp.satellites` | `pcp-satellite-catalog-service` (`SatelliteDeletedEvent`) | `tle-monitoring`, `slots-service`, `pcp-complex-mission-service`, `pcp-dynamic-plan-service`, `pcp-ballistics-service`, `pcp-mission-planing-service` | удаление КА (fan-out для очистки производных данных) |
| `pcp.slots.status.v1` | `pcp-mission-planing-service` (`BookedSlotsStatusChangedEvent`) | `slots-service` (`BookedSlotsKafkaListener`) | смена статуса забронированных слотов (lifecycle Kafka-first) |
| `pcp.route.in.v1` | `pcp-route-processing-service` (`processed-route`) | `pcp-mission-planing-service` (`ModeStatusChangedKafkaListener`, `ModeStatusChangedEvent`) | обработанный маршрут → смена статуса режима съёмки |
| `pcp.route.georeference.v1` | `pcp-route-processing-service` (`route-georeference`) | `pcp-request-service` (`RequestKafkaListener.consumeRoute`) | геопривязка маршрута к заявке |
| `pcp.request.survey-georeference.v1` | (инжект `RouteProcessingTestController` / внешний источник) | `pcp-route-processing-service` (`RoutePassportMessageListener`, вход) | входной маршрутный паспорт на обработку |
| `pcp.request.survey-georeference-dlq.v1` | `pcp-route-processing-service` (DLQ) | — | dead-letter для входных паспортов |
| `pcp.request.status.v1` | `pcp-request-service` (`OutboxPublisherService`, outbox) | **geoportal** (внешний, в репо нет) | терминальный статус заявки `COMPLETED`/`EXPIRED` (`REQUEST_TERMINAL_STATUS`) |
| `pcp.request.in.v1` | — | *(объявлен в конфиге `pcp-request-service`, активного слушателя нет)* | входящие заявки (legacy/зарезервировано) |
### Поток данных (упрощённо)
```
CelesTrak ──HTTP──> tle-monitoring ──pcp.tle(TLE)──────────────> ballistics
slots-service ─────pcp.tle(ICRVPlacedEvent)───────────────────> ballistics
satellite-catalog ──pcp.satellites(SatelliteDeletedEvent)──┬──> tle-monitoring
├──> slots-service
├──> complex-mission
├──> dynamic-plan
├──> ballistics
└──> mission-planing
route-processing ──pcp.route.in.v1──────> mission-planing (ModeStatusChanged)
route-processing ──pcp.route.georeference.v1──> request-service
(вход) pcp.request.survey-georeference.v1 ──> route-processing
mission-planing ──pcp.slots.status.v1(BookedSlots)──────> slots-service
request-service ──pcp.request.status.v1 (outbox)────────> geoportal (внешний)
```
### ТГУ-топики (`pcp.tgu.*`)
`pcp-tgu-service` объявляет консьюмеры на свои топики (`config-repo/pcp-tgu-service.yaml`, блок `topics:`).
Продюсеры этих событий — внешняя система СПО УОГ / тестовые эндпоинты; внутри репозитория стабильных продюсеров нет.
| Топик | Консьюмер в tgu | Событие |
|---|---|---|
| `pcp.tgu.visibility-windows-changed.v1` | `VisibilityWindowsChangedConsumer` | изменение окон видимости |
| `pcp.tgu.satellite-plan-decision.v1` | `PlanDecisionConsumer` | решение по плану (принят/отклонён) |
| `pcp.tgu.layin-confirmed.v1` | `LayinConfirmedConsumer` | подтверждение закладки |
| `pcp.tgu.layin-failed.v1` | `LayinFailedConsumer` | ошибка закладки |
| `pcp.tgu.plan-not-accepted.v1` | `PlanNotAcceptedConsumer` | план не принят |
| `pcp.tgu.station-params-changed.v1` | `StationParamsChangedConsumer` | смена параметров станции |
---
## 3. HTTP/REST — карта вызовов между сервисами
Адреса берутся из `settings.*``pcp.services.*` (Spring Cloud Config). Клиенты — `WebClient`.
| Вызывающий сервис | Целевой сервис | Эндпоинты (примеры) | Назначение |
|---|---|---|---|
| `pcp-ui-service` (BFF) | `tle-monitoring` | `/v1/api/tle`, `/v1/api/tle/satellite/{id}` | TLE |
| | `pcp-satellite-catalog` | `/api/satellites`, `/api/satellites/{id}/observation-profile`, `/slot-profile`, `/api/satellite-groups` | каталог КА |
| | `pcp-complex-mission` | `/api/missions`, `/api/missions/{id}/drops/calculate`, `/api/com-plan/runs`, `/api/com-plan/process` | миссии и комплексные планы |
| | `pcp-dynamic-plan` | `/v1/main/calcPlan`, `/{runId}/result`, `/routes`, `/intervals` | динамические планы |
| | `pcp-request` (request / earth-grid) | `/v1/requests`, `/v1/requests/{id}/cells` | заявки и ячейки |
| | `pcp-stations` | `/api/stations`, `/api/stations/{id}` | станции |
| | `slots-service` | `/api/slots/...`, `/api/slots/booking[...]` | слоты и бронирования |
| | `pcp-ballistics` | `/api/satellites/{id}/asc-node`, `/flight-line`, `/orbit`, `/extract-time`, `/initial-conditions/current`, `/receive-rv`, `/api/tle/parse`, `/api/tle/point` | баллистика |
| | `pcp-coverage-scheme` | `/api/coverage-schemes/calculate` | схемы покрытия |
| | `pcp-tgu` | `/api/platforms`, `/api/plans[...]`, `/api/plans/{id}/dismiss`, `/restore`, `/test/tgu/plans/{id}/decision`, `/confirm-layin` | планы ТГУ |
| `pcp-tgu-service` | `pcp-mission-planing` (`mission-planing.base-url`) | `/api/missions/{id}/clone`, `/api/missions/blank`, `/api/missions/{id}` | управление миссиями плана |
| | `pcp-ballistics` (`ballistics.base-url`) | расчёт точек/баллистики (`ExternalPointsClient`) | внешние точки |
| | `ordinis.nstart.cloud` (внешний) | `/api/v1/spacecraft/records` (`platforms-url`) | классификатор платформ КА |
| `pcp-mission-planing` | `pcp-ballistics` | `/api/obj-view/mpl-square`, `/mpl-point`, `/cell-coverage/{cell}`, `/api/satellites/{id}/rva`, `/extract-time` | баллистика для планирования |
| | `pcp-complex-mission` | `/api/satellites/snapshots/{id}`, режимы КА | данные комплексной миссии |
| | `pcp-satellite-catalog` | `/api/satellites/{id}` | каталог КА |
| `pcp-complex-mission` | `pcp-ballistics` (`ic-service`) | расчёты НУ/баллистики | баллистика |
| | `pcp-satellite-catalog` | `/api/satellites`, `/api/satellites/batch` | каталог КА |
| | `slots-service` | покрытие/слоты (`CoverageBatchClient`, `SlotService`) | слоты |
| | `pcp-request` (`earth-grid`) | сетка Земли/ячейки (`EarthGridService`) | заявки/ячейки |
| | `pcp-coverage-scheme` | расчёт схемы покрытия | покрытие |
| `pcp-dynamic-plan` | `pcp-request` | `/v1/requests` (выборка заявок) | заявки |
| | `pcp-ballistics` | `/api/satellites/orbit/availability/{norad}`, `/extract-time`, `/api/obj-view/mpl-square` | баллистика |
| | `pcp-satellite-catalog` | `/api/satellites` | каталог КА |
| `pcp-coverage-scheme` | `pcp-ballistics` | `/api/obj-view/mpl-square`, `/mpl-point`, `/extract-time` | баллистика |
| | `pcp-complex-mission` | `/api/satellites/{id}` | данные КА/миссии |
| `pcp-ballistics` | `pcp-stations` | `/api/stations` | станции (для расчётов видимости) |
| `slots-service` | `pcp-satellite-catalog` | `/api/satellites`, `/batch`, `/slot-profile` | каталог КА |
| | `pcp-request` (`earth-grid`) | `/v1/requests`, `/cells` | заявки/ячейки |
| `tle-monitoring` | `pcp-satellite-catalog` | `/api/satellites` | каталог КА |
Все сервисы дополнительно ходят в `spring-cloud-config-server` (8888) за конфигом и регистрируются в `spring-boot-admin-server`.
### Граф зависимостей (кто кого зовёт по HTTP)
```
ui-service ──> почти все (BFF-агрегатор)
tgu ─────────> mission-planing, ballistics, (ordinis ext)
mission-planing ─> ballistics, complex-mission, satellite-catalog
complex-mission ─> ballistics, satellite-catalog, slots, request, coverage-scheme
dynamic-plan ───> request, ballistics, satellite-catalog
coverage-scheme ─> ballistics, complex-mission
ballistics ─────> stations
slots ──────────> satellite-catalog, request
tle-monitoring ─> satellite-catalog (+ CelesTrak внешний)
```
`pcp-ballistics` и `pcp-satellite-catalog` — наиболее востребованные «нижние» сервисы.
---
## 4. Camunda — процессы и воркеры
Используется **Camunda 8 (Zeebe)**, `io.camunda:camunda-spring-boot-4-starter:8.8.10`.
Брокер внешний: gRPC `camunda.k8s.265.nstart.cloud:30901` (dev) / `localhost:26500` (локально),
ключ `camunda.client.grpc-address`.
- **`pcp-tgu-service`** — *оркестратор*: содержит BPMN-определения, разворачивает их и запускает экземпляры процессов (`CamundaClient.newCreateInstanceCommand()`).
- **`pcp-mission-planing-service`** — *исполнитель*: реализует все job-воркеры (`@JobWorker` в `FlowService`).
### Процесс `createSatelliteMission`
BPMN: [createSatelliteMission.bpmn](../../services/pcp-tgu-service/src/main/resources/BPMN/createSatelliteMission.bpmn).
Запуск: `BpmnPlanIssueProcessStarter``IssueDuePlanWorker.issuePlanNow()``PlanController` `POST /api/plans/{planId}/issue`.
```
startSatelliteMission
→ [calculateSatelliteSurveyMissions] «Сформировать план съёмки»
→ [calculateSatelliteDropMissions] «Сформировать план сбросов»
→ [sendSatellitePlan] «Отправка плана в СПО УОГ»
→ endSatelliteMission
```
### Процесс `updateAcceptedPlanSlots`
BPMN: [updateAcceptedPlanSlots.bpmn](../../services/pcp-tgu-service/src/main/resources/BPMN/updateAcceptedPlanSlots.bpmn).
Запуск: `AcceptedPlanSlotsUpdateProcessStarter``AcceptedPlanSlotsUpdateWorker`.
```
start → [updateSlotsStatus] «Обновление статуса слотов» → end
```
### Соответствие job-type → воркер
Все воркеры — в `pcp-mission-planing-service`, [FlowService.kt](../../services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/FlowService.kt), `autoComplete = false` (ручной `newCompleteCommand`).
| Job type (BPMN) | Метод воркера | Процесс |
|---|---|---|
| `calculateSatelliteSurveyMissions` | `FlowService.surveyPlan` | createSatelliteMission |
| `calculateSatelliteDropMissions` | `FlowService.surveyDrop` | createSatelliteMission |
| `sendSatellitePlan` | `FlowService.sendPlan` | createSatelliteMission |
| `updateSlotsStatus` | `FlowService.confirmPlan` | updateAcceptedPlanSlots |
### Схема
```
pcp-tgu-service pcp-mission-planing-service
(оркестратор) (job-воркеры, FlowService)
PlanController POST /api/plans/{id}/issue
BpmnPlanIssueProcessStarter
│ newCreateInstanceCommand("createSatelliteMission")
┌─────────────── Zeebe / Camunda 8 broker (gRPC) ───────────────┐
│ calculateSatelliteSurveyMissions ─────────────────────────► surveyPlan
│ calculateSatelliteDropMissions ─────────────────────────► surveyDrop
│ sendSatellitePlan ─────────────────────────► sendPlan
└───────────────────────────────────────────────────────────────┘
AcceptedPlanSlotsUpdateProcessStarter
│ newCreateInstanceCommand("updateAcceptedPlanSlots")
┌─────────────── Zeebe broker ───────────────┐
│ updateSlotsStatus ───────────────────────► confirmPlan
└─────────────────────────────────────────────┘
```
> Связь между HTTP и Camunda: воркеры `pcp-mission-planing-service` в ходе выполнения задач
> сами ходят по HTTP в `pcp-ballistics`, `pcp-complex-mission`, `pcp-satellite-catalog`
> и публикуют Kafka-событие `pcp.slots.status.v1` (см. разделы 23).
@@ -0,0 +1,153 @@
# Жизненный цикл статусов плана ТГУ
Справочник по статусам business-plan в расписании ТГУ: когда наступает каждый статус, что он
означает и какой переход его порождает. Источник правды — enum
[`PlannedPlanStatus`](../../services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/PlannedPlanStatus.kt);
переходы — в `service/IssueDuePlanWorker`, `service/HandlePlanDecisionUseCase`,
`service/CreateDraftUseCase`, `service/CreateChainPlanUseCase`, `dto/PlanResponse`.
Эти же статусы (кроме «зарезервированных», см. ниже) показаны в легенде вкладки «Планы»
([`tguStatus.ts`](../../services/pcp-tgu-ops-ui/src/features/tgu-planning/tguStatus.ts)) и задают
цвет плашек на таймлайне.
## Диаграмма переходов (упрощённо)
```
создать черновик
(нет плана) ───────────────────────────────► DRAFT
│ promote («Применить») / авто-выдача
проекция будущего ····(виртуально)····► PLANNED ──(подошёл срок, авто-выдача)──► ISSUING
│ BPMN-процесс стартовал
WAITING_DECISION
ACCEPTED-решение │ │ REJECTED-решение
▼ ▼
AWAITING_LAYIN REJECTED (цепочка на паузе)
┌────────────────────────────────┼────────────────────────────────┐
событие «заложен» событие «не заложен» событие «не принят»
▼ ▼ ▼
LAID_IN LAYIN_FAILED NOT_ACCEPTED
(база цепочки; конкуренты (срочная перевыдача) (срочная перевыдача)
того же окна → SUPERSEDED)
при сбое старта BPMN из ISSUING ─────────────────────────────────────────► START_AMBIGUOUS
```
## Статусы
### DRAFT — черновик
- **Когда.** Оператор создаёт план через «Создать план» (`CreateChainPlanUseCase`) или «Добавить»/
редактирование (`CreateDraftUseCase`).
- **Что значит.** План в стадии правки. Персистится, но **не** является головой цепочки, **не**
попадает в проекцию/read-путь как запланированный слот и **не** выдаётся авто-воркером.
- **Дальше.** Становится активным только после promote («Применить») — переходит в `ISSUING`.
Удаление черновика (abandon) убирает его и клон-миссию.
- В UI помечен как «ЧЕРНОВИК», виден только при включённом режиме «Черновики».
### PLANNED — проекция будущего (виртуальный)
- **Когда.** Вычисляется на лету `PlanProjectionService`/`PlanResponse.projected()` при чтении
расписания. **Никогда не пишется в БД.**
- **Что значит.** Предполагаемый будущий слот цепочки (следующие ЗРВ) — «как пойдёт цепочка, если
ничего не менять». Не несёт `basePlanId`, стартом цепочки не считается.
- **Дальше.** Когда подходит срок выдачи, по этому слоту авто-воркер материализует реальный план
сразу в `ISSUING` (`materializeIssuingPlan``insertIssuingPlan`).
- В UI виден только при включённом тумблере «Возможные будущие планы».
### ISSUING — идёт выдача
- **Когда.** `IssueDuePlanWorker` начинает выдачу: promote черновика (`prepareDraftPromotion`),
ручная (пере)выдача конкретного плана (`prepareSpecificPlan`) или авто-материализация головы
проекции (`materializeIssuingPlan`).
- **Что значит.** Запущен BPMN-процесс выдачи плана на борт; создан attempt со статусом `STARTING`.
- **Дальше.** Успешный старт процесса → `WAITING_DECISION` (`markStarted`). Сбой старта BPMN →
`START_AMBIGUOUS` (`markAmbiguous`).
### WAITING_DECISION — ожидает решения
- **Когда.** BPMN-процесс выдачи успешно стартовал, attempt → `STARTED` (`markStarted`).
- **Что значит.** План выдан, ждём решения оператора/борта: ACCEPTED либо REJECTED.
- **Дальше.**
- ACCEPTED → `AWAITING_LAYIN` (`acceptPlan`).
- REJECTED → `REJECTED` (`rejectPlan`).
- **Grace-окно.** Если решения нет дольше `waitingDecisionGraceMinutes` после `startTime`, цепочка
«молчаливо» едет дальше: якорь сдвигается на этот план, блокировка снимается, но **статус
остаётся `WAITING_DECISION`** (`tryAdvancePastUnconfirmedPlan`). Запоздалое решение запишется на
attempt и ушедшую вперёд цепочку не тронет. Поэтому в UI кнопки решения гасятся после grace.
### AWAITING_LAYIN — принят, ждёт подтверждения закладки
- **Когда.** Пришло решение ACCEPTED (`acceptPlan`).
- **Что значит.** План принят и ждёт закладки. **ACCEPT больше НЕ делает план базой и не двигает
`chainVersion`** — это разъединено намеренно: после приёмки можно отправить и принять коррекцию
того же окна, поэтому по одному окну может одновременно «лететь» несколько AWAITING_LAYIN/
WAITING_DECISION. Базой и версией цепочки управляет только закладка (`LAID_IN`). На ACCEPT
запускается джоб обновления слотов принятого плана (`acceptedPlanSlotsUpdateJobService.enqueue`).
- **Дальше.** При подтверждении закладки → `LAID_IN` (становится базой). При негативе —
`LAYIN_FAILED`/`NOT_ACCEPTED`. Если базой стала **другая** коррекция того же окна — этот план
гасится в `SUPERSEDED`.
- В легенде это «принятый, ждёт закладки» статус (зелёный). Перевыдача/коррекция в свой слот ещё
возможна, пока до старта больше `layinFreezeMinutes` (после — «точка невозврата», только
fork-forward).
### REJECTED — отклонён
- **Когда.** Пришло решение REJECTED (`rejectPlan`).
- **Что значит.** План признан неправильным. Авто-перевыдача **не** взводится (повторять тот же план
бессмысленно). Цепочка ставится **на паузу** (`pausedAfterReject = true`) — **только если по КА не
осталось других живых кандидатов** (в полёте/приняты, но не заложены): при нескольких выдачах
отказ одной не глушит цепочку, пока жив другой кандидат. На паузе проекция пуста, воркер ничего не
выдаёт. Базовый план не трогается.
- **Дальше.** Оператор вручную создаёт исправленный план; его promote/ручная выдача снимают паузу.
- В UI у КА с паузой рисуется пометка «⏸ на паузе».
### NOT_ACCEPTED — не принят (негативный фидбек)
- **Когда.** Пришло внешнее событие «не принят» по текущему базовому плану в `AWAITING_LAYIN`
(`handleNotAccepted``applyNegativeFeedback`).
- **Что значит.** Терминальный негатив по базовому плану. В отличие от REJECTED — это **технический**
негатив, поэтому запускается **срочная перевыдача** на ближайшую ЗРВ (`reissueOnNearestWindow`).
- Событие, пришедшее не по текущему базовому плану (устаревшее/дубль), игнорируется.
### LAYIN_FAILED — не заложен (негативный фидбек)
- **Когда.** Пришло внешнее событие «не заложен» по текущему базовому плану в `AWAITING_LAYIN`
(`handleLayinFailed``applyNegativeFeedback`).
- **Что значит.** Терминальный негатив: закладка не удалась. Поведение сейчас идентично
`NOT_ACCEPTED`**срочная перевыдача** на ближайшую ЗРВ.
### START_AMBIGUOUS — неопределённый старт
- **Когда.** Из `ISSUING` не удалось достоверно стартовать BPMN-процесс выдачи (исключение в
`startPreparedAttempt``markAmbiguous`); attempt → `AMBIGUOUS`.
- **Что значит.** Неизвестно, ушёл план на борт или нет — требуется ручной разбор оператором.
Опасный статус, в UI выделен пунктиром.
### LAID_IN — заложен (терминальный успех, база цепочки)
- **Когда.** Пришло внешнее событие «план заложен» по плану в `AWAITING_LAYIN`
(`LayinConfirmedConsumer``handleLayinConfirmed`, топик `layin-confirmed`).
- **Что значит.** Подтверждённая закладка — **именно она делает план базой цепочки**
(`state.currentBasePlanId = plan.planId`, `chainVersion += 1`, снимается пауза/срочная
перевыдача). «Точка невозврата»: дальше план меняется только через fork-forward (гарды
`CreateDraftUseCase`, `assertReissuableInSlot`).
- **Supersede конкурентов.** Все ещё не заложенные планы КА (ISSUING/WAITING_DECISION/AWAITING_LAYIN)
с окном, пересекающим окно заложенного, переводятся в `SUPERSEDED` (`supersedeCompetingPlans`).
- Событие по плану не в `AWAITING_LAYIN` (устаревшее/дубль/уже вытеснён) — игнорируется.
- В легенде — зелёный, темнее `AWAITING_LAYIN`.
### SUPERSEDED — вытеснён заложенной коррекцией (терминал)
- **Когда.** По тому же окну КА другой план ушёл в `LAID_IN`, а этот был конкурирующей выдачей/
коррекцией (`supersedeCompetingPlans`).
- **Что значит.** Терминал, цепочку не двигает. Запоздавшее решение по его attempt записывается, но
игнорируется (план уже не in-flight). Базовый план не трогается.
## Зарезервированные / не возникающие сейчас статусы
- **ACCEPTED** — в enum присутствует, но приёмка (`acceptPlan`) переводит план **сразу** в
`AWAITING_LAYIN`, минуя `ACCEPTED`. Фактически это легаси-значение, нигде не присваивается; в
легенду вкладки «Планы» намеренно не вынесено.
## Связанные конфиги (`PlanningProperties`)
- `waitingDecisionGraceMinutes` — сколько ждём решение после старта, прежде чем «молчаливо» ехать
дальше (см. `WAITING_DECISION`).
- `layinFreezeMinutes` — за сколько минут до старта окно выдачи замораживается («точка невозврата»):
перевыдача в свой слот запрещена, только fork-forward.
- `notificationBeforeStartMinutes` — за сколько до старта авто-воркер начинает выдачу головы цепочки.
## См. также
- [PCP_ARCHITECTURE.md](PCP_ARCHITECTURE.md)
- [PCP_TGU_OPS_UI_CAPABILITY_MAP.md](PCP_TGU_OPS_UI_CAPABILITY_MAP.md)
+2
View File
@@ -21,6 +21,8 @@
- существующие публичные контракты не сломаны без явного запроса;
- добавлены или обновлены релевантные тесты;
- не внесено несвязанных правок;
- время на API — naive `LocalDateTime` без зоны; backend штампует `now` через `LocalDateTime.now()`, фронт парсит/форматирует через `utils/utcTime` без `new Date("<строка>")`;
- egress фронта к бэку — naive ISO (`YYYY-MM-DDTHH:mm:ss`, без Z);
- validation выполнен по правилам `AGENTS.md`, либо пропуски явно объяснены.
### Дополнительно для BPM / Kafka / Zeebe / external integration изменений
+30
View File
@@ -0,0 +1,30 @@
# Следующая задача: маршруты, сбросы и перенос включений между планами
> Статус: запланировано (дизайн ещё не начат). Это фиксация намерения, не финальный план.
Следующий блок работ — три связанные темы вокруг содержимого плана (включений) и
их съёмочно-сбросовой обвязки:
## 1. Построение маршрутов
Довести построение маршрутов наблюдения по плану. Сейчас маршруты порождаются в
потоке `createSatelliteMission` (BPMN), где `planId == missionId`; результат
ложится в БД mission-planing. Уточнить: когда и по какому триггеру строятся
маршруты для нового/перевыданного плана, и как они привязаны к включениям.
## 2. Формирование сбросов
Формирование сбросов (`DropMode`) поверх съёмочных включений (`SurveyMode`):
расчёт окон сброса, привязка к ЗРВ/КПП, согласование времени сброса со временем
съёмки. Внутренние времена — naive `LocalDateTime` (см. сквозную модель без зон).
## 3. Перенос включений из плана в план
При перевыдаче/версионировании плана переносить уже сформированные включения
(съёмки + сбросы) из предыдущего плана в новый — чтобы не терять набранное
содержимое цепочки. Определить, что переносится как есть, что пересчитывается,
и как это стыкуется с двумя осями версионирования (цепочка ТГУ + контент миссии).
## Контекст / связанные места
- Маршруты и их сохранение: `createSatelliteMission`, `planId == missionId`.
- Сбросы/съёмки: `pcp-mission-planing-service``SurveyMode`/`DropMode`,
`MissionPlaningService`.
- Версионирование/перевыдача плана — две оси (цепочка ТГУ + контент миссии).
- Время — сквозной naive `LocalDateTime` без зоны (после отката UTC-слоя).
-219
View File
@@ -1,219 +0,0 @@
# Задача: встроить интерфейс «Редактор включений КА» в `pcp-tgu-ops-ui`
> Документ-ТЗ для кодинг-агента. Раздел 0 — промпт для запуска (скопировать в агента).
> Разделы 1–11 — инструкция, которую агент читает в репозитории и которой следует.
> Стиль работы, границы слоёв и формат финального ответа — по корневому `AGENTS.md`.
---
## 0. Промпт для запуска агента (copy-paste)
```
Ты работаешь в монорепозитории observatio-terrae (pcp).
Задача: встроить прототип интерфейса «Редактор включений КА» в существующее
фронтенд-приложение services/pcp-tgu-ops-ui (Vite + React 19 + TypeScript).
Перед началом обязательно прочитай:
1. /AGENTS.md — правила работы, стиль, формат финального ответа.
2. /docs/tasks/TASK_tgu-editor-integration.md — полное ТЗ этой задачи (этот файл).
3. Исходный прототип: /docs/ui-prototypes/pcp-tgu-editor/ (editor.jsx и соседние).
4. Целевую структуру: services/pcp-tgu-ops-ui/src (App.tsx, features/, api/, styles/).
Это ПОРТ, а не копирование: прототип на Babel-standalone с глобалями и мок-данными;
цель — ESM/TS-модули, реальный API-клиент, существующая тема и паттерны проекта.
Выполняй строго по этапам из раздела 5 ТЗ. Делай минимальные ревьюопригодные изменения,
не трогай чужие сервисы и backend (Kotlin). Карту, орбитальную математику и мок-данные
из прототипа НЕ переноси — переиспользуй features/tgu-map-2d и api/tguApi (раздел 3).
После каждого этапа прогоняй `npm run test` и `npm run build` в модуле.
В конце дай отчёт в формате из раздела «Обязательный формат финального ответа» AGENTS.md.
```
---
## 1. Контекст и цель
`pcp-tgu-ops-ui` — операторский UI ТГУ (Mission Ops): уже работающее SPA на **Vite 7 + React 19 + TypeScript**, feature-sliced структура (`features/tgu-planning`, `features/tgu-map-2d`), централизованный API-клиент `src/api/tguApi.ts` (все вызовы через `/api/...`, dev-proxy на backend `:7008`), токены темы в `src/styles/theme.css`, юнит-тесты на Vitest.
Прототип «Редактор» — браузерный demo (React/Babel из CDN, JSX компилируется в браузере, общие глобали `D`/`SENSOR_MODES`/`window.WorldGeo`, мок-данные из `data.jsx`). Это **другой стек**, поэтому его нельзя «подключить» файлами — нужно портировать как новую фичу приложения.
**Цель:** редактор включений КА доступен как третья вкладка приложения (`timeline | map | editor`), работает на реальных данных через `tguApi`, переиспользует существующую карту и тему, покрыт тестами и собирается без ошибок.
Функциональность редактора (из прототипа `editor.jsx`): выбор активного КА/плана, черновик включений (`works`: `shoot` — съёмка, `downlink` — сброс), drag/resize по таймлайну, добавление съёмки по точке на карте и сброса по сеансу со станцией, контекст соседних КА, undo/redo, проверка конфликтов (пересечения по времени — живые; видимость цели/станции — по кнопке), применение правок.
---
## 2. Границы задачи (scope)
**В scope:**
- Новая фича `src/features/tgu-editor/` в `pcp-tgu-ops-ui`.
- Третья вкладка `"editor"` в существующем переключателе вкладок.
- Перенос UI-компонентов редактора на TSX + типизация.
- Чистые модули логики (undo/redo, конфликты) + их тесты.
- Подключение к реальным данным через `tguApi` (планы/платформы).
- Перемаппинг токенов темы.
**Вне scope (НЕ делать без отдельного запроса):**
- Изменения в любых Kotlin-сервисах, включая `pcp-tgu-service` (backend-эндпоинт сохранения правок — отдельная задача, см. раздел 7).
- Перенос карты/орбитальной математики/мок-данных из прототипа (раздел 3).
- Добавление роутера, state-менеджеров, UI-библиотек, новых рантайм-зависимостей без необходимости.
- Рефакторинг существующих фич `tgu-planning` / `tgu-map-2d` сверх минимально необходимого для переиспользования.
- Изменения `helm/`, `settings.gradle.kts`, CI (модуль фронта в них и так не участвует).
---
## 3. Целевая архитектура и правила переиспользования
Следуй существующему feature-sliced паттерну (см. `features/tgu-planning`, `features/tgu-map-2d`): UI-компоненты в `*.tsx`, типы в `model/*.ts`, нетривиальная логика — в отдельных чистых `*.ts` модулях с тестами.
**Обязательно переиспользовать (НЕ дублировать):**
- **Карта** — `features/tgu-map-2d` (`Tgu2DMapView`, `canvas/drawTracks|drawSwath|drawStations|drawPlanWorks|drawBaseMap`, `geometry/mapProjection|spatialIndex|bbox`). Редактору нужна карта в режиме «выбор точки съёмки» и подсветки трасс — расширяй существующий компонент пропсами (`pickMode`, `onPickPoint`, `targets`), а не переноси `mapview.jsx`/`orbit.jsx`/`world.js`/`basemap.js`.
- **API** — `src/api/tguApi.ts` (`fetchPlans`, `fetchPlatforms`, паттерн `fetchJson`/`TguApiError`). Все сетевые вызовы редактора добавляй сюда же или в `features/tgu-editor/editorApi.ts` в том же стиле.
- **Тема** — `src/styles/theme.css` (см. раздел 6).
- **Модели** — `src/model/tguTypes.ts`, `src/model/timelineTypes.ts`. Переиспользуй существующие типы планов/платформ, не вводи параллельные.
**Не переносить из прототипа:** `mapview.jsx`, `orbit.jsx`, `world.js`, `basemap.js`, `util.jsx` (дублируют то, что уже есть в TS), `data.jsx` (мок), `tabs.jsx` (заменяется вкладкой), `Editor.html`/`Map.html` (точка входа уже `index.html` + `main.tsx`).
Орбитальные функции, которых нет в `tgu-map-2d` (`targetPasses`, `stationPasses`, `subPoint`, `nearestStation`), вынеси в чистый общий модуль `features/tgu-map-2d/geometry/passes.ts` (с тестами) и переиспользуй из карты и редактора. Не размещай бизнес/гео-логику внутри React-компонентов.
---
## 4. Инвентаризация и маппинг файлов
Источник: `docs/ui-prototypes/pcp-tgu-editor/` (положить туда содержимое архива до начала работ — по образцу уже существующих `docs/ui-prototypes/pcp-tgu-mission-ops/`).
| Прототип | Действие | Целевой файл |
|---|---|---|
| `editor.jsx` (`EditorApp`) | Портировать, убрать `ReactDOM.createRoot`, сделать экспортируемым компонентом-вкладкой | `features/tgu-editor/TguEditorTab.tsx` |
| `editor.jsx` (`EditorToolbar`) | Портировать | `features/tgu-editor/EditorToolbar.tsx` |
| `editorrail.jsx` | Портировать | `features/tgu-editor/EditorRail.tsx` |
| `editortimeline.jsx` | Портировать | `features/tgu-editor/EditorTimeline.tsx` |
| `editorpanels.jsx` (`WorkInspector`/`ShootPanel`/`DownlinkPanel`) | Портировать | `features/tgu-editor/EditorPanels.tsx` |
| `visibility.jsx` | Портировать визуализацию; гео-расчёты вынести в `passes.ts` | `features/tgu-editor/VisibilityTracks.tsx` |
| `editor.jsx` (undo/redo, mutate, seedDraft) | Вынести в чистый модуль | `features/tgu-editor/editorDraft.ts` (+`editorDraft.test.ts`) |
| `editor.jsx` (overlaps + runCheck) | Вынести в чистый модуль | `features/tgu-editor/editorConflicts.ts` (+`editorConflicts.test.ts`) |
| типы `work`/`draft`/`target` | Создать | `features/tgu-editor/model/editorTypes.ts` |
| `mapview.jsx`, `orbit.jsx`, `world.js`, `basemap.js`, `util.jsx`, `data.jsx`, `tabs.jsx`, `*.html` | НЕ переносить | — |
---
## 5. Пошаговый план (атомарные этапы)
Каждый этап завершай прогоном `npm run test` и `npm run build` в `services/pcp-tgu-ops-ui`.
**Этап 0 — подготовка.** Скопировать прототип в `docs/ui-prototypes/pcp-tgu-editor/`. Прочитать `App.tsx`, `TguPlanningPage.tsx`, `TguToolbar.tsx`, `tguApi.ts`, `theme.css`, `Tgu2DMapView.tsx`. Зафиксировать в отчёте точки расширения.
**Этап 1 — типы и каркас фичи.** Создать `features/tgu-editor/model/editorTypes.ts` (`Work` с `kind: "shoot" | "downlink"`, `origin`, `modeId`, `stationId`, `targetLat/Lon`, `isNew`; `Draft`, `EditorTarget`). Создать пустой `TguEditorTab.tsx` с заголовком-заглушкой. Скомпилироваться.
**Этап 2 — чистая логика + тесты.** Перенести в `editorDraft.ts`: `seedDraft`, `mutate`, `undo`, `redo`, `addShoot`, `addDownlink`, `updateWork`, `deleteWork`, `duplicateWork` (как чистые функции над `Draft`/`Work`, без React и без `Date.now()` внутри — id/время передавать параметром для детерминизма тестов). Перенести в `editorConflicts.ts`: `overlaps` и проверку видимости. Написать `editorDraft.test.ts` и `editorConflicts.test.ts` (undo/redo инварианты, обнаружение пересечений, отсутствие/наличие видимости). Это первоочередное — логика должна быть покрыта до UI.
**Этап 3 — токены темы.** Реализовать перемаппинг (раздел 6). Убедиться, что портируемые стили резолвятся в палитру приложения.
**Этап 4 — UI без карты.** Портировать `EditorToolbar`, `EditorRail`, `EditorTimeline`, `EditorPanels`, `VisibilityTracks` на TSX. Завязать их на состояние в `TguEditorTab` (`useState`/`useMemo`/`useCallback` напрямую из `react`). Drag/resize таймлайна сохранить. Вместо `alert()` использовать уведомление в стиле `decisionNotice`/`decisionError`.
**Этап 5 — карта.** Расширить `Tgu2DMapView` пропсами `pickMode`/`onPickPoint`/`targets` (минимально, без поломки текущего использования в `Tgu2DMapTab`). Подключить в редакторе для выбора точки съёмки и подсветки трасс цели/станции через `passes.ts`.
**Этап 6 — данные.** Заменить мок `D` на реальные данные: планы/платформы грузить через `tguApi` (как в `TguPlanningPage`). `seedDraft` строить из выбранного плана. Активный КА/план брать из пропсов (раздел 7), не из `location.search`.
**Этап 7 — вкладка.** В `model/timelineTypes.ts`/`TguPlanningPage.tsx` расширить `activeTab` до `"timeline" | "map" | "editor"`, добавить кнопку вкладки в `TguToolbar` (`onTabChange`), отрендерить `TguEditorTab` при `activeTab === "editor"`, передав `selectedSpacecraftId`/`selectedPlan`/`appliedRange`.
**Этап 8 — apply (заглушка).** Реализовать `applyDraft` через типизированную функцию `saveEditedPlan` в `editorApi.ts`, помеченную `// TODO(backend): эндпоинт сохранения правок плана отсутствует в pcp-tgu-service` (раздел 7). Поведение: показать понятное уведомление о том, что сохранение во внешний сервис ещё не реализовано; не падать.
**Этап 9 — финализация.** Прогнать `npm run test` и `npm run build`. Отчёт по формату AGENTS.md.
---
## 6. Перемаппинг токенов темы
Прототип использует oklch-токены (`--bg-0..3`, `--ink..--ink-3`, `--t-optical/radar/combo`, `--now`, `--warn`, `--line`, `--shadow`), которых нет в `theme.css` приложения (там hex: `--bg-base`, `--bg-panel`, `--text`, `--accent`, `--danger`, `--warning`, ...).
Минимально-инвазивный подход: создать `src/styles/editor-tokens.css` (импортировать в `TguEditorTab` или в `main.tsx`) с алиасами, чтобы портированный код, ссылающийся на старые имена, резолвился в палитру приложения — без переписывания каждого inline-стиля:
```css
:root {
--bg-0: var(--bg-base);
--bg-1: var(--bg-panel);
--bg-2: var(--bg-panel-2);
--bg-3: var(--bg-elevated);
--ink: var(--text);
--ink-1: var(--text);
--ink-2: var(--text-muted);
--ink-3: var(--text-dim);
--now: var(--warning);
--warn: var(--warning);
/* --line, --line-soft, --accent, --shadow совпадают по имени — проверить значения */
/* --t-optical/radar/combo: задать на основе --accent/--warning/--danger или вынести в палитру типов КА */
}
```
Уточнить фактические значения `--line/--line-soft/--accent/--shadow` (имена совпадают, значения могут отличаться) и зафиксировать решение в отчёте. Альтернатива (если команда предпочитает) — переписать стили редактора сразу на родные токены приложения; выбрать один путь и не смешивать.
---
## 7. Данные и API
- **Чтение:** планы и платформы — через существующие `fetchPlans`/`fetchPlatforms` (`tguApi.ts`). Активный КА/план редактор получает пропсами от `TguPlanningPage` (`selectedSpacecraftId`, `selectedPlan`), а не из URL.
- **Гео/орбиты:** через `tgu-map-2d` + `passes.ts`; мок `world.js`/`data.jsx` не использовать.
- **Сохранение правок (apply):** в текущем API `pcp-tgu-service` **нет** эндпоинта сохранения отредактированного набора включений (есть только выдача плана и `decision`/`issue`). Поэтому в этой задаче `applyDraft` реализуется как заглушка с явным `TODO(backend)` и не делает реальной записи.
**Предложение контракта для backend-команды** (вне scope, зафиксировать в отчёте, не реализовывать):
- `POST /api/tgu-planning/plans/{planId}/edits` с телом `{ works: EditedWork[], baseRevision?: string }`, идемпотентно по `operationId`, возвращает новый план/attempt. Семантику (новый attempt vs патч существующего) согласовать отдельно.
---
## 8. Конвенции
Следуй `AGENTS.md` (раздел про стиль и scope) применительно к TS/React:
- Минимальные, локальные, ревьюопригодные изменения; не переименовывать и не форматировать несвязанные файлы.
- Предпочитать `const`, маленькие функции, явные имена; не вводить «умные» абстракции на будущее.
- Бизнес/гео-логику не держать в React-компонентах — выносить в чистые `*.ts` модули.
- Комментарии — там, где помогают сопровождению (undo/redo инварианты, правила конфликтов, edge cases), а не на каждую строку. Язык комментариев — как в проекте.
- Не глотать ошибки сети молча — использовать паттерн `TguApiError`/уведомлений, как в существующем коде.
- Не добавлять новые рантайм-зависимости без явной необходимости; React/ReactDOM/Babel из CDN прототипа не тащить — они уже есть в проекте как npm-пакеты.
- TypeScript: без `any` в публичных сигнатурах; типы — в `model/`.
---
## 9. Критерии приёмки и команды валидации
Рабочая директория: `services/pcp-tgu-ops-ui`.
```
npm install # при необходимости
npm run test # vitest run — все тесты зелёные, включая новые editorDraft/editorConflicts/passes
npm run build # tsc -b && vite build — без ошибок типов и сборки
npm run dev # ручная проверка: вкладка «Редактор» открывается, см. ниже
```
Функциональные критерии:
1. В тулбаре доступна третья вкладка; переключение `timeline | map | editor` работает, существующие вкладки не сломаны.
2. Редактор открывается на реальных данных (планы/платформы из `tguApi`), без мок-`D`.
3. Работают: drag/resize включений, добавление съёмки по точке на карте, добавление сброса по станции, undo/redo, проверка конфликтов (живые пересечения + видимость по кнопке).
4. Карта в редакторе — это `tgu-map-2d` (нет второго движка карты в бандле).
5. Тема консистентна с приложением (нет «бесцветных» из-за отсутствующих токенов).
6. `applyDraft` не падает и явно сообщает про отсутствующий backend-эндпоинт.
Если какая-то команда не запускалась — указать какая и почему, и описать остаточный риск (по AGENTS.md).
---
## 10. Риски, допущения, чего не делать
- **Допущение:** структура планов/включений из `tguApi` достаточна, чтобы построить `seedDraft`. Если в реальном ответе нет полей под `works` редактора — зафиксировать gap, сделать адаптер-маппер и описать недостающие поля в отчёте, **не** менять backend.
- **Риск:** React 19 + StrictMode даёт двойной вызов эффектов — проверить `runCheck`/`setTimeout` на отсутствие двойных срабатываний.
- **Риск:** расширение `Tgu2DMapView` может задеть `Tgu2DMapTab` — новые пропсы делать опциональными с дефолтами, существующее поведение не менять.
- **Не делать:** правки в Kotlin-сервисах, в чужих фичах сверх необходимого, новый роутинг, новые тяжёлые зависимости, перенос мок-данных и второго движка карты.
---
## 11. Обязательный формат финального ответа
По `AGENTS.md` агент завершает задачу блоками:
1. **Changed files** — список изменённых/созданных файлов.
2. **What changed** — краткое summary реализации по этапам.
3. **Architecture compliance** — почему соблюдены границы слоёв (логика вне компонентов, переиспользование `tgu-map-2d`/`tguApi`/темы, отсутствие дублей карты и мок-данных).
4. **Validation** — какие команды (`npm run test`, `npm run build`, ручная проверка) запускались и результаты.
5. **Risks / assumptions** — что не проверено, gap по данным/backend, edge cases.
@@ -0,0 +1,179 @@
# INTEGRATION — встраивание вкладки «Состояние группировки»
Цель: добавить дашборд новой вкладкой в существующий шелл (рядом с
`Планы / Карта / Комплексный план / Редактор / Заявки`), переключив встроенную
симуляцию на реальные данные.
---
## 1. Зависимости
```bash
npm i d3-geo topojson-client
# react / react-dom — уже есть в проекте
```
Версии, на которых собран прототип: `d3-geo@3`, `topojson-client@3`, `react@18`.
---
## 2. Положить модуль
Скопируйте папку `src/` в проект, например в `src/features/constellation/`.
Все импорты внутри — относительные, так что путь произвольный.
`dashboard.css` импортируется автоматически из `ConstellationDashboard.jsx`
(`import "./dashboard.css"`). Если используете CSS-модули/Tailwind — см. п.6.
---
## 3. Карта мира (TopoJSON)
`MapView.jsx` сейчас тянет карту с CDN:
```js
fetch("https://unpkg.com/world-atlas@2.0.2/land-110m.json")
```
Для прода **положите файл локально** и раздавайте со своего origin:
```js
// import landUrl from "../assets/land-110m.json?url"; // Vite
fetch(landUrl).then(r => r.json()).then(/* ... */)
```
Либо, если у вас уже есть карта (Leaflet/MapLibre/OpenLayers), рассмотрите
вариант рендерить слои группировки поверх неё — см. п.7.
---
## 4. Монтирование как вкладки
`src/index.js` отдаёт `default` = компонент `ConstellationDashboard` (без пропсов —
сейчас он сам строит данные из `data.js`/`sim.js`).
Пример с массивом табов (адаптируйте под ваш роутер):
```jsx
import ConstellationDashboard from "@/features/constellation";
const TABS = [
{ id: "plans", label: "Планы", element: <PlansView /> },
{ id: "map", label: "Карта", element: <MapTab /> },
// ...
{ id: "constel", label: "Группировка", element: <ConstellationDashboard /> }, // ← новая
];
```
или с react-router:
```jsx
<Route path="/constellation" element={<ConstellationDashboard />} />
```
### Важно про высоту
Корневой `.app` использует `height:100vh`, а `reference/styles.css` ставит
`body{overflow:hidden}`. Внутри вкладки с собственной шапкой это надо снять:
- замените в `dashboard.css` `.app{height:100vh}``.app{height:100%}` и дайте
контейнеру вкладки реальную высоту (`height:100%` по цепочке родителей), **или**
- оставьте `100vh`, если вкладка занимает весь вьюпорт.
- правила для `body{…}` из `dashboard.css` лучше удалить/перенести, чтобы не влиять
на остальное приложение (или сразу оскопить — см. п.6).
---
## 5. Контракты данных (главное)
Сейчас данные синтетические. Замена — в двух файлах: `data.js` (что у нас есть)
и `sim.js` (где это находится во времени/пространстве).
### 5.1 `data.js` — статический состав группировки
```ts
type Payload = "optic" | "radar";
interface Satellite {
id: string; // уникальный ключ
name: string; // "Ресурс-П №3"
norad: number;
payload: Payload; // влияет на цвет и какие ROI снимает
domain: "ОПТИКА" | "ЛОКАЦИЯ"; // верхняя группировка в списке/таймлайне
group: string; // подгруппа ("Ресурс-П", "Кондор-ФКА", …)
color: string; // = COL[payload], проставляется автоматически
mem0: number; // стартовая загрузка памяти, %
orbit: { latAmp:number; period:number; phase:number; lon0:number }; // только для встроенной симуляции
}
interface Station { id:string; name:string; lon:number; lat:number; }
interface ROI { // заявка на съёмку (полигон)
id:string; name:string; payload:Payload;
poly:[number,number][]; // [[lon,lat], …] (без замыкающей точки)
}
```
Экспорты: `SATELLITES`, `STATIONS`, `ROIS`, `COL`.
Чтобы расширить до 20–30 КА и новых групп — просто добавляйте элементы; список,
сводка и таймлайн подхватывают автоматически (группировка идёт по `domain``group`).
Поле `orbit` нужно только встроенной симуляции (п.5.3).
### 5.2 `sim.js` — геометрия и расписание
Чистые функции, легко заменить на реальные источники:
| Функция | Что возвращает | Чем заменить в проде |
|---|---|---|
| `subSat(sat, tMs, epochMs)` | `{lon,lat}` подспутниковой точки | реальная эфемерида/SGP4 по TLE на время `t` |
| `subSolar(tMs)` | `{lon,lat}` подсолнечной точки | можно оставить (астрономия точная) |
| `inShadow(lon,lat,tMs)` | в тени ли точка | по вашей модели освещённости |
| `statusAt(sat,tMs,env)` | `{state, p, roi?, station?, prohibit?, eclipse}` | **главный хук**: верните статус из вашего плана |
| `buildSchedule(...)` | интервалы `capture/downlink/prohibit/shadow` по КА на домене | отдайте готовые интервалы из API планирования |
| `buildProhibits(...)` | окна запрета по КА | из вашего сервиса ограничений (ВНЗ, питание и т.п.) |
`state ∈ "operational" | "capture" | "downlink" | "prohibit"` (плюс флаг `eclipse`).
Приоритет в `statusAt`: запрет → съёмка → сброс → operational.
### 5.3 Минимальная интеграция с API (рекомендуемый путь)
1. **Состав** (`SATELLITES/STATIONS/ROIS`) — из ваших справочников/`pcp-request-service`.
2. **Положение** — замените тело `subSat()` на расчёт по TLE (напр. `satellite.js`/SGP4),
`epochMs`/`period`/`latAmp`/`lon0` тогда не нужны.
3. **Расписание** — вместо `buildSchedule()` отдайте интервалы прямо из плана:
объект `{ [satId]: { capture:[], downlink:[], prohibit:[], shadow:[] } }`,
где интервал = `{ start:ms, end:ms, label?:string }`. Таймлайн и лента это рисуют как есть.
4. **Запреты**`prohibit`-интервалы из сервиса ограничений (поле `reason` → подпись).
5. **Память борта** — если телеметрия известна, замените модельную динамику в RAF-цикле
`ConstellationDashboard.jsx` (блок `memory dynamics`) на реальные значения по `satId`.
Домен времени задаётся в `buildEnv()` (`now-6h … now+18h`) — подгоните под горизонт плана.
---
## 6. Изоляция стилей (если нужно)
`dashboard.css` — глобальные классы (`.app`, `.hdr`, `.side`, …) и `:root`-переменные.
Если боитесь конфликтов:
- оберните корень во `<div className="cdash">…</div>` и добавьте префикс к селекторам
(или прогоните через CSS-модули / `@scope`), **или**
- оставьте как есть, если имена классов не пересекаются с вашими.
Переменные в `:root` безобидны (с префиксом `--bg0` и т.п.), но при желании
перенесите их на `.cdash`.
---
## 7. (Опционально) рендер на вашей карте
Если в приложении уже есть карта (на скриншотах — тёмная equirectangular):
- слои **заявок (ROI)**, **станций**, **треков**, **полос обзора**, **линий сброса**
можно отрисовать вашими средствами (GeoJSON-слой / canvas-overlay), переиспользуя
геометрию из `sim.js` (`subSat`, footprint = окружность радиусом ~7–9° вокруг точки).
- тогда `MapView.jsx` нужен лишь как референс по слоям/цветам/порядку отрисовки.
- остальные части (`Header`, `SidePanel`, `Timeline`, RAF-часы/память) переиспользуются как есть.
---
## 8. Проверка после встраивания
- [ ] Вкладка открывается, карта рисует сушу/сетку, КА движутся при Play.
- [ ] Скорости ×1…×600 меняют ход времени; «Сейчас» включает LIVE.
- [ ] Скраб по таймлайну двигает плейхед и пересчитывает статусы/карту.
- [ ] Клик по КА (список/карта/таймлайн) ставит фокус и затемняет остальных.
- [ ] Полосы памяти меняются на съёмке/сбросе.
- [ ] Высота/скролл вкладки корректны (см. п.4 «про высоту»).
@@ -0,0 +1,156 @@
# Handoff: Дашборд состояния группировки (Mission Ops «Состояние группировки»)
## Overview
Полноэкранная информационная панель (operations wall) для мониторинга орбитальной
группировки КА в реальном времени. Показывает на карте мира движущиеся аппараты,
их маршруты съёмки, сбросы данных на наземные станции, интервалы запрета работы и
тень Земли. Снизу — таймлайн событий по каждому КА. Воспроизведение управляется
кнопкой Play со скоростями ×1/×10/×60/×600, есть скраб по времени и кнопка «Сейчас».
Назначение: дежурная смена видит «что сейчас делает группировка» одним взглядом —
кто снимает, кто сбрасывает, кто в запрете/в тени, загрузку памяти бортов и
ближайшие события.
## About the Design Files
Файлы в этом пакете — **дизайн-референс, реализованный на HTML/React-прототипе**.
Это эталон внешнего вида и поведения, **не обязательно финальный продакшн-код**.
Задача — **воссоздать этот интерфейс в вашем кодовом окружении** (судя по скриншотам —
React + Vite, карта в стиле тёмной equirectangular-проекции), используя ваши паттерны,
ваш роутинг табов и, по возможности, **вашу существующую карту и ваши данные из API**
вместо встроенной симуляции.
Два слоя в пакете:
- `reference/` — рабочий автономный прототип (открывается в браузере, грузит React/d3 с CDN).
Нужен только чтобы «пощупать» поведение и свериться по пикселям.
- `src/` — те же экраны, переписанные в **чистые ESM-модули** (`import/export`, без CDN
и без `window.*`). Это то, что встраивается в приложение. См. `INTEGRATION.md`.
## Fidelity
**High-fidelity.** Цвета, типографика, отступы, состояния и анимации — финальные.
Воссоздавайте 1:1, подменяя только источник данных (см. ниже) и, при желании,
рендер карты на вашу картографическую библиотеку.
## Screens / Views
Один экран, 4 функциональные зоны в CSS-grid/flex.
### 1. Topbar (шапка) — `Header`
- **Layout:** горизонтальный флекс, высота `54px`, нижняя граница `1px var(--line)`,
фон `linear-gradient(var(--bg1),var(--bg0))`. Три блока: бренд (слева, `min-width:248px`),
транспорт (центр, `flex:1`, по центру), часы (справа, `min-width:248px`, выровнено вправо).
- **Бренд:** кружок-маркер `20px` с бирюзовым свечением + «СОСТОЯНИЕ ГРУППИРОВКИ»
(600, 13px, `nowrap`) и «MISSION OPS · ТГУ» (mono, 9px, `--dim`, letter-spacing .12em).
- **Транспорт:** кнопки `1ч`, Play/Pause (бирюзовая, 42px), `+1ч`; разделитель;
сегмент скоростей `×1 ×10 ×60 ×600` (активная — бирюзовый фон); кнопка `СЕЙЧАС`
(в live-режиме — зелёная).
- **Часы:** бейдж `LIVE`/`СИМ` (LIVE — зелёная пульсирующая точка), крупное mono-время
`HH:MM:SS` (22px) + `UTC`, ниже `ДЕНЬ · ДД.ММ.ГГГГ` (mono, 10px).
### 2. Left panel (левая панель) — `SidePanel`
Ширина `328px`, фон `--bg1`, правая граница, вертикальный флекс, скролл внутри.
- **СВОДКА ГРУППИРОВКИ:** 4 плитки (grid 4×1): СНИМАЮТ / СБРОС / ЗАПРЕТ / В ТЕНИ —
число (mono, 21px, цвет статуса) + подпись (8px, `--dim`). Под ними строка
«● ОПТИКА n ● ЛОКАЦИЯ n · N КА».
- **Поиск:** input (mono, 11.5px) — фильтрует список по name / NORAD / группе.
- **КОСМИЧЕСКИЕ АППАРАТЫ:** список, сгруппированный `ОПТИКА`→группы, `ЛОКАЦИЯ`→группы.
Каждая строка КА: цветная риска слева (cyan=оптика / violet=локация), имя (500, 12.5px),
статус справа (mono, 9px, цвет статуса, иконка ☾ если в тени), снизу `NORAD XXXXX`,
полоса памяти (4px) и `NN%`. Цвет памяти: бирюза → амбер >78% → красный >92%.
Клик по строке = фокус КА (подсветка только его событий).
- **АКТИВНЫЕ СОБЫТИЯ:** лента текущих событий (СЪЁМКА/СБРОС/ЗАПРЕТ) с целью/станцией
и обратным отсчётом «ещё M:SS». Клик = фокус соответствующего КА.
### 3. Map (карта) — `MapView`
Занимает всё центральное пространство (`flex:1`). Canvas 2D, equirectangular-проекция
(d3-geo), повёрнута на `rotate([-10,0])`. Слои:
- База (кэш offscreen): океан `#0a0e0f`, сетка градусов (шаг 30°), суша `#171c1e`
(контур `#252d30`), прямоугольники заявок (ROI) пунктиром амбер, подписи станций (mono 9px).
- Тень Земли: ночное полушарие (d3.geoCircle от антисолнечной точки, r=90°) заливкой
`rgba(20,28,46,0.55)` с кромкой-терминатором.
- По каждому КА: синусоидальный трек (±52 мин, цвет по типу полезной нагрузки),
«полоса обзора» (footprint, geoCircle), глиф-ромб с гало и кольцом-статусом, подпись.
- Съёмка: амбер-утолщение трека + подсветка полигона заявки.
- Сброс: анимированная пунктирная синяя линия КА→станция + свечение станции.
- Станции: белые треугольники.
- Оверлеи (абсолютно): подсказка/карточка фокуса (сверху-слева), легенда (снизу-слева),
координаты курсора (снизу-справа, mono), тултип имени КА при наведении.
### 4. Timeline (таймлайн) — `Timeline`
Высота `248px`, фон `--bg1`, верхняя граница. Шапка: «ТАЙМЛАЙН СОБЫТИЙ» + легенда
(СЪЁМКА/СБРОС/ЗАПРЕТ РАБОТЫ(штриховка)/ТЕНЬ ЗЕМЛИ) + статус режима. Тело — SVG:
- Левая колонка (`188px`): группы и имена КА с цветной точкой.
- Ось времени сверху (часовые тики, на полночь — дата).
- Дорожка каждого КА: полосы событий — тень (низ, слейт), сброс (синий), съёмка (амбер),
запрет (красная штриховка, во всю высоту).
- Вертикальный плейхед (бирюза в live, амбер в симуляции) + пунктирная линия «СЕЙЧАС»
(реальное время). Зажатие+перетаскивание = скраб времени. Клик по строке = фокус КА.
## Interactions & Behavior
- **Play/Pause:** запуск/останов модельного времени.
- **Скорость ×1/×10/×60/×600:** множитель к ходу модельного времени (×1 = реальное).
- **СЕЙЧАС:** включает live-режим (время = системные часы, ×1, бейдж LIVE, зелёная пульсация).
- **−1ч / +1ч:** сдвиг модельного времени на час (выходит из live).
- **Скраб по таймлайну:** установка времени перетаскиванием (выходит из live).
- **Фокус КА:** клик в списке / на карте / в таймлайне / в ленте — подсвечивает один КА,
затемняет остальные; повторный клик снимает фокус.
- **Наведение на карту:** тултип имени ближайшего КА + координаты курсора (lat/lon).
- **Анимации:** бегущие пунктиры линий сброса, пульсация live-точки (1.4s), плавные
переходы полос памяти. Без бесконечных декоративных лупов на контенте.
## State Management
Всё состояние — в корневом компоненте (`ConstellationDashboard.jsx`):
- `simRef` (модельное время, ms) — в `ref`, чтобы canvas рисовался на 60fps без ре-рендеров.
- `playingRef`, `speedRef`, `liveRef` — режим воспроизведения.
- `focusId` (state + ref) — выбранный КА.
- `memRef` — карта «загрузка памяти %» по КА (растёт при съёмке, падает при сбросе).
- `query` — строка поиска. `cursor`, `hover` — UI карты.
- RAF-цикл двигает время и память, рисует карту императивно (`mapApi.draw`), а панели/таймлайн
ре-рендерит троттлингом (~12 fps) через `setTick`. Производные (статусы, счётчики,
активные события) вычисляются в `render` из `simRef` + `memRef`.
> Контракты данных и точки замены симуляции на реальное API — в `INTEGRATION.md`.
## Design Tokens
Из `src/dashboard.css` (`:root`):
- **Фоны:** `--bg0 #0a0c0b` (канвас), `--bg1 #0e1211` (панели), `--panel #111514`,
`--ins #0a0e0d` (вставки/карта).
- **Линии:** `--line rgba(255,255,255,.06)`, `--line2 rgba(255,255,255,.11)`.
- **Текст:** `--hi #e7ece9`, `--mid #95a19d`, `--dim #5b6662`, `--faint #3c4644`.
- **Акценты:** `--teal #5fb3a3` (управление/активное), `--amber #d99a4e` (съёмка/заявки),
`--blue #5b9bd5` (сброс), `--red #d9685f` (запрет), `--green #5cae7e` (operational),
`--violet #a78bdb` (локация/РСА).
- **Тип нагрузки:** ОПТИКА `#56c2c8` (cyan), ЛОКАЦИЯ `#a78bdb` (violet) — `COL` в `data.js`.
- **Карта:** океан `#0a0e0f`, суша `#171c1e`/контур `#252d30`, ночь `rgba(20,28,46,.55)`.
- **Типографика:** IBM Plex Sans (UI), IBM Plex Mono (числа/координаты/ID/метки).
Заголовки секций — капс, letter-spacing .14.16em.
- **Скругления:** 5–8px. **Высоты:** topbar 54, timeline 248, строка таймлайна 22.
## Assets
- Карта мира: TopoJSON `world-atlas@2``land-110m.json` (грузится в `MapView`).
В проде положите файл локально или используйте свою карту (см. `INTEGRATION.md`).
- Иконок-картинок нет: станции/КА рисуются на canvas, бренд-маркер — CSS.
- Шрифты: IBM Plex Sans/Mono (Google Fonts; `@import` в `dashboard.css` можно убрать,
если шрифты уже есть в приложении).
## Files
```
design_handoff_constellation_dashboard/
├── README.md ← этот файл (дизайн-референс)
├── INTEGRATION.md ← как встроить вкладку + контракты данных
├── reference/ ← автономный прототип «как должно выглядеть»
│ ├── preview.html ← открыть в браузере
│ ├── styles.css
│ └── js/ (data, sim, map, timeline, panels, app — версия с CDN/window.*)
└── src/ ← ESM-модули для встраивания
├── index.js ← точка входа (default = ConstellationDashboard)
├── ConstellationDashboard.jsx← корневой компонент вкладки
├── MapView.jsx ← canvas-карта
├── Timeline.jsx ← таймлайн событий
├── Panels.jsx ← Header + SidePanel
├── sim.js ← геометрия/расписание (заменяемо на API)
├── data.js ← модель группировки (заменяемо на API)
├── util.js ← хелперы
└── dashboard.css ← все стили
```
См. **`INTEGRATION.md`** — пошагово: зависимости, монтирование как вкладки,
контракты данных и где подключить реальные эфемериды/расписание.
@@ -0,0 +1,203 @@
/* ============================================================
app.jsx — wiring, RAF clock, memory sim, derived state
============================================================ */
const { useState, useEffect, useRef, useMemo } = React;
function buildEnv() {
const epochMs = Date.now();
const { sats, stations, rois } = window.OPS;
const prohibits = SIM.buildProhibits(sats, epochMs);
const env = { epochMs, sats, stations, rois, prohibits };
const domain = { start: epochMs - 6 * 3600e3, end: epochMs + 18 * 3600e3 };
const sched = SIM.buildSchedule(sats, env, domain.start, domain.end, 1);
return { env, domain, sched };
}
function findInterval(list, t) {
if (!list) return null;
for (const iv of list) if (t >= iv.start && t < iv.end) return iv;
return null;
}
function App() {
const cfg = useMemo(buildEnv, []);
const { env, domain, sched } = cfg;
const apiRef = useRef({});
const simRef = useRef(env.epochMs);
const playingRef = useRef(true);
const speedRef = useRef(10);
const liveRef = useRef(false);
const focusRef = useRef(null);
const hoverRef = useRef(null);
const memRef = useRef(Object.fromEntries(env.sats.map((s) => [s.id, s.mem0])));
const lastT = useRef(performance.now());
const [, setTick] = useState(0);
const [playing, setPlaying] = useState(true);
const [speed, setSpeed] = useState(10);
const [live, setLive] = useState(false);
const [focusId, setFocusId] = useState(null);
const [query, setQuery] = useState("");
const [cursor, setCursor] = useState(null);
const [hover, setHover] = useState(null);
function setFocus(id) { focusRef.current = id; setFocusId(id); }
// map api callbacks
useEffect(() => {
const a = apiRef.current;
a.simTime = () => simRef.current;
a.onCursor = (ll) => setCursor(ll);
a.onPick = (id, x, y) => { hoverRef.current = id; setHover(id ? { id, x, y } : null); };
a.onClickPick = () => { if (hoverRef.current) setFocus(focusRef.current === hoverRef.current ? null : hoverRef.current); };
a.onReady = () => a.draw && a.draw(simRef.current, { focusId: focusRef.current });
}, []);
// RAF loop
useEffect(() => {
let raf;
let acc = 0;
const loop = (now) => {
const dt = Math.min(100, now - lastT.current);
lastT.current = now;
if (playingRef.current) {
if (liveRef.current) simRef.current = Date.now();
else simRef.current += dt * speedRef.current;
if (simRef.current > domain.end) simRef.current = domain.end;
if (simRef.current < domain.start) simRef.current = domain.start;
// memory dynamics
const secEq = (dt * speedRef.current) / 1000;
for (const s of env.sats) {
const stt = SIM.statusAt(s, simRef.current, env);
let m = memRef.current[s.id];
if (stt.state === "capture") m += 0.7 * secEq;
else if (stt.state === "downlink") m -= 1.5 * secEq;
else m += 0.02 * secEq; // idle housekeeping drift
memRef.current[s.id] = Math.max(0, Math.min(100, m));
}
}
if (apiRef.current.draw) apiRef.current.draw(simRef.current, { focusId: focusRef.current });
acc += dt;
if (acc >= 80) { acc = 0; setTick((t) => (t + 1) % 1e6); }
raf = requestAnimationFrame(loop);
};
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
}, []);
// ---- controls ----
function onPlay() { const p = !playingRef.current; playingRef.current = p; setPlaying(p); if (!p) { liveRef.current = false; setLive(false); } }
function onSpeed(s) { speedRef.current = s; setSpeed(s); if (s !== 1) { liveRef.current = false; setLive(false); } }
function onNow() { liveRef.current = true; playingRef.current = true; speedRef.current = 1; simRef.current = Date.now(); setLive(true); setPlaying(true); setSpeed(1); }
function onStep(d) { liveRef.current = false; setLive(false); simRef.current = Math.max(domain.start, Math.min(domain.end, simRef.current + d)); }
function onScrub(t) { liveRef.current = false; setLive(false); simRef.current = Math.max(domain.start, Math.min(domain.end, t)); }
// ---- derived (per render) ----
const tNow = simRef.current;
const statuses = {};
const counts = { capture: 0, downlink: 0, prohibit: 0, eclipse: 0, optic: 0, radar: 0, total: env.sats.length };
for (const s of env.sats) {
const st = SIM.statusAt(s, tNow, env);
statuses[s.id] = st;
if (counts[st.state] !== undefined) counts[st.state]++;
if (st.eclipse) counts.eclipse++;
if (s.payload === "optic") counts.optic++; else counts.radar++;
}
const events = [];
for (const s of env.sats) {
const st = statuses[s.id];
let iv = null, type = null, target = null, color = null;
if (st.state === "capture") { iv = findInterval(sched[s.id].capture, tNow); type = "СЪЁМКА"; target = st.roi.name; color = STATUS_META.capture.c; }
else if (st.state === "downlink") { iv = findInterval(sched[s.id].downlink, tNow); type = "СБРОС"; target = "ст. " + st.station.name; color = STATUS_META.downlink.c; }
else if (st.state === "prohibit") { iv = findInterval(sched[s.id].prohibit, tNow); type = "ЗАПРЕТ"; target = st.prohibit.reason; color = STATUS_META.prohibit.c; }
if (type) events.push({ id: s.id, satId: s.id, sat: s.name, type, target, color, remain: window.fmtDur((iv ? iv.end : tNow) - tNow) });
}
events.sort((a, b) => a.remain.localeCompare(b.remain));
const focusSat = focusId ? env.sats.find((s) => s.id === focusId) : null;
return (
<div className="app">
<Header simTime={tNow} playing={playing} speed={speed} live={live}
onPlay={onPlay} onSpeed={onSpeed} onNow={onNow} onStep={onStep} />
<div className="body">
<SidePanel sats={env.sats} statuses={statuses} memory={memRef.current} counts={counts}
events={events} focusId={focusId} onFocus={setFocus} query={query} setQuery={setQuery} />
<div className="main">
<div className="map-area">
<MapView env={env} apiRef={apiRef} />
<div className="map-caption">
{focusSat ? (
<FocusCard sat={focusSat} status={statuses[focusId]} mem={memRef.current[focusId]} onClose={() => setFocus(null)} />
) : (
<div className="cap-hint">КЛИК ПО АППАРАТУ ФОКУС И ПОДСВЕТКА СОБЫТИЙ</div>
)}
</div>
<MapLegend />
<div className="map-cursor">
{cursor ? `${Math.abs(cursor.lat).toFixed(3)}° ${cursor.lat >= 0 ? "с.ш." : "ю.ш."}, ${Math.abs(cursor.lon).toFixed(3)}° ${cursor.lon >= 0 ? "в.д." : "з.д."}` : "—"}
</div>
{hover && hover.id !== focusId && (
<div className="map-tip" style={{ left: hover.x + 14, top: hover.y - 8 }}>
{env.sats.find((s) => s.id === hover.id)?.name}
</div>
)}
</div>
<div className="tl-area">
<div className="tl-head">
<span className="tl-title">ТАЙМЛАЙН СОБЫТИЙ</span>
<div className="tl-legend">
<LegItem c="#d99a4e" t="СЪЁМКА" />
<LegItem c="#5b9bd5" t="СБРОС" />
<LegItem c="#d9685f" t="ЗАПРЕТ РАБОТЫ" hatch />
<LegItem c="rgba(120,140,180,0.5)" t="ТЕНЬ ЗЕМЛИ" />
</div>
<span className="tl-hint">{live ? "РЕЖИМ LIVE · ×1" : "СИМУЛЯЦИЯ ×" + speed} · перетащите для скраба</span>
</div>
<Timeline env={env} sched={sched} simTime={tNow} domain={domain} focusId={focusId}
onScrub={onScrub} onFocus={setFocus} live={live} />
</div>
</div>
</div>
</div>
);
}
function FocusCard({ sat, status, mem, onClose }) {
const meta = STATUS_META[status.state];
return (
<div className="focus-card">
<span className="fc-pay" style={{ background: sat.color }} />
<div className="fc-info">
<div className="fc-name">{sat.name}<button className="fc-x" onClick={onClose}></button></div>
<div className="fc-meta">
<span>NORAD {sat.norad}</span><span className="fc-dot">·</span><span>{sat.group}</span>
<span className="fc-dot">·</span><span style={{ color: meta.c }}>{status.eclipse ? "☾ " : ""}{meta.t}</span>
</div>
</div>
<div className="fc-pos">
<div>{Math.abs(status.p.lat).toFixed(2)}° {status.p.lat >= 0 ? "N" : "S"} · {Math.abs(status.p.lon).toFixed(2)}° {status.p.lon >= 0 ? "E" : "W"}</div>
<div className="fc-mem">ПАМЯТЬ <b>{Math.round(mem)}%</b></div>
</div>
</div>
);
}
function MapLegend() {
return (
<div className="map-legend">
<div className="ml-row"><span className="ml-tri" />СТАНЦИЯ ПРИЁМА</div>
<div className="ml-row"><span className="ml-sw" style={{ background: window.OPS.COL.optic }} />ОПТИКА</div>
<div className="ml-row"><span className="ml-sw" style={{ background: window.OPS.COL.radar }} />ЛОКАЦИЯ</div>
<div className="ml-row"><span className="ml-sw amber" />МАРШРУТ СЪЁМКИ</div>
<div className="ml-row"><span className="ml-sw blue dash" />СБРОС НА СТАНЦИЮ</div>
<div className="ml-row"><span className="ml-sw night" />ТЕНЬ ЗЕМЛИ</div>
</div>
);
}
function LegItem({ c, t, hatch }) {
return <span className="leg-item"><i className={"leg-sw" + (hatch ? " hatch" : "")} style={{ background: hatch ? undefined : c }} />{t}</span>;
}
ReactDOM.createRoot(document.getElementById("root")).render(<App />);
@@ -0,0 +1,73 @@
/* ============================================================
data.js — constellation model (plain JS, attaches window.OPS)
Группировка: ОПТИКА + ЛОКАЦИЯ, с группами КА.
============================================================ */
(function () {
const COL = {
optic: "#56c2c8", // оптика — cyan
radar: "#a78bdb", // локация (РСА) — violet
};
// --- Satellites -------------------------------------------------
// orbit: latAmp(deg amplitude of sub-sat latitude), period(min),
// phase(0..1 of period), lon0(deg start longitude)
// mem0: initial onboard memory fill %
const sats = [
// ============ ОПТИКА ============
{ id: "res3", name: "Ресурс-П №3", norad: 41386, payload: "optic", domain: "ОПТИКА", group: "Ресурс-П",
orbit: { latAmp: 81, period: 94.2, phase: 0.02, lon0: 12 }, mem0: 38 },
{ id: "res4", name: "Ресурс-П №4", norad: 41387, payload: "optic", domain: "ОПТИКА", group: "Ресурс-П",
orbit: { latAmp: 81, period: 94.6, phase: 0.34, lon0: 155 }, mem0: 64 },
{ id: "res5", name: "Ресурс-П №5", norad: 41388, payload: "optic", domain: "ОПТИКА", group: "Ресурс-П",
orbit: { latAmp: 81, period: 94.4, phase: 0.67, lon0: -98 }, mem0: 21 },
{ id: "kan6", name: "Канопус-В №6", norad: 43657, payload: "optic", domain: "ОПТИКА", group: "Канопус-В",
orbit: { latAmp: 78, period: 96.1, phase: 0.12, lon0: 64 }, mem0: 52 },
{ id: "kan7", name: "Канопус-В №7", norad: 43658, payload: "optic", domain: "ОПТИКА", group: "Канопус-В",
orbit: { latAmp: 78, period: 96.4, phase: 0.55, lon0: -150 }, mem0: 79 },
// ============ ЛОКАЦИЯ (РСА) ============
{ id: "kon1", name: "Кондор-ФКА №1", norad: 57350, payload: "radar", domain: "ЛОКАЦИЯ", group: "Кондор-ФКА",
orbit: { latAmp: 74, period: 92.7, phase: 0.20, lon0: 33 }, mem0: 45 },
{ id: "kon2", name: "Кондор-ФКА №2", norad: 62138, payload: "radar", domain: "ЛОКАЦИЯ", group: "Кондор-ФКА",
orbit: { latAmp: 74, period: 92.9, phase: 0.62, lon0: 178 }, mem0: 17 },
{ id: "kon3", name: "Кондор-ФКА №3", norad: 62139, payload: "radar", domain: "ЛОКАЦИЯ", group: "Кондор-ФКА",
orbit: { latAmp: 74, period: 92.5, phase: 0.88, lon0: -70 }, mem0: 70 },
{ id: "obz1", name: "Обзор-Р №1", norad: 60001, payload: "radar", domain: "ЛОКАЦИЯ", group: "Обзор-Р",
orbit: { latAmp: 67, period: 97.8, phase: 0.30, lon0: 100 }, mem0: 33 },
{ id: "obz2", name: "Обзор-Р №2", norad: 60002, payload: "radar", domain: "ЛОКАЦИЯ", group: "Обзор-Р",
orbit: { latAmp: 67, period: 98.1, phase: 0.74, lon0: -25 }, mem0: 58 },
];
sats.forEach((s) => { s.color = COL[s.payload]; });
// --- Ground stations -------------------------------------------
const stations = [
{ id: "msk", name: "Москва", lon: 37.6, lat: 55.7 },
{ id: "mur", name: "Мурманск", lon: 33.1, lat: 68.9 },
{ id: "ana", name: "Анадырь", lon: 177.5, lat: 64.7 },
{ id: "kha", name: "Хабаровск", lon: 135.1, lat: 48.5 },
{ id: "kgd", name: "Калининград", lon: 20.5, lat: 54.7 },
{ id: "svb", name: "Шпицберген", lon: 15.6, lat: 78.2 },
{ id: "ant", name: "Прогресс", lon: 76.4, lat: -69.4 },
];
// --- Targets / ROIs (заявки на съёмку) -------------------------
// rect helper: center + half-width/height in degrees
function rect(lon, lat, hw, hh) {
return [
[lon - hw, lat - hh], [lon + hw, lat - hh],
[lon + hw, lat + hh], [lon - hw, lat + hh],
];
}
const rois = [
{ id: "gib", name: "Гибралтар", payload: "optic", poly: rect(-5.4, 36.0, 3.2, 2.4) },
{ id: "blk", name: "Чёрное море", payload: "optic", poly: rect(34.0, 43.5, 6.0, 3.0) },
{ id: "jpn", name: "Японские о-ва", payload: "radar", poly: rect(140.5, 37.5, 3.4, 6.5) },
{ id: "kam", name: "Камчатка", payload: "radar", poly: rect(159.0, 56.0, 4.5, 5.0) },
{ id: "cal", name: "Калифорния", payload: "optic", poly: rect(-119.5, 36.5, 3.5, 4.5) },
{ id: "syr", name: "Сирия", payload: "optic", poly: rect(38.5, 35.0, 3.0, 2.6) },
{ id: "sah", name: "о.Сахалин", payload: "radar", poly: rect(143.0, 50.5, 2.2, 4.8) },
{ id: "kur", name: "Курилы", payload: "radar", poly: rect(151.0, 46.0, 2.0, 3.6) },
];
window.OPS = { sats, stations, rois, COL };
})();
@@ -0,0 +1,331 @@
/* ============================================================
map.jsx — canvas world map (exports window.MapView)
Renders: land + graticule, ROIs, stations, night/terminator,
satellite sine tracks, footprints, capture swaths, downlinks.
Imperative draw(simTime, ui) called from the app RAF loop.
============================================================ */
const { useRef, useEffect } = React;
const MAP_C = {
ocean: "#0a0e0f",
land: "#171c1e",
landStroke: "#252d30",
grat: "rgba(120,150,150,0.06)",
gratText: "rgba(150,180,178,0.25)",
amber: "#d99a4e",
blue: "#5b9bd5",
red: "#d9685f",
teal: "#5fb3a3",
night: "rgba(20,28,46,0.55)",
nightEdge: "rgba(90,120,170,0.18)",
station: "#cdd6d3",
};
function MapView({ env, apiRef }) {
const wrapRef = useRef(null);
const canvasRef = useRef(null);
const st = useRef({
w: 0, h: 0, dpr: 1, proj: null, path: null,
base: null, land: null, ready: false,
});
// ---- load world land (topojson via unpkg, graceful fallback) ----
useEffect(() => {
let alive = true;
fetch("https://unpkg.com/world-atlas@2.0.2/land-110m.json")
.then((r) => r.json())
.then((topo) => {
if (!alive) return;
st.current.land = topojson.feature(topo, topo.objects.land);
buildBase();
if (apiRef.current.onReady) apiRef.current.onReady();
})
.catch(() => { buildBase(); });
return () => { alive = false; };
}, []);
// ---- sizing ----
function resize() {
const wrap = wrapRef.current, cv = canvasRef.current;
if (!wrap || !cv) return;
const r = wrap.getBoundingClientRect();
const dpr = Math.min(window.devicePixelRatio || 1, 2);
st.current.w = r.width; st.current.h = r.height; st.current.dpr = dpr;
cv.width = r.width * dpr; cv.height = r.height * dpr;
cv.style.width = r.width + "px"; cv.style.height = r.height + "px";
const proj = d3.geoEquirectangular()
.scale(r.width / (2 * Math.PI))
.translate([r.width / 2, r.height / 2])
.rotate([-10, 0]);
st.current.proj = proj;
buildBase();
}
useEffect(() => {
resize();
const ro = new ResizeObserver(resize);
if (wrapRef.current) ro.observe(wrapRef.current);
return () => ro.disconnect();
}, []);
// ---- cached base layer (land, graticule, ROIs, stations) ----
function buildBase() {
const s = st.current;
if (!s.w || !s.proj) return;
const dpr = s.dpr;
const off = document.createElement("canvas");
off.width = s.w * dpr; off.height = s.h * dpr;
const ctx = off.getContext("2d");
ctx.scale(dpr, dpr);
const path = d3.geoPath(s.proj, ctx);
// ocean
ctx.fillStyle = MAP_C.ocean;
ctx.fillRect(0, 0, s.w, s.h);
// graticule
const grat = d3.geoGraticule().step([30, 30]);
ctx.beginPath(); path(grat());
ctx.strokeStyle = MAP_C.grat; ctx.lineWidth = 1; ctx.stroke();
// land
if (s.land) {
ctx.beginPath(); path(s.land);
ctx.fillStyle = MAP_C.land; ctx.fill();
ctx.strokeStyle = MAP_C.landStroke; ctx.lineWidth = 0.6; ctx.stroke();
}
// ROIs (заявки) — faint amber dashed (planar path, avoids d3 winding-complement)
ctx.save();
ctx.lineWidth = 1; ctx.setLineDash([4, 3]);
for (const r of env.rois) {
tracePoly(ctx, s.proj, r.poly);
ctx.fillStyle = "rgba(217,154,78,0.05)"; ctx.fill();
ctx.strokeStyle = "rgba(217,154,78,0.5)"; ctx.stroke();
}
ctx.restore();
// station labels
ctx.font = "9px 'IBM Plex Mono', monospace";
ctx.textAlign = "left"; ctx.textBaseline = "middle";
for (const stn of env.stations) {
const pt = s.proj([stn.lon, stn.lat]); if (!pt) continue;
ctx.fillStyle = "rgba(205,214,211,0.55)";
ctx.fillText(stn.name.toUpperCase(), pt[0] + 8, pt[1] - 6);
}
s.base = off; s.path = path; s.ready = true;
}
// ---- helpers ----
function project(lon, lat) {
const p = st.current.proj && st.current.proj([lon, lat]);
return p;
}
// draw a great-circle-ish track polyline, breaking at antimeridian wrap
function strokeTrack(ctx, pts, color, width, alpha) {
const s = st.current, half = s.w * 0.5;
ctx.save(); ctx.globalAlpha = alpha; ctx.strokeStyle = color;
ctx.lineWidth = width; ctx.lineJoin = "round"; ctx.lineCap = "round";
ctx.beginPath();
let prev = null, started = false;
for (const ll of pts) {
const p = s.proj(ll); if (!p) { started = false; continue; }
if (started && prev && Math.abs(p[0] - prev[0]) > half) started = false;
if (!started) { ctx.moveTo(p[0], p[1]); started = true; }
else ctx.lineTo(p[0], p[1]);
prev = p;
}
ctx.stroke(); ctx.restore();
}
// ---- main per-frame draw ----
function draw(tMs, ui) {
const s = st.current, cv = canvasRef.current;
if (!cv || !s.proj) return;
const ctx = cv.getContext("2d");
ctx.setTransform(s.dpr, 0, 0, s.dpr, 0, 0);
ctx.clearRect(0, 0, s.w, s.h);
if (s.base) ctx.drawImage(s.base, 0, 0, s.w, s.h);
const path = d3.geoPath(s.proj, ctx);
const focus = ui.focusId;
// --- night overlay (тень Земли) ---
const ss = SIM.subSolar(tMs);
const anti = [SIM.wrapLon(ss.lon + 180), -ss.lat];
const nightPoly = d3.geoCircle().center(anti).radius(90)();
ctx.beginPath(); path(nightPoly);
ctx.fillStyle = MAP_C.night; ctx.fill();
ctx.lineWidth = 1; ctx.strokeStyle = MAP_C.nightEdge; ctx.stroke();
// --- per-sat dynamic ---
const dash = (tMs / 30) % 16;
const sats = env.sats;
// tracks first (under glyphs)
for (const sat of sats) {
const dim = focus && focus !== sat.id;
const pts = [];
for (let dm = -52; dm <= 52; dm += 1.6) {
pts.push([0, 0]); // placeholder replaced below
}
// build actual track points
pts.length = 0;
for (let dm = -52; dm <= 52; dm += 1.6) {
const p = SIM.subSat(sat, tMs + dm * 60000, env.epochMs);
pts.push([p.lon, p.lat]);
}
strokeTrack(ctx, pts, sat.color, dim ? 0.8 : 1.4, dim ? 0.10 : 0.30);
}
// events + glyphs
for (const sat of sats) {
const dim = focus && focus !== sat.id;
const stt = SIM.statusAt(sat, tMs, env);
const p = stt.p;
const pt = s.proj([p.lon, p.lat]); if (!pt) continue;
const baseA = dim ? 0.25 : 1;
// footprint circle (swath of view)
const fp = d3.geoCircle().center([p.lon, p.lat]).radius(sat.payload === "radar" ? 6.5 : 8)();
ctx.beginPath(); path(fp);
ctx.fillStyle = hexA(sat.color, dim ? 0.03 : 0.055); ctx.fill();
ctx.strokeStyle = hexA(sat.color, dim ? 0.08 : 0.16); ctx.lineWidth = 1; ctx.stroke();
// capture (маршрут съёмки) — amber swath along track + ROI highlight
if (stt.state === "capture") {
const seg = [];
for (let dm = -7; dm <= 7; dm += 1) {
const q = SIM.subSat(sat, tMs + dm * 60000, env.epochMs);
seg.push([q.lon, q.lat]);
}
strokeTrack(ctx, seg, MAP_C.amber, dim ? 3 : 6, dim ? 0.3 : 0.85);
ctx.setLineDash([]);
tracePoly(ctx, s.proj, stt.roi.poly);
ctx.fillStyle = "rgba(217,154,78,0.16)"; ctx.fill();
ctx.strokeStyle = MAP_C.amber; ctx.lineWidth = 1.4; ctx.stroke();
}
// downlink (сброс) — animated dashed line to station
if (stt.state === "downlink") {
const sp = s.proj([stt.station.lon, stt.station.lat]);
if (sp) {
ctx.save();
ctx.setLineDash([5, 5]); ctx.lineDashOffset = -dash;
ctx.strokeStyle = hexA(MAP_C.blue, dim ? 0.4 : 0.95); ctx.lineWidth = 1.6;
ctx.beginPath(); ctx.moveTo(pt[0], pt[1]); ctx.lineTo(sp[0], sp[1]); ctx.stroke();
ctx.restore();
// station glow
ctx.beginPath(); ctx.arc(sp[0], sp[1], 7, 0, 7);
ctx.fillStyle = hexA(MAP_C.blue, 0.18); ctx.fill();
}
}
// stations as triangles (drawn here so glow sits under)
// (drawn once globally below to avoid repaint per sat) -> skip
// sat glyph
drawSatGlyph(ctx, pt[0], pt[1], sat, stt, baseA, sat.id === focus);
// label
if (!dim || sat.id === focus) {
ctx.font = "10px 'IBM Plex Mono', monospace";
ctx.textAlign = "left"; ctx.textBaseline = "middle";
ctx.fillStyle = hexA("#e6ebe9", baseA);
ctx.fillText(sat.name, pt[0] + 11, pt[1] + 0.5);
}
}
// stations triangles on top
for (const stn of env.stations) {
const sp = s.proj([stn.lon, stn.lat]); if (!sp) continue;
drawTriangle(ctx, sp[0], sp[1], 5, MAP_C.station);
}
}
function drawSatGlyph(ctx, x, y, sat, stt, alpha, isFocus) {
const ringColor = stt.state === "capture" ? MAP_C.amber
: stt.state === "downlink" ? MAP_C.blue
: stt.state === "prohibit" ? MAP_C.red : null;
// halo
ctx.beginPath(); ctx.arc(x, y, 9, 0, 7);
ctx.fillStyle = hexA(sat.color, 0.10 * alpha); ctx.fill();
// ring
if (ringColor) {
ctx.beginPath(); ctx.arc(x, y, 7.5, 0, 7);
ctx.strokeStyle = hexA(ringColor, alpha); ctx.lineWidth = 1.6; ctx.setLineDash([]); ctx.stroke();
}
if (isFocus) {
ctx.beginPath(); ctx.arc(x, y, 11, 0, 7);
ctx.strokeStyle = hexA(MAP_C.teal, 0.9); ctx.lineWidth = 1; ctx.stroke();
}
// diamond body
ctx.save(); ctx.translate(x, y); ctx.rotate(Math.PI / 4);
const r = 3.4;
ctx.fillStyle = stt.eclipse ? hexA(sat.color, 0.5 * alpha) : hexA(sat.color, alpha);
ctx.strokeStyle = hexA("#05080a", alpha); ctx.lineWidth = 1;
ctx.beginPath(); ctx.rect(-r, -r, r * 2, r * 2); ctx.fill(); ctx.stroke();
ctx.restore();
}
function drawTriangle(ctx, x, y, r, color) {
ctx.beginPath();
ctx.moveTo(x, y - r); ctx.lineTo(x + r, y + r * 0.8); ctx.lineTo(x - r, y + r * 0.8);
ctx.closePath();
ctx.fillStyle = hexA(color, 0.85); ctx.fill();
ctx.strokeStyle = "rgba(10,14,16,0.9)"; ctx.lineWidth = 0.8; ctx.stroke();
}
// pointer → lon/lat + nearest sat
function onMove(e) {
const s = st.current, r = canvasRef.current.getBoundingClientRect();
const x = e.clientX - r.left, y = e.clientY - r.top;
const ll = s.proj && s.proj.invert([x, y]);
if (apiRef.current.onCursor) apiRef.current.onCursor(ll ? { lon: ll[0], lat: ll[1] } : null);
// hit test sats handled by app via getHover; expose nearest
if (apiRef.current.onPick) {
let best = null, bd = 16;
for (const sat of env.sats) {
const p = SIM.subSat(sat, apiRef.current.simTime(), env.epochMs);
const pp = s.proj([p.lon, p.lat]); if (!pp) continue;
const d = Math.hypot(pp[0] - x, pp[1] - y);
if (d < bd) { bd = d; best = sat.id; }
}
apiRef.current.onPick(best, x, y);
}
}
function onClick() {
if (apiRef.current.onClickPick) apiRef.current.onClickPick();
}
// expose imperative API
useEffect(() => {
apiRef.current.draw = draw;
apiRef.current.project = project;
});
return (
<div className="map-wrap" ref={wrapRef}>
<canvas ref={canvasRef} onMouseMove={onMove} onMouseLeave={() => apiRef.current.onCursor && apiRef.current.onCursor(null)} onClick={onClick} />
</div>
);
}
// trace a small lon/lat polygon as a planar projected path (no antimeridian crossing)
function tracePoly(ctx, proj, poly) {
ctx.beginPath();
poly.forEach((pt, i) => {
const p = proj(pt); if (!p) return;
if (i === 0) ctx.moveTo(p[0], p[1]); else ctx.lineTo(p[0], p[1]);
});
ctx.closePath();
}
function hexA(hex, a) {
if (hex[0] !== "#") return hex;
const n = parseInt(hex.slice(1), 16);
const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;
return `rgba(${r},${g},${b},${a})`;
}
window.MapView = MapView;
window.hexA = hexA;
@@ -0,0 +1,180 @@
/* ============================================================
panels.jsx — header + left panel (exports window.Header,
window.SidePanel). Status meta, summary, roster, activity feed.
============================================================ */
const STATUS_META = {
operational: { c: "#5cae7e", t: "НА ОРБИТЕ" },
capture: { c: "#d99a4e", t: "СЪЁМКА" },
downlink: { c: "#5b9bd5", t: "СБРОС" },
prohibit: { c: "#d9685f", t: "ЗАПРЕТ" },
};
function fmtClock(ms) {
const d = new Date(ms);
const p = (n) => String(n).padStart(2, "0");
return {
time: `${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())}`,
date: `${p(d.getUTCDate())}.${p(d.getUTCMonth() + 1)}.${d.getUTCFullYear()}`,
dow: ["ВС", "ПН", "ВТ", "СР", "ЧТ", "ПТ", "СБ"][d.getUTCDay()],
};
}
function fmtDur(ms) {
const s = Math.max(0, Math.round(ms / 1000));
const m = Math.floor(s / 60), ss = s % 60;
return `${m}:${String(ss).padStart(2, "0")}`;
}
function Header({ simTime, playing, speed, live, onPlay, onSpeed, onNow, onStep }) {
const ck = fmtClock(simTime);
const speeds = [1, 10, 60, 600];
return (
<header className="hdr">
<div className="hdr-brand">
<span className="brand-mark" />
<div className="brand-txt">
<div className="brand-1">СОСТОЯНИЕ ГРУППИРОВКИ</div>
<div className="brand-2">MISSION OPS · ТГУ</div>
</div>
</div>
<div className="hdr-transport">
<button className="t-btn" onClick={() => onStep(-3600e3)} title="1 час">1ч</button>
<button className={"t-btn t-play" + (playing ? " on" : "")} onClick={onPlay}>
{playing ? "❚❚" : "▶"}
</button>
<button className="t-btn" onClick={() => onStep(3600e3)} title="+1 час">+1ч</button>
<div className="t-sep" />
<div className="speed-seg">
{speeds.map((s) => (
<button key={s} className={"sp" + (speed === s ? " on" : "")} onClick={() => onSpeed(s)}>×{s}</button>
))}
</div>
<button className={"t-btn t-now" + (live ? " live" : "")} onClick={onNow}>СЕЙЧАС</button>
</div>
<div className="hdr-clock">
<div className={"live-badge" + (live ? " on" : "")}>
<span className="live-dot" />{live ? "LIVE" : "СИМ"}
</div>
<div className="clock-main">
<span className="clock-time">{ck.time}</span>
<span className="clock-utc">UTC</span>
</div>
<div className="clock-date">{ck.dow} · {ck.date}</div>
</div>
</header>
);
}
function SummaryTiles({ counts }) {
const tiles = [
{ k: "capture", label: "СНИМАЮТ", n: counts.capture, c: STATUS_META.capture.c },
{ k: "downlink", label: "СБРОС", n: counts.downlink, c: STATUS_META.downlink.c },
{ k: "prohibit", label: "ЗАПРЕТ", n: counts.prohibit, c: STATUS_META.prohibit.c },
{ k: "eclipse", label: "В ТЕНИ", n: counts.eclipse, c: "#7d92c4" },
];
return (
<div className="summary">
<div className="summary-tiles">
{tiles.map((t) => (
<div key={t.k} className="s-tile">
<div className="s-num" style={{ color: t.c }}>{t.n}</div>
<div className="s-lbl">{t.label}</div>
</div>
))}
</div>
<div className="summary-foot">
<span><i className="dot" style={{ background: window.OPS.COL.optic }} />ОПТИКА {counts.optic}</span>
<span><i className="dot" style={{ background: window.OPS.COL.radar }} />ЛОКАЦИЯ {counts.radar}</span>
<span className="muted">{counts.total} КА</span>
</div>
</div>
);
}
function SatRow({ sat, status, mem, focus, onClick }) {
const meta = STATUS_META[status.state];
const memColor = mem > 92 ? "#d9685f" : mem > 78 ? "#d99a4e" : "#5fb3a3";
return (
<div className={"sat-row" + (focus ? " focus" : "")} onClick={onClick}>
<span className="sr-payload" style={{ background: sat.color }} />
<div className="sr-main">
<div className="sr-top">
<span className="sr-name">{sat.name}</span>
<span className="sr-status" style={{ color: meta.c }}>
{status.eclipse && <span className="ecl"></span>}{meta.t}
</span>
</div>
<div className="sr-bot">
<span className="sr-norad">NORAD {sat.norad}</span>
<div className="mem-bar"><span style={{ width: mem + "%", background: memColor }} /></div>
<span className="sr-mem" style={{ color: memColor }}>{Math.round(mem)}%</span>
</div>
</div>
</div>
);
}
function Roster({ sats, statuses, memory, focusId, onFocus, query }) {
const q = query.trim().toLowerCase();
const filtered = sats.filter((s) =>
!q || s.name.toLowerCase().includes(q) || String(s.norad).includes(q) || s.group.toLowerCase().includes(q));
const groups = [];
let curD = null, curG = null;
filtered.forEach((s) => {
if (s.domain !== curD) { groups.push({ type: "domain", label: s.domain, color: s.color }); curD = s.domain; curG = null; }
if (s.group !== curG) { groups.push({ type: "group", label: s.group }); curG = s.group; }
groups.push({ type: "sat", sat: s });
});
return (
<div className="roster">
{groups.map((g, i) => {
if (g.type === "domain") return <div key={i} className="r-domain"><i className="dot" style={{ background: g.color }} />{g.label}</div>;
if (g.type === "group") return <div key={i} className="r-group">{g.label}</div>;
const s = g.sat;
return <SatRow key={s.id} sat={s} status={statuses[s.id]} mem={memory[s.id]}
focus={focusId === s.id} onClick={() => onFocus(focusId === s.id ? null : s.id)} />;
})}
</div>
);
}
function ActivityFeed({ events, onFocus, focusId }) {
return (
<div className="activity">
<div className="sec-label">АКТИВНЫЕ СОБЫТИЯ <span className="count">{events.length}</span></div>
<div className="act-list">
{events.length === 0 && <div className="act-empty">нет активных событий</div>}
{events.map((e) => (
<div key={e.id} className={"act-row" + (focusId === e.satId ? " focus" : "")} onClick={() => onFocus(e.satId)}>
<span className="act-bar" style={{ background: e.color }} />
<div className="act-body">
<div className="act-top"><span className="act-type" style={{ color: e.color }}>{e.type}</span><span className="act-tgt">{e.target}</span></div>
<div className="act-sub">{e.sat}<span className="act-rem">ещё {e.remain}</span></div>
</div>
</div>
))}
</div>
</div>
);
}
function SidePanel({ sats, statuses, memory, counts, events, focusId, onFocus, query, setQuery }) {
return (
<aside className="side">
<div className="sec-label">СВОДКА ГРУППИРОВКИ</div>
<SummaryTiles counts={counts} />
<div className="side-search">
<input placeholder="Поиск name / NORAD / группа" value={query} onChange={(e) => setQuery(e.target.value)} />
</div>
<div className="sec-label tight">КОСМИЧЕСКИЕ АППАРАТЫ</div>
<Roster sats={sats} statuses={statuses} memory={memory} focusId={focusId} onFocus={onFocus} query={query} />
<ActivityFeed events={events} onFocus={onFocus} focusId={focusId} />
</aside>
);
}
window.Header = Header;
window.SidePanel = SidePanel;
window.STATUS_META = STATUS_META;
window.fmtDur = fmtDur;
@@ -0,0 +1,158 @@
/* ============================================================
sim.js — geometry + schedule (plain JS, attaches window.SIM)
============================================================ */
(function () {
const D2R = Math.PI / 180, R2D = 180 / Math.PI;
const TWO_PI = Math.PI * 2;
const SIDEREAL_MIN = 1436.07; // earth rotation period (min)
function wrapLon(lon) {
lon = ((lon + 180) % 360 + 360) % 360 - 180;
return lon;
}
// sub-satellite point at sim time t(ms), given epoch ms.
function subSat(sat, tMs, epochMs) {
const m = (tMs - epochMs) / 60000; // minutes since epoch
const o = sat.orbit;
const M = o.phase * TWO_PI + TWO_PI * (m / o.period);
const lat = o.latAmp * Math.sin(M);
// one sine wave per orbit across full longitude, drifting west via earth rotation
const lon = wrapLon(o.lon0 + 360 * (m / o.period) - 360 * (m / SIDEREAL_MIN));
return { lon, lat };
}
// subsolar point (approx, declination ignored -> equatorial)
function subSolar(tMs) {
const secOfDay = (tMs / 1000) % 86400;
const lon = wrapLon(180 - (secOfDay / 86400) * 360);
// small seasonal declination wobble for life
const dayFrac = (tMs / 86400000) % 365.25;
const lat = 23.4 * Math.sin((dayFrac / 365.25) * TWO_PI - 1.4);
return { lon, lat };
}
// angular (great-circle) distance in degrees
function angDist(lon1, lat1, lon2, lat2) {
const φ1 = lat1 * D2R, φ2 = lat2 * D2R;
const = (lat2 - lat1) * D2R;
const = (lon2 - lon1) * D2R;
const a = Math.sin( / 2) ** 2 + Math.cos(φ1) * Math.cos(φ2) * Math.sin( / 2) ** 2;
return 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)) * R2D;
}
function inShadow(lon, lat, tMs) {
const ss = subSolar(tMs);
return angDist(lon, lat, ss.lon, ss.lat) > 90;
}
// point in polygon (ray casting), poly = [[lon,lat],...]
function pointInPoly(lon, lat, poly) {
let inside = false;
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
const xi = poly[i][0], yi = poly[i][1];
const xj = poly[j][0], yj = poly[j][1];
const intersect = (yi > lat) !== (yj > lat) &&
lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi;
if (intersect) inside = !inside;
}
return inside;
}
const STATION_RANGE = 22; // deg ground range for downlink visibility
// which ROI is the sat over (matching payload), null if none
function overROI(sat, p, rois) {
for (const r of rois) {
if (r.payload !== sat.payload) continue;
if (pointInPoly(p.lon, p.lat, r.poly)) return r;
}
return null;
}
// best (nearest in-range) station, null if none
function nearStation(p, stations) {
let best = null, bd = STATION_RANGE;
for (const st of stations) {
const d = angDist(p.lon, p.lat, st.lon, st.lat);
if (d < bd) { bd = d; best = st; }
}
return best;
}
// --- prohibition windows (интервалы запрета) per sat ----------
function buildProhibits(sats, epochMs) {
const H = 3600e3;
const out = {};
sats.forEach((s, i) => {
const list = [];
// deterministic-ish spread
const a0 = (i * 1.9) % 22 - 4; // hours from epoch
list.push({ start: epochMs + a0 * H, end: epochMs + (a0 + 0.7 + (i % 3) * 0.15) * H, reason: "ВНЗ" });
if (i % 2 === 0) {
const a1 = a0 + 8 + (i % 4);
list.push({ start: epochMs + a1 * H, end: epochMs + (a1 + 0.9) * H, reason: "ОГРАН. ПИТАНИЕ" });
}
out[s.id] = list;
});
return out;
}
function inProhibit(list, tMs) {
if (!list) return null;
for (const w of list) if (tMs >= w.start && tMs < w.end) return w;
return null;
}
// live status of a sat at time t (priority order)
// returns {state, roi, station, eclipse, prohibit}
function statusAt(sat, tMs, env) {
const p = subSat(sat, tMs, env.epochMs);
const prohibit = inProhibit(env.prohibits[sat.id], tMs);
const eclipse = inShadow(p.lon, p.lat, tMs);
if (prohibit) return { state: "prohibit", p, prohibit, eclipse };
const roi = overROI(sat, p, env.rois);
if (roi) return { state: "capture", p, roi, eclipse };
const st = nearStation(p, env.stations);
if (st) return { state: "downlink", p, station: st, eclipse };
return { state: "operational", p, eclipse };
}
// --- precompute event intervals over a domain for the timeline -
function buildSchedule(sats, env, domainStart, domainEnd, stepMin) {
const step = stepMin * 60000;
const sched = {};
for (const s of sats) {
const tracks = { capture: [], downlink: [], prohibit: [], shadow: [] };
// prohibits are explicit
(env.prohibits[s.id] || []).forEach((w) =>
tracks.prohibit.push({ start: w.start, end: w.end, label: w.reason }));
let cur = { capture: null, downlink: null, shadow: null };
for (let t = domainStart; t <= domainEnd; t += step) {
const p = subSat(s, t, env.epochMs);
const proh = inProhibit(env.prohibits[s.id], t);
const shadow = inShadow(p.lon, p.lat, t);
const roi = !proh ? overROI(s, p, env.rois) : null;
const st = !proh && !roi ? nearStation(p, env.stations) : null;
// shadow band
if (shadow) { if (!cur.shadow) cur.shadow = { start: t, end: t }; else cur.shadow.end = t; }
else if (cur.shadow) { tracks.shadow.push(cur.shadow); cur.shadow = null; }
// capture band
if (roi) { if (!cur.capture) cur.capture = { start: t, end: t, label: roi.name }; else cur.capture.end = t; }
else if (cur.capture) { tracks.capture.push(cur.capture); cur.capture = null; }
// downlink band
if (st) { if (!cur.downlink) cur.downlink = { start: t, end: t, label: st.name }; else cur.downlink.end = t; }
else if (cur.downlink) { tracks.downlink.push(cur.downlink); cur.downlink = null; }
}
if (cur.shadow) tracks.shadow.push(cur.shadow);
if (cur.capture) tracks.capture.push(cur.capture);
if (cur.downlink) tracks.downlink.push(cur.downlink);
sched[s.id] = tracks;
}
return sched;
}
window.SIM = {
D2R, R2D, wrapLon, subSat, subSolar, angDist, inShadow,
pointInPoly, overROI, nearStation, buildProhibits, inProhibit,
statusAt, buildSchedule, STATION_RANGE,
};
})();
@@ -0,0 +1,160 @@
/* ============================================================
timeline.jsx — bottom event timeline (exports window.Timeline)
Per-sat rows with capture / downlink / prohibit / shadow bands,
moving playhead, real-"now" marker, drag-to-scrub.
============================================================ */
const { useRef: useRefTL, useEffect: useEffectTL, useState: useStateTL } = React;
const TL_SPAN = 12 * 3600e3; // visible window
const TL_LEAD = 0.32; // playhead position fraction
const TL_GUTTER = 188;
const TL_ROW = 22;
const TL_AXIS = 26;
const TL_COL = {
capture: "#d99a4e",
downlink: "#5b9bd5",
prohibit: "#d9685f",
shadow: "rgba(120,140,180,0.30)",
};
function Timeline({ env, sched, simTime, domain, focusId, onScrub, onFocus, live }) {
const wrapRef = useRefTL(null);
const [w, setW] = useStateTL(1200);
const dragRef = useRefTL(false);
useEffectTL(() => {
const ro = new ResizeObserver((es) => setW(es[0].contentRect.width));
if (wrapRef.current) ro.observe(wrapRef.current);
return () => ro.disconnect();
}, []);
const areaW = Math.max(200, w - TL_GUTTER);
let viewStart = simTime - TL_LEAD * TL_SPAN;
viewStart = Math.max(domain.start, Math.min(viewStart, domain.end - TL_SPAN));
const viewEnd = viewStart + TL_SPAN;
const X = (t) => TL_GUTTER + ((t - viewStart) / TL_SPAN) * areaW;
const clampT = (t) => Math.max(viewStart, Math.min(viewEnd, t));
const sats = env.sats;
const rows = [];
let y = TL_AXIS;
let lastDomain = null;
sats.forEach((s) => {
if (s.domain !== lastDomain) { rows.push({ type: "group", label: s.domain, y }); y += 16; lastDomain = s.domain; }
rows.push({ type: "sat", sat: s, y });
y += TL_ROW;
});
const totalH = y + 6;
// axis ticks (hourly)
const ticks = [];
const hour = 3600e3;
let t0 = Math.ceil(viewStart / hour) * hour;
for (let t = t0; t <= viewEnd; t += hour) ticks.push(t);
function ptFromEvent(e) {
const r = wrapRef.current.getBoundingClientRect();
const x = e.clientX - r.left;
const frac = (x - TL_GUTTER) / areaW;
return viewStart + frac * TL_SPAN;
}
function down(e) {
if (e.clientX - wrapRef.current.getBoundingClientRect().left < TL_GUTTER) return;
dragRef.current = true; onScrub(ptFromEvent(e));
}
function move(e) { if (dragRef.current) onScrub(ptFromEvent(e)); }
function up() { dragRef.current = false; }
useEffectTL(() => {
window.addEventListener("mousemove", move);
window.addEventListener("mouseup", up);
return () => { window.removeEventListener("mousemove", move); window.removeEventListener("mouseup", up); };
});
const nowX = X(Date.now());
const headX = X(simTime);
return (
<div className="tl-wrap" ref={wrapRef} onMouseDown={down}>
<svg width={w} height={totalH} style={{ display: "block" }}>
<defs>
<pattern id="hatch" width="6" height="6" patternTransform="rotate(45)" patternUnits="userSpaceOnUse">
<rect width="6" height="6" fill="rgba(217,104,95,0.12)" />
<line x1="0" y1="0" x2="0" y2="6" stroke="rgba(217,104,95,0.6)" strokeWidth="2" />
</pattern>
</defs>
{/* axis */}
<line x1={TL_GUTTER} y1={TL_AXIS} x2={w} y2={TL_AXIS} stroke="rgba(255,255,255,0.08)" />
{ticks.map((t, i) => {
const x = X(t); const d = new Date(t);
const hh = String(d.getUTCHours()).padStart(2, "0");
const isDay = d.getUTCHours() === 0;
return (
<g key={i}>
<line x1={x} y1={TL_AXIS - 5} x2={x} y2={totalH} stroke="rgba(255,255,255,0.035)" />
<text x={x + 3} y={TL_AXIS - 9} className="tl-tick">
{isDay ? `${String(d.getUTCDate()).padStart(2, "0")}.${String(d.getUTCMonth() + 1).padStart(2, "0")}` : `${hh}:00`}
</text>
</g>
);
})}
{/* rows */}
{rows.map((r, i) => {
if (r.type === "group") {
return <text key={i} x={10} y={r.y + 11} className="tl-group">{r.label}</text>;
}
const s = r.sat;
const tr = sched[s.id] || {};
const dim = focusId && focusId !== s.id;
const cy = r.y + TL_ROW / 2;
const band = (iv, key, fill, h) => {
const x1 = X(Math.max(iv.start, viewStart)), x2 = X(Math.min(iv.end, viewEnd));
if (x2 <= TL_GUTTER || x1 >= w || x2 - x1 < 0.6) return null;
return <rect key={key} x={x1} y={cy - h / 2} width={Math.max(1.5, x2 - x1)} height={h}
fill={fill} rx="1" opacity={dim ? 0.3 : 1} />;
};
return (
<g key={i} onClick={() => onFocus(focusId === s.id ? null : s.id)} style={{ cursor: "pointer" }}>
<rect x={0} y={r.y} width={w} height={TL_ROW} fill={focusId === s.id ? "rgba(95,179,163,0.06)" : "transparent"} />
<line x1={TL_GUTTER} y1={r.y + TL_ROW} x2={w} y2={r.y + TL_ROW} stroke="rgba(255,255,255,0.025)" />
<circle cx={14} cy={cy} r={3} fill={s.color} opacity={dim ? 0.4 : 1} />
<text x={24} y={cy + 3.5} className="tl-name" opacity={dim ? 0.45 : 1}>{s.name}</text>
{/* baseline */}
<line x1={TL_GUTTER} y1={cy} x2={w} y2={cy} stroke="rgba(255,255,255,0.04)" />
{/* shadow (under) */}
{(tr.shadow || []).map((iv, k) => band(iv, "sh" + k, TL_COL.shadow, TL_ROW - 8))}
{/* downlink */}
{(tr.downlink || []).map((iv, k) => band(iv, "dl" + k, TL_COL.downlink, 8))}
{/* capture */}
{(tr.capture || []).map((iv, k) => band(iv, "cp" + k, TL_COL.capture, 10))}
{/* prohibit (hatched, full height) */}
{(tr.prohibit || []).map((iv, k) => {
const x1 = X(Math.max(iv.start, viewStart)), x2 = X(Math.min(iv.end, viewEnd));
if (x2 <= TL_GUTTER || x1 >= w || x2 - x1 < 0.6) return null;
return <rect key={"pr" + k} x={x1} y={r.y + 2} width={Math.max(2, x2 - x1)} height={TL_ROW - 4}
fill="url(#hatch)" stroke="rgba(217,104,95,0.5)" strokeWidth="0.8" opacity={dim ? 0.4 : 1} />;
})}
</g>
);
})}
{/* now marker (real wall clock) */}
{nowX > TL_GUTTER && nowX < w && (
<g>
<line x1={nowX} y1={TL_AXIS - 5} x2={nowX} y2={totalH} stroke="rgba(95,179,163,0.35)" strokeDasharray="2 3" />
<text x={nowX + 3} y={totalH - 3} className="tl-nowlbl">СЕЙЧАС</text>
</g>
)}
{/* playhead */}
<g>
<line x1={headX} y1={TL_AXIS - 6} x2={headX} y2={totalH} stroke={live ? "#5fb3a3" : "#d99a4e"} strokeWidth="1.5" />
<polygon points={`${headX - 5},${TL_AXIS - 6} ${headX + 5},${TL_AXIS - 6} ${headX},${TL_AXIS + 1}`} fill={live ? "#5fb3a3" : "#d99a4e"} />
</g>
</svg>
</div>
);
}
window.Timeline = Timeline;
@@ -0,0 +1,34 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Состояние группировки — Mission Ops</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<div id="root"></div>
<!-- geo libs -->
<script src="https://unpkg.com/d3@7.9.0/dist/d3.min.js"></script>
<script src="https://unpkg.com/topojson-client@3.1.0/dist/topojson-client.min.js"></script>
<!-- react + babel (pinned) -->
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
<!-- data + sim (plain) -->
<script src="js/data.js"></script>
<script src="js/sim.js"></script>
<!-- components (babel) -->
<script type="text/babel" src="js/map.jsx"></script>
<script type="text/babel" src="js/timeline.jsx"></script>
<script type="text/babel" src="js/panels.jsx"></script>
<script type="text/babel" src="js/app.jsx"></script>
</body>
</html>
@@ -0,0 +1,180 @@
:root {
--bg0:#0a0c0b; --bg1:#0e1211; --panel:#111514; --ins:#0a0e0d;
--line:rgba(255,255,255,0.06); --line2:rgba(255,255,255,0.11);
--hi:#e7ece9; --mid:#95a19d; --dim:#5b6662; --faint:#3c4644;
--teal:#5fb3a3; --teal-bg:rgba(95,179,163,0.13); --teal-br:rgba(95,179,163,0.35);
--amber:#d99a4e; --blue:#5b9bd5; --red:#d9685f; --green:#5cae7e; --violet:#a78bdb;
}
*{box-sizing:border-box;}
html,body{height:100%;margin:0;}
body{
background:var(--bg0); color:var(--hi);
font-family:'IBM Plex Sans',system-ui,sans-serif;
font-size:13px; -webkit-font-smoothing:antialiased;
overflow:hidden;
}
.mono,.clock-time,.sr-norad,.map-cursor,.tl-tick,.fc-meta{font-family:'IBM Plex Mono',monospace;}
button{font-family:inherit; cursor:pointer; color:inherit;}
::-webkit-scrollbar{width:8px;height:8px;}
::-webkit-scrollbar-thumb{background:rgba(255,255,255,0.07);border-radius:4px;}
::-webkit-scrollbar-track{background:transparent;}
.app{height:100vh; display:flex; flex-direction:column; overflow:hidden;}
/* ---------- header ---------- */
.hdr{
height:54px; flex:none; display:flex; align-items:center;
border-bottom:1px solid var(--line); background:linear-gradient(var(--bg1),var(--bg0));
padding:0 16px; gap:18px; position:relative; z-index:5;
}
.hdr-brand{display:flex; align-items:center; gap:11px; min-width:248px;}
.brand-mark{width:20px;height:20px;border-radius:50%;border:1.5px solid var(--teal);
box-shadow:0 0 10px rgba(95,179,163,0.4) inset, 0 0 6px rgba(95,179,163,0.3);}
.brand-1{font-weight:600; font-size:13px; letter-spacing:.04em; white-space:nowrap;}
.brand-2{font-family:'IBM Plex Mono',monospace; font-size:9px; color:var(--dim); letter-spacing:.12em; margin-top:1px;}
.hdr-transport{flex:1; display:flex; align-items:center; justify-content:center; gap:7px;}
.t-btn{background:var(--bg1); border:1px solid var(--line2); color:var(--mid);
height:30px; min-width:34px; padding:0 11px; border-radius:6px; font-size:11.5px; letter-spacing:.04em;
transition:.12s;}
.t-btn:hover{border-color:var(--teal-br); color:var(--hi);}
.t-play{font-size:12px; color:var(--teal); border-color:var(--teal-br); background:var(--teal-bg); width:42px;}
.t-play.on{background:rgba(95,179,163,0.2);}
.t-now{font-size:10.5px; letter-spacing:.1em;}
.t-now.live{background:rgba(92,174,126,0.16); border-color:rgba(92,174,126,0.5); color:var(--green);}
.t-sep{width:1px; height:20px; background:var(--line2); margin:0 4px;}
.speed-seg{display:flex; border:1px solid var(--line2); border-radius:6px; overflow:hidden; background:var(--bg1);}
.speed-seg .sp{background:transparent; border:0; color:var(--dim); height:28px; padding:0 11px; font-size:11px;
font-family:'IBM Plex Mono',monospace; border-right:1px solid var(--line); transition:.12s;}
.speed-seg .sp:last-child{border-right:0;}
.speed-seg .sp:hover{color:var(--mid);}
.speed-seg .sp.on{background:var(--teal-bg); color:var(--teal);}
.hdr-clock{display:flex; flex-direction:column; align-items:flex-end; min-width:240px; gap:1px;}
.live-badge{display:flex; align-items:center; gap:5px; font-size:9.5px; letter-spacing:.16em; color:var(--amber);
font-family:'IBM Plex Mono',monospace;}
.live-badge .live-dot{width:6px;height:6px;border-radius:50%;background:var(--amber);}
.live-badge.on{color:var(--green);}
.live-badge.on .live-dot{background:var(--green); box-shadow:0 0 6px var(--green); animation:pulse 1.4s infinite;}
@keyframes pulse{0%,100%{opacity:1;}50%{opacity:.35;}}
.clock-main{display:flex; align-items:baseline; gap:6px;}
.clock-time{font-size:22px; font-weight:500; letter-spacing:.02em; line-height:1;}
.clock-utc{font-size:9px; color:var(--dim); letter-spacing:.1em;}
.clock-date{font-size:10px; color:var(--mid); font-family:'IBM Plex Mono',monospace; letter-spacing:.06em;}
/* ---------- body ---------- */
.body{flex:1; display:flex; min-height:0;}
.side{width:328px; flex:none; border-right:1px solid var(--line); background:var(--bg1);
display:flex; flex-direction:column; overflow:hidden;}
.main{flex:1; display:flex; flex-direction:column; min-width:0;}
.sec-label{font-family:'IBM Plex Mono',monospace; font-size:9.5px; letter-spacing:.16em; color:var(--dim);
padding:13px 16px 8px; display:flex; align-items:center; gap:8px;}
.sec-label.tight{padding-top:6px;}
.sec-label .count{background:var(--bg0); border:1px solid var(--line2); border-radius:8px; padding:0 6px;
font-size:9px; color:var(--mid);}
/* summary */
.summary{padding:0 14px 6px;}
.summary-tiles{display:grid; grid-template-columns:repeat(4,1fr); gap:6px;}
.s-tile{background:var(--ins); border:1px solid var(--line); border-radius:7px; padding:9px 4px 7px; text-align:center;}
.s-num{font-family:'IBM Plex Mono',monospace; font-size:21px; font-weight:600; line-height:1;}
.s-lbl{font-size:8px; color:var(--dim); letter-spacing:.1em; margin-top:5px;}
.summary-foot{display:flex; align-items:center; gap:13px; padding:9px 2px 2px; font-size:10px;
color:var(--mid); font-family:'IBM Plex Mono',monospace;}
.summary-foot .dot,.r-domain .dot{width:7px;height:7px;border-radius:2px;display:inline-block;margin-right:5px;vertical-align:middle;}
.summary-foot .muted{margin-left:auto; color:var(--dim);}
/* search */
.side-search{padding:4px 14px 2px;}
.side-search input{width:100%; background:var(--ins); border:1px solid var(--line); border-radius:6px;
color:var(--hi); padding:7px 10px; font-size:11.5px; outline:none; font-family:'IBM Plex Mono',monospace;}
.side-search input::placeholder{color:var(--faint);}
.side-search input:focus{border-color:var(--teal-br);}
/* roster */
.roster{flex:1; min-height:0; overflow-y:auto; padding:0 8px 8px;}
.r-domain{font-family:'IBM Plex Mono',monospace; font-size:10px; letter-spacing:.14em; color:var(--mid);
padding:11px 8px 5px; display:flex; align-items:center;}
.r-group{font-size:9.5px; color:var(--faint); letter-spacing:.08em; padding:3px 8px 3px 20px; text-transform:uppercase;}
.sat-row{display:flex; gap:9px; padding:7px 8px 7px 10px; border-radius:7px; cursor:pointer;
align-items:center; transition:.1s; border:1px solid transparent;}
.sat-row:hover{background:rgba(255,255,255,0.025);}
.sat-row.focus{background:var(--teal-bg); border-color:var(--teal-br);}
.sr-payload{width:3px; align-self:stretch; border-radius:2px; flex:none;}
.sr-main{flex:1; min-width:0;}
.sr-top{display:flex; justify-content:space-between; align-items:baseline; gap:8px;}
.sr-name{font-size:12.5px; font-weight:500; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;}
.sr-status{font-size:9px; letter-spacing:.08em; font-family:'IBM Plex Mono',monospace; flex:none; display:flex; align-items:center; gap:3px;}
.sr-status .ecl{opacity:.85;}
.sr-bot{display:flex; align-items:center; gap:8px; margin-top:4px;}
.sr-norad{font-size:9px; color:var(--dim); flex:none;}
.mem-bar{flex:1; height:4px; background:rgba(255,255,255,0.07); border-radius:2px; overflow:hidden;}
.mem-bar span{display:block; height:100%; border-radius:2px; transition:width .2s;}
.sr-mem{font-size:9.5px; font-family:'IBM Plex Mono',monospace; flex:none; width:30px; text-align:right;}
/* activity */
.activity{flex:none; max-height:34%; display:flex; flex-direction:column; border-top:1px solid var(--line); overflow:hidden;}
.act-list{overflow-y:auto; padding:0 8px 8px;}
.act-empty{color:var(--faint); font-size:11px; padding:6px 10px; font-family:'IBM Plex Mono',monospace;}
.act-row{display:flex; gap:8px; padding:6px 8px; border-radius:6px; cursor:pointer; align-items:center;}
.act-row:hover{background:rgba(255,255,255,0.025);}
.act-row.focus{background:var(--teal-bg);}
.act-bar{width:3px; align-self:stretch; border-radius:2px; flex:none;}
.act-body{flex:1; min-width:0;}
.act-top{display:flex; gap:7px; align-items:baseline;}
.act-type{font-size:9.5px; letter-spacing:.1em; font-family:'IBM Plex Mono',monospace;}
.act-tgt{font-size:11px; color:var(--hi); white-space:nowrap; overflow:hidden; text-overflow:ellipsis;}
.act-sub{display:flex; justify-content:space-between; font-size:9.5px; color:var(--dim); margin-top:2px; font-family:'IBM Plex Mono',monospace;}
.act-rem{color:var(--mid);}
/* ---------- map ---------- */
.map-area{flex:1; position:relative; min-height:0; overflow:hidden; background:var(--ins);}
.map-wrap{position:absolute; inset:0;}
.map-wrap canvas{display:block;}
.map-caption{position:absolute; top:12px; left:12px; z-index:3;}
.cap-hint{font-family:'IBM Plex Mono',monospace; font-size:9.5px; letter-spacing:.12em; color:var(--faint);
background:rgba(10,14,13,0.6); border:1px solid var(--line); padding:6px 10px; border-radius:6px; white-space:nowrap;}
.focus-card{display:flex; align-items:stretch; gap:11px; background:rgba(11,16,15,0.92); backdrop-filter:blur(6px);
border:1px solid var(--teal-br); border-radius:8px; padding:10px 13px; min-width:330px;}
.fc-pay{width:3px; border-radius:2px; flex:none;}
.fc-info{flex:1;}
.fc-name{font-size:14px; font-weight:600; display:flex; justify-content:space-between; align-items:center;}
.fc-x{background:none; border:0; color:var(--dim); font-size:12px; padding:0 2px;}
.fc-x:hover{color:var(--hi);}
.fc-meta{font-size:9.5px; color:var(--mid); margin-top:4px; display:flex; gap:6px; align-items:center; letter-spacing:.04em;}
.fc-dot{color:var(--faint);}
.fc-pos{text-align:right; font-family:'IBM Plex Mono',monospace; font-size:10px; color:var(--mid); display:flex; flex-direction:column; justify-content:center; gap:4px;}
.fc-mem b{color:var(--hi);}
.map-legend{position:absolute; bottom:12px; left:12px; z-index:3; background:rgba(10,14,13,0.55);
border:1px solid var(--line); border-radius:7px; padding:8px 11px; display:flex; flex-direction:column; gap:5px;}
.ml-row{display:flex; align-items:center; gap:8px; font-family:'IBM Plex Mono',monospace; font-size:9px;
letter-spacing:.08em; color:var(--mid); white-space:nowrap;}
.ml-tri{width:0;height:0;border-left:4px solid transparent;border-right:4px solid transparent;border-bottom:7px solid #cdd6d3;}
.ml-sw{width:14px; height:3px; border-radius:2px; background:var(--teal);}
.ml-sw.amber{background:var(--amber);}
.ml-sw.blue{background:var(--blue);}
.ml-sw.blue.dash{background:repeating-linear-gradient(90deg,var(--blue) 0 4px,transparent 4px 7px);}
.ml-sw.night{background:rgba(90,120,170,0.5); height:9px; width:14px; border-radius:2px;}
.map-cursor{position:absolute; bottom:10px; right:14px; z-index:3; font-family:'IBM Plex Mono',monospace;
font-size:10px; color:var(--mid); background:rgba(10,14,13,0.6); padding:3px 8px; border-radius:5px; border:1px solid var(--line);}
.map-tip{position:absolute; z-index:4; pointer-events:none; font-family:'IBM Plex Mono',monospace; font-size:10px;
background:rgba(11,16,15,0.95); border:1px solid var(--line2); padding:3px 7px; border-radius:5px; color:var(--hi); white-space:nowrap;}
/* ---------- timeline ---------- */
.tl-area{height:248px; flex:none; border-top:1px solid var(--line); background:var(--bg1); display:flex; flex-direction:column;}
.tl-head{height:34px; flex:none; display:flex; align-items:center; gap:18px; padding:0 16px; border-bottom:1px solid var(--line);}
.tl-title{font-family:'IBM Plex Mono',monospace; font-size:10px; letter-spacing:.16em; color:var(--mid);}
.tl-legend{display:flex; gap:15px; flex:1;}
.leg-item{display:flex; align-items:center; gap:6px; font-family:'IBM Plex Mono',monospace; font-size:9px;
letter-spacing:.08em; color:var(--dim);}
.leg-sw{width:13px; height:8px; border-radius:2px; display:inline-block;}
.leg-sw.hatch{background:repeating-linear-gradient(45deg,rgba(217,104,95,0.7) 0 2px,rgba(217,104,95,0.12) 2px 5px); border:1px solid rgba(217,104,95,0.5);}
.tl-hint{font-family:'IBM Plex Mono',monospace; font-size:9.5px; color:var(--faint); letter-spacing:.06em;}
.tl-wrap{flex:1; overflow-y:auto; overflow-x:hidden; user-select:none; cursor:crosshair;}
.tl-tick{fill:var(--dim); font-size:9px; font-family:'IBM Plex Mono',monospace;}
.tl-group{fill:var(--faint); font-size:9px; letter-spacing:.14em; font-family:'IBM Plex Mono',monospace;}
.tl-name{fill:var(--mid); font-size:10.5px;}
.tl-nowlbl{fill:rgba(95,179,163,0.6); font-size:8px; letter-spacing:.12em; font-family:'IBM Plex Mono',monospace;}
@@ -0,0 +1,210 @@
/* ============================================================
ConstellationDashboard.jsx wiring, RAF clock, memory sim (ESM)
Default export: <ConstellationDashboard /> монтируется как вкладка.
============================================================ */
import React, { useState, useEffect, useRef, useMemo } from "react";
import MapView from "./MapView.jsx";
import Timeline from "./Timeline.jsx";
import { Header, SidePanel, STATUS_META, fmtDur } from "./Panels.jsx";
import * as SIM from "./sim.js";
import { SATELLITES, STATIONS, ROIS, COL } from "./data.js";
import "./dashboard.css";
function buildEnv() {
const epochMs = Date.now();
const sats = SATELLITES, stations = STATIONS, rois = ROIS;
const prohibits = SIM.buildProhibits(sats, epochMs);
const env = { epochMs, sats, stations, rois, prohibits };
const domain = { start: epochMs - 6 * 3600e3, end: epochMs + 18 * 3600e3 };
const sched = SIM.buildSchedule(sats, env, domain.start, domain.end, 1);
return { env, domain, sched };
}
function findInterval(list, t) {
if (!list) return null;
for (const iv of list) if (t >= iv.start && t < iv.end) return iv;
return null;
}
function App() {
const cfg = useMemo(buildEnv, []);
const { env, domain, sched } = cfg;
const apiRef = useRef({});
const simRef = useRef(env.epochMs);
const playingRef = useRef(true);
const speedRef = useRef(10);
const liveRef = useRef(false);
const focusRef = useRef(null);
const hoverRef = useRef(null);
const memRef = useRef(Object.fromEntries(env.sats.map((s) => [s.id, s.mem0])));
const lastT = useRef(performance.now());
const [, setTick] = useState(0);
const [playing, setPlaying] = useState(true);
const [speed, setSpeed] = useState(10);
const [live, setLive] = useState(false);
const [focusId, setFocusId] = useState(null);
const [query, setQuery] = useState("");
const [cursor, setCursor] = useState(null);
const [hover, setHover] = useState(null);
function setFocus(id) { focusRef.current = id; setFocusId(id); }
// map api callbacks
useEffect(() => {
const a = apiRef.current;
a.simTime = () => simRef.current;
a.onCursor = (ll) => setCursor(ll);
a.onPick = (id, x, y) => { hoverRef.current = id; setHover(id ? { id, x, y } : null); };
a.onClickPick = () => { if (hoverRef.current) setFocus(focusRef.current === hoverRef.current ? null : hoverRef.current); };
a.onReady = () => a.draw && a.draw(simRef.current, { focusId: focusRef.current });
}, []);
// RAF loop
useEffect(() => {
let raf;
let acc = 0;
const loop = (now) => {
const dt = Math.min(100, now - lastT.current);
lastT.current = now;
if (playingRef.current) {
if (liveRef.current) simRef.current = Date.now();
else simRef.current += dt * speedRef.current;
if (simRef.current > domain.end) simRef.current = domain.end;
if (simRef.current < domain.start) simRef.current = domain.start;
// memory dynamics
const secEq = (dt * speedRef.current) / 1000;
for (const s of env.sats) {
const stt = SIM.statusAt(s, simRef.current, env);
let m = memRef.current[s.id];
if (stt.state === "capture") m += 0.7 * secEq;
else if (stt.state === "downlink") m -= 1.5 * secEq;
else m += 0.02 * secEq; // idle housekeeping drift
memRef.current[s.id] = Math.max(0, Math.min(100, m));
}
}
if (apiRef.current.draw) apiRef.current.draw(simRef.current, { focusId: focusRef.current });
acc += dt;
if (acc >= 80) { acc = 0; setTick((t) => (t + 1) % 1e6); }
raf = requestAnimationFrame(loop);
};
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
}, []);
// ---- controls ----
function onPlay() { const p = !playingRef.current; playingRef.current = p; setPlaying(p); if (!p) { liveRef.current = false; setLive(false); } }
function onSpeed(s) { speedRef.current = s; setSpeed(s); if (s !== 1) { liveRef.current = false; setLive(false); } }
function onNow() { liveRef.current = true; playingRef.current = true; speedRef.current = 1; simRef.current = Date.now(); setLive(true); setPlaying(true); setSpeed(1); }
function onStep(d) { liveRef.current = false; setLive(false); simRef.current = Math.max(domain.start, Math.min(domain.end, simRef.current + d)); }
function onScrub(t) { liveRef.current = false; setLive(false); simRef.current = Math.max(domain.start, Math.min(domain.end, t)); }
// ---- derived (per render) ----
const tNow = simRef.current;
const statuses = {};
const counts = { capture: 0, downlink: 0, prohibit: 0, eclipse: 0, optic: 0, radar: 0, total: env.sats.length };
for (const s of env.sats) {
const st = SIM.statusAt(s, tNow, env);
statuses[s.id] = st;
if (counts[st.state] !== undefined) counts[st.state]++;
if (st.eclipse) counts.eclipse++;
if (s.payload === "optic") counts.optic++; else counts.radar++;
}
const events = [];
for (const s of env.sats) {
const st = statuses[s.id];
let iv = null, type = null, target = null, color = null;
if (st.state === "capture") { iv = findInterval(sched[s.id].capture, tNow); type = "СЪЁМКА"; target = st.roi.name; color = STATUS_META.capture.c; }
else if (st.state === "downlink") { iv = findInterval(sched[s.id].downlink, tNow); type = "СБРОС"; target = "ст. " + st.station.name; color = STATUS_META.downlink.c; }
else if (st.state === "prohibit") { iv = findInterval(sched[s.id].prohibit, tNow); type = "ЗАПРЕТ"; target = st.prohibit.reason; color = STATUS_META.prohibit.c; }
if (type) events.push({ id: s.id, satId: s.id, sat: s.name, type, target, color, remain: fmtDur((iv ? iv.end : tNow) - tNow) });
}
events.sort((a, b) => a.remain.localeCompare(b.remain));
const focusSat = focusId ? env.sats.find((s) => s.id === focusId) : null;
return (
<div className="app">
<Header simTime={tNow} playing={playing} speed={speed} live={live}
onPlay={onPlay} onSpeed={onSpeed} onNow={onNow} onStep={onStep} />
<div className="body">
<SidePanel sats={env.sats} statuses={statuses} memory={memRef.current} counts={counts}
events={events} focusId={focusId} onFocus={setFocus} query={query} setQuery={setQuery} />
<div className="main">
<div className="map-area">
<MapView env={env} apiRef={apiRef} />
<div className="map-caption">
{focusSat ? (
<FocusCard sat={focusSat} status={statuses[focusId]} mem={memRef.current[focusId]} onClose={() => setFocus(null)} />
) : (
<div className="cap-hint">КЛИК ПО АППАРАТУ ФОКУС И ПОДСВЕТКА СОБЫТИЙ</div>
)}
</div>
<MapLegend />
<div className="map-cursor">
{cursor ? `${Math.abs(cursor.lat).toFixed(3)}° ${cursor.lat >= 0 ? "с.ш." : "ю.ш."}, ${Math.abs(cursor.lon).toFixed(3)}° ${cursor.lon >= 0 ? "в.д." : "з.д."}` : "—"}
</div>
{hover && hover.id !== focusId && (
<div className="map-tip" style={{ left: hover.x + 14, top: hover.y - 8 }}>
{env.sats.find((s) => s.id === hover.id)?.name}
</div>
)}
</div>
<div className="tl-area">
<div className="tl-head">
<span className="tl-title">ТАЙМЛАЙН СОБЫТИЙ</span>
<div className="tl-legend">
<LegItem c="#d99a4e" t="СЪЁМКА" />
<LegItem c="#5b9bd5" t="СБРОС" />
<LegItem c="#d9685f" t="ЗАПРЕТ РАБОТЫ" hatch />
<LegItem c="rgba(120,140,180,0.5)" t="ТЕНЬ ЗЕМЛИ" />
</div>
<span className="tl-hint">{live ? "РЕЖИМ LIVE · ×1" : "СИМУЛЯЦИЯ ×" + speed} · перетащите для скраба</span>
</div>
<Timeline env={env} sched={sched} simTime={tNow} domain={domain} focusId={focusId}
onScrub={onScrub} onFocus={setFocus} live={live} />
</div>
</div>
</div>
</div>
);
}
function FocusCard({ sat, status, mem, onClose }) {
const meta = STATUS_META[status.state];
return (
<div className="focus-card">
<span className="fc-pay" style={{ background: sat.color }} />
<div className="fc-info">
<div className="fc-name">{sat.name}<button className="fc-x" onClick={onClose}></button></div>
<div className="fc-meta">
<span>NORAD {sat.norad}</span><span className="fc-dot">·</span><span>{sat.group}</span>
<span className="fc-dot">·</span><span style={{ color: meta.c }}>{status.eclipse ? "☾ " : ""}{meta.t}</span>
</div>
</div>
<div className="fc-pos">
<div>{Math.abs(status.p.lat).toFixed(2)}° {status.p.lat >= 0 ? "N" : "S"} · {Math.abs(status.p.lon).toFixed(2)}° {status.p.lon >= 0 ? "E" : "W"}</div>
<div className="fc-mem">ПАМЯТЬ <b>{Math.round(mem)}%</b></div>
</div>
</div>
);
}
function MapLegend() {
return (
<div className="map-legend">
<div className="ml-row"><span className="ml-tri" />СТАНЦИЯ ПРИЁМА</div>
<div className="ml-row"><span className="ml-sw" style={{ background: COL.optic }} />ОПТИКА</div>
<div className="ml-row"><span className="ml-sw" style={{ background: COL.radar }} />ЛОКАЦИЯ</div>
<div className="ml-row"><span className="ml-sw amber" />МАРШРУТ СЪЁМКИ</div>
<div className="ml-row"><span className="ml-sw blue dash" />СБРОС НА СТАНЦИЮ</div>
<div className="ml-row"><span className="ml-sw night" />ТЕНЬ ЗЕМЛИ</div>
</div>
);
}
function LegItem({ c, t, hatch }) {
return <span className="leg-item"><i className={"leg-sw" + (hatch ? " hatch" : "")} style={{ background: hatch ? undefined : c }} />{t}</span>;
}
export default App;
@@ -0,0 +1,331 @@
/* ============================================================
MapView.jsx canvas world map (ESM, default export)
Renders: land + graticule, ROIs, stations, night/terminator,
satellite sine tracks, footprints, capture swaths, downlinks.
Imperative draw(simTime, ui) called from the app RAF loop.
============================================================ */
import React, { useRef, useEffect } from "react";
import { geoEquirectangular, geoPath, geoGraticule, geoCircle } from "d3-geo";
import { feature } from "topojson-client";
import * as SIM from "./sim.js";
import { hexA } from "./util.js";
// keep the original call-sites (d3.* / topojson.*) working unchanged
const d3 = { geoEquirectangular, geoPath, geoGraticule, geoCircle };
const topojson = { feature };
const MAP_C = {
ocean: "#0a0e0f",
land: "#171c1e",
landStroke: "#252d30",
grat: "rgba(120,150,150,0.06)",
gratText: "rgba(150,180,178,0.25)",
amber: "#d99a4e",
blue: "#5b9bd5",
red: "#d9685f",
teal: "#5fb3a3",
night: "rgba(20,28,46,0.55)",
nightEdge: "rgba(90,120,170,0.18)",
station: "#cdd6d3",
};
function MapView({ env, apiRef }) {
const wrapRef = useRef(null);
const canvasRef = useRef(null);
const st = useRef({
w: 0, h: 0, dpr: 1, proj: null, path: null,
base: null, land: null, ready: false,
});
// ---- load world land (topojson via unpkg, graceful fallback) ----
useEffect(() => {
let alive = true;
fetch("https://unpkg.com/world-atlas@2.0.2/land-110m.json")
.then((r) => r.json())
.then((topo) => {
if (!alive) return;
st.current.land = topojson.feature(topo, topo.objects.land);
buildBase();
if (apiRef.current.onReady) apiRef.current.onReady();
})
.catch(() => { buildBase(); });
return () => { alive = false; };
}, []);
// ---- sizing ----
function resize() {
const wrap = wrapRef.current, cv = canvasRef.current;
if (!wrap || !cv) return;
const r = wrap.getBoundingClientRect();
const dpr = Math.min(window.devicePixelRatio || 1, 2);
st.current.w = r.width; st.current.h = r.height; st.current.dpr = dpr;
cv.width = r.width * dpr; cv.height = r.height * dpr;
cv.style.width = r.width + "px"; cv.style.height = r.height + "px";
const proj = d3.geoEquirectangular()
.scale(r.width / (2 * Math.PI))
.translate([r.width / 2, r.height / 2])
.rotate([-10, 0]);
st.current.proj = proj;
buildBase();
}
useEffect(() => {
resize();
const ro = new ResizeObserver(resize);
if (wrapRef.current) ro.observe(wrapRef.current);
return () => ro.disconnect();
}, []);
// ---- cached base layer (land, graticule, ROIs, stations) ----
function buildBase() {
const s = st.current;
if (!s.w || !s.proj) return;
const dpr = s.dpr;
const off = document.createElement("canvas");
off.width = s.w * dpr; off.height = s.h * dpr;
const ctx = off.getContext("2d");
ctx.scale(dpr, dpr);
const path = d3.geoPath(s.proj, ctx);
// ocean
ctx.fillStyle = MAP_C.ocean;
ctx.fillRect(0, 0, s.w, s.h);
// graticule
const grat = d3.geoGraticule().step([30, 30]);
ctx.beginPath(); path(grat());
ctx.strokeStyle = MAP_C.grat; ctx.lineWidth = 1; ctx.stroke();
// land
if (s.land) {
ctx.beginPath(); path(s.land);
ctx.fillStyle = MAP_C.land; ctx.fill();
ctx.strokeStyle = MAP_C.landStroke; ctx.lineWidth = 0.6; ctx.stroke();
}
// ROIs (заявки) faint amber dashed (planar path, avoids d3 winding-complement)
ctx.save();
ctx.lineWidth = 1; ctx.setLineDash([4, 3]);
for (const r of env.rois) {
tracePoly(ctx, s.proj, r.poly);
ctx.fillStyle = "rgba(217,154,78,0.05)"; ctx.fill();
ctx.strokeStyle = "rgba(217,154,78,0.5)"; ctx.stroke();
}
ctx.restore();
// station labels
ctx.font = "9px 'IBM Plex Mono', monospace";
ctx.textAlign = "left"; ctx.textBaseline = "middle";
for (const stn of env.stations) {
const pt = s.proj([stn.lon, stn.lat]); if (!pt) continue;
ctx.fillStyle = "rgba(205,214,211,0.55)";
ctx.fillText(stn.name.toUpperCase(), pt[0] + 8, pt[1] - 6);
}
s.base = off; s.path = path; s.ready = true;
}
// ---- helpers ----
function project(lon, lat) {
const p = st.current.proj && st.current.proj([lon, lat]);
return p;
}
// draw a great-circle-ish track polyline, breaking at antimeridian wrap
function strokeTrack(ctx, pts, color, width, alpha) {
const s = st.current, half = s.w * 0.5;
ctx.save(); ctx.globalAlpha = alpha; ctx.strokeStyle = color;
ctx.lineWidth = width; ctx.lineJoin = "round"; ctx.lineCap = "round";
ctx.beginPath();
let prev = null, started = false;
for (const ll of pts) {
const p = s.proj(ll); if (!p) { started = false; continue; }
if (started && prev && Math.abs(p[0] - prev[0]) > half) started = false;
if (!started) { ctx.moveTo(p[0], p[1]); started = true; }
else ctx.lineTo(p[0], p[1]);
prev = p;
}
ctx.stroke(); ctx.restore();
}
// ---- main per-frame draw ----
function draw(tMs, ui) {
const s = st.current, cv = canvasRef.current;
if (!cv || !s.proj) return;
const ctx = cv.getContext("2d");
ctx.setTransform(s.dpr, 0, 0, s.dpr, 0, 0);
ctx.clearRect(0, 0, s.w, s.h);
if (s.base) ctx.drawImage(s.base, 0, 0, s.w, s.h);
const path = d3.geoPath(s.proj, ctx);
const focus = ui.focusId;
// --- night overlay (тень Земли) ---
const ss = SIM.subSolar(tMs);
const anti = [SIM.wrapLon(ss.lon + 180), -ss.lat];
const nightPoly = d3.geoCircle().center(anti).radius(90)();
ctx.beginPath(); path(nightPoly);
ctx.fillStyle = MAP_C.night; ctx.fill();
ctx.lineWidth = 1; ctx.strokeStyle = MAP_C.nightEdge; ctx.stroke();
// --- per-sat dynamic ---
const dash = (tMs / 30) % 16;
const sats = env.sats;
// tracks first (under glyphs)
for (const sat of sats) {
const dim = focus && focus !== sat.id;
const pts = [];
for (let dm = -52; dm <= 52; dm += 1.6) {
pts.push([0, 0]); // placeholder replaced below
}
// build actual track points
pts.length = 0;
for (let dm = -52; dm <= 52; dm += 1.6) {
const p = SIM.subSat(sat, tMs + dm * 60000, env.epochMs);
pts.push([p.lon, p.lat]);
}
strokeTrack(ctx, pts, sat.color, dim ? 0.8 : 1.4, dim ? 0.10 : 0.30);
}
// events + glyphs
for (const sat of sats) {
const dim = focus && focus !== sat.id;
const stt = SIM.statusAt(sat, tMs, env);
const p = stt.p;
const pt = s.proj([p.lon, p.lat]); if (!pt) continue;
const baseA = dim ? 0.25 : 1;
// footprint circle (swath of view)
const fp = d3.geoCircle().center([p.lon, p.lat]).radius(sat.payload === "radar" ? 6.5 : 8)();
ctx.beginPath(); path(fp);
ctx.fillStyle = hexA(sat.color, dim ? 0.03 : 0.055); ctx.fill();
ctx.strokeStyle = hexA(sat.color, dim ? 0.08 : 0.16); ctx.lineWidth = 1; ctx.stroke();
// capture (маршрут съёмки) amber swath along track + ROI highlight
if (stt.state === "capture") {
const seg = [];
for (let dm = -7; dm <= 7; dm += 1) {
const q = SIM.subSat(sat, tMs + dm * 60000, env.epochMs);
seg.push([q.lon, q.lat]);
}
strokeTrack(ctx, seg, MAP_C.amber, dim ? 3 : 6, dim ? 0.3 : 0.85);
ctx.setLineDash([]);
tracePoly(ctx, s.proj, stt.roi.poly);
ctx.fillStyle = "rgba(217,154,78,0.16)"; ctx.fill();
ctx.strokeStyle = MAP_C.amber; ctx.lineWidth = 1.4; ctx.stroke();
}
// downlink (сброс) animated dashed line to station
if (stt.state === "downlink") {
const sp = s.proj([stt.station.lon, stt.station.lat]);
if (sp) {
ctx.save();
ctx.setLineDash([5, 5]); ctx.lineDashOffset = -dash;
ctx.strokeStyle = hexA(MAP_C.blue, dim ? 0.4 : 0.95); ctx.lineWidth = 1.6;
ctx.beginPath(); ctx.moveTo(pt[0], pt[1]); ctx.lineTo(sp[0], sp[1]); ctx.stroke();
ctx.restore();
// station glow
ctx.beginPath(); ctx.arc(sp[0], sp[1], 7, 0, 7);
ctx.fillStyle = hexA(MAP_C.blue, 0.18); ctx.fill();
}
}
// stations as triangles (drawn here so glow sits under)
// (drawn once globally below to avoid repaint per sat) -> skip
// sat glyph
drawSatGlyph(ctx, pt[0], pt[1], sat, stt, baseA, sat.id === focus);
// label
if (!dim || sat.id === focus) {
ctx.font = "10px 'IBM Plex Mono', monospace";
ctx.textAlign = "left"; ctx.textBaseline = "middle";
ctx.fillStyle = hexA("#e6ebe9", baseA);
ctx.fillText(sat.name, pt[0] + 11, pt[1] + 0.5);
}
}
// stations triangles on top
for (const stn of env.stations) {
const sp = s.proj([stn.lon, stn.lat]); if (!sp) continue;
drawTriangle(ctx, sp[0], sp[1], 5, MAP_C.station);
}
}
function drawSatGlyph(ctx, x, y, sat, stt, alpha, isFocus) {
const ringColor = stt.state === "capture" ? MAP_C.amber
: stt.state === "downlink" ? MAP_C.blue
: stt.state === "prohibit" ? MAP_C.red : null;
// halo
ctx.beginPath(); ctx.arc(x, y, 9, 0, 7);
ctx.fillStyle = hexA(sat.color, 0.10 * alpha); ctx.fill();
// ring
if (ringColor) {
ctx.beginPath(); ctx.arc(x, y, 7.5, 0, 7);
ctx.strokeStyle = hexA(ringColor, alpha); ctx.lineWidth = 1.6; ctx.setLineDash([]); ctx.stroke();
}
if (isFocus) {
ctx.beginPath(); ctx.arc(x, y, 11, 0, 7);
ctx.strokeStyle = hexA(MAP_C.teal, 0.9); ctx.lineWidth = 1; ctx.stroke();
}
// diamond body
ctx.save(); ctx.translate(x, y); ctx.rotate(Math.PI / 4);
const r = 3.4;
ctx.fillStyle = stt.eclipse ? hexA(sat.color, 0.5 * alpha) : hexA(sat.color, alpha);
ctx.strokeStyle = hexA("#05080a", alpha); ctx.lineWidth = 1;
ctx.beginPath(); ctx.rect(-r, -r, r * 2, r * 2); ctx.fill(); ctx.stroke();
ctx.restore();
}
function drawTriangle(ctx, x, y, r, color) {
ctx.beginPath();
ctx.moveTo(x, y - r); ctx.lineTo(x + r, y + r * 0.8); ctx.lineTo(x - r, y + r * 0.8);
ctx.closePath();
ctx.fillStyle = hexA(color, 0.85); ctx.fill();
ctx.strokeStyle = "rgba(10,14,16,0.9)"; ctx.lineWidth = 0.8; ctx.stroke();
}
// pointer lon/lat + nearest sat
function onMove(e) {
const s = st.current, r = canvasRef.current.getBoundingClientRect();
const x = e.clientX - r.left, y = e.clientY - r.top;
const ll = s.proj && s.proj.invert([x, y]);
if (apiRef.current.onCursor) apiRef.current.onCursor(ll ? { lon: ll[0], lat: ll[1] } : null);
// hit test sats handled by app via getHover; expose nearest
if (apiRef.current.onPick) {
let best = null, bd = 16;
for (const sat of env.sats) {
const p = SIM.subSat(sat, apiRef.current.simTime(), env.epochMs);
const pp = s.proj([p.lon, p.lat]); if (!pp) continue;
const d = Math.hypot(pp[0] - x, pp[1] - y);
if (d < bd) { bd = d; best = sat.id; }
}
apiRef.current.onPick(best, x, y);
}
}
function onClick() {
if (apiRef.current.onClickPick) apiRef.current.onClickPick();
}
// expose imperative API
useEffect(() => {
apiRef.current.draw = draw;
apiRef.current.project = project;
});
return (
<div className="map-wrap" ref={wrapRef}>
<canvas ref={canvasRef} onMouseMove={onMove} onMouseLeave={() => apiRef.current.onCursor && apiRef.current.onCursor(null)} onClick={onClick} />
</div>
);
}
// trace a small lon/lat polygon as a planar projected path (no antimeridian crossing)
function tracePoly(ctx, proj, poly) {
ctx.beginPath();
poly.forEach((pt, i) => {
const p = proj(pt); if (!p) return;
if (i === 0) ctx.moveTo(p[0], p[1]); else ctx.lineTo(p[0], p[1]);
});
ctx.closePath();
}
export default MapView;
@@ -0,0 +1,180 @@
/* ============================================================
Panels.jsx header + left panel (ESM)
Exports: Header, SidePanel, STATUS_META, fmtDur.
============================================================ */
import React from "react";
import { COL } from "./data.js";
const STATUS_META = {
operational: { c: "#5cae7e", t: "НА ОРБИТЕ" },
capture: { c: "#d99a4e", t: "СЪЁМКА" },
downlink: { c: "#5b9bd5", t: "СБРОС" },
prohibit: { c: "#d9685f", t: "ЗАПРЕТ" },
};
function fmtClock(ms) {
const d = new Date(ms);
const p = (n) => String(n).padStart(2, "0");
return {
time: `${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())}`,
date: `${p(d.getUTCDate())}.${p(d.getUTCMonth() + 1)}.${d.getUTCFullYear()}`,
dow: ["ВС", "ПН", "ВТ", "СР", "ЧТ", "ПТ", "СБ"][d.getUTCDay()],
};
}
function fmtDur(ms) {
const s = Math.max(0, Math.round(ms / 1000));
const m = Math.floor(s / 60), ss = s % 60;
return `${m}:${String(ss).padStart(2, "0")}`;
}
function Header({ simTime, playing, speed, live, onPlay, onSpeed, onNow, onStep }) {
const ck = fmtClock(simTime);
const speeds = [1, 10, 60, 600];
return (
<header className="hdr">
<div className="hdr-brand">
<span className="brand-mark" />
<div className="brand-txt">
<div className="brand-1">СОСТОЯНИЕ ГРУППИРОВКИ</div>
<div className="brand-2">MISSION OPS · ТГУ</div>
</div>
</div>
<div className="hdr-transport">
<button className="t-btn" onClick={() => onStep(-3600e3)} title="1 час">1ч</button>
<button className={"t-btn t-play" + (playing ? " on" : "")} onClick={onPlay}>
{playing ? "❚❚" : "▶"}
</button>
<button className="t-btn" onClick={() => onStep(3600e3)} title="+1 час">+1ч</button>
<div className="t-sep" />
<div className="speed-seg">
{speeds.map((s) => (
<button key={s} className={"sp" + (speed === s ? " on" : "")} onClick={() => onSpeed(s)}>×{s}</button>
))}
</div>
<button className={"t-btn t-now" + (live ? " live" : "")} onClick={onNow}>СЕЙЧАС</button>
</div>
<div className="hdr-clock">
<div className={"live-badge" + (live ? " on" : "")}>
<span className="live-dot" />{live ? "LIVE" : "СИМ"}
</div>
<div className="clock-main">
<span className="clock-time">{ck.time}</span>
<span className="clock-utc">UTC</span>
</div>
<div className="clock-date">{ck.dow} · {ck.date}</div>
</div>
</header>
);
}
function SummaryTiles({ counts }) {
const tiles = [
{ k: "capture", label: "СНИМАЮТ", n: counts.capture, c: STATUS_META.capture.c },
{ k: "downlink", label: "СБРОС", n: counts.downlink, c: STATUS_META.downlink.c },
{ k: "prohibit", label: "ЗАПРЕТ", n: counts.prohibit, c: STATUS_META.prohibit.c },
{ k: "eclipse", label: "В ТЕНИ", n: counts.eclipse, c: "#7d92c4" },
];
return (
<div className="summary">
<div className="summary-tiles">
{tiles.map((t) => (
<div key={t.k} className="s-tile">
<div className="s-num" style={{ color: t.c }}>{t.n}</div>
<div className="s-lbl">{t.label}</div>
</div>
))}
</div>
<div className="summary-foot">
<span><i className="dot" style={{ background: COL.optic }} />ОПТИКА {counts.optic}</span>
<span><i className="dot" style={{ background: COL.radar }} />ЛОКАЦИЯ {counts.radar}</span>
<span className="muted">{counts.total} КА</span>
</div>
</div>
);
}
function SatRow({ sat, status, mem, focus, onClick }) {
const meta = STATUS_META[status.state];
const memColor = mem > 92 ? "#d9685f" : mem > 78 ? "#d99a4e" : "#5fb3a3";
return (
<div className={"sat-row" + (focus ? " focus" : "")} onClick={onClick}>
<span className="sr-payload" style={{ background: sat.color }} />
<div className="sr-main">
<div className="sr-top">
<span className="sr-name">{sat.name}</span>
<span className="sr-status" style={{ color: meta.c }}>
{status.eclipse && <span className="ecl"></span>}{meta.t}
</span>
</div>
<div className="sr-bot">
<span className="sr-norad">NORAD {sat.norad}</span>
<div className="mem-bar"><span style={{ width: mem + "%", background: memColor }} /></div>
<span className="sr-mem" style={{ color: memColor }}>{Math.round(mem)}%</span>
</div>
</div>
</div>
);
}
function Roster({ sats, statuses, memory, focusId, onFocus, query }) {
const q = query.trim().toLowerCase();
const filtered = sats.filter((s) =>
!q || s.name.toLowerCase().includes(q) || String(s.norad).includes(q) || s.group.toLowerCase().includes(q));
const groups = [];
let curD = null, curG = null;
filtered.forEach((s) => {
if (s.domain !== curD) { groups.push({ type: "domain", label: s.domain, color: s.color }); curD = s.domain; curG = null; }
if (s.group !== curG) { groups.push({ type: "group", label: s.group }); curG = s.group; }
groups.push({ type: "sat", sat: s });
});
return (
<div className="roster">
{groups.map((g, i) => {
if (g.type === "domain") return <div key={i} className="r-domain"><i className="dot" style={{ background: g.color }} />{g.label}</div>;
if (g.type === "group") return <div key={i} className="r-group">{g.label}</div>;
const s = g.sat;
return <SatRow key={s.id} sat={s} status={statuses[s.id]} mem={memory[s.id]}
focus={focusId === s.id} onClick={() => onFocus(focusId === s.id ? null : s.id)} />;
})}
</div>
);
}
function ActivityFeed({ events, onFocus, focusId }) {
return (
<div className="activity">
<div className="sec-label">АКТИВНЫЕ СОБЫТИЯ <span className="count">{events.length}</span></div>
<div className="act-list">
{events.length === 0 && <div className="act-empty">нет активных событий</div>}
{events.map((e) => (
<div key={e.id} className={"act-row" + (focusId === e.satId ? " focus" : "")} onClick={() => onFocus(e.satId)}>
<span className="act-bar" style={{ background: e.color }} />
<div className="act-body">
<div className="act-top"><span className="act-type" style={{ color: e.color }}>{e.type}</span><span className="act-tgt">{e.target}</span></div>
<div className="act-sub">{e.sat}<span className="act-rem">ещё {e.remain}</span></div>
</div>
</div>
))}
</div>
</div>
);
}
function SidePanel({ sats, statuses, memory, counts, events, focusId, onFocus, query, setQuery }) {
return (
<aside className="side">
<div className="sec-label">СВОДКА ГРУППИРОВКИ</div>
<SummaryTiles counts={counts} />
<div className="side-search">
<input placeholder="Поиск name / NORAD / группа" value={query} onChange={(e) => setQuery(e.target.value)} />
</div>
<div className="sec-label tight">КОСМИЧЕСКИЕ АППАРАТЫ</div>
<Roster sats={sats} statuses={statuses} memory={memory} focusId={focusId} onFocus={onFocus} query={query} />
<ActivityFeed events={events} onFocus={onFocus} focusId={focusId} />
</aside>
);
}
export { Header, SidePanel, STATUS_META, fmtDur };
@@ -0,0 +1,160 @@
/* ============================================================
Timeline.jsx bottom event timeline (ESM, default export)
Per-sat rows with capture / downlink / prohibit / shadow bands,
moving playhead, real-"now" marker, drag-to-scrub.
============================================================ */
import React, { useRef as useRefTL, useEffect as useEffectTL, useState as useStateTL } from "react";
const TL_SPAN = 12 * 3600e3; // visible window
const TL_LEAD = 0.32; // playhead position fraction
const TL_GUTTER = 188;
const TL_ROW = 22;
const TL_AXIS = 26;
const TL_COL = {
capture: "#d99a4e",
downlink: "#5b9bd5",
prohibit: "#d9685f",
shadow: "rgba(120,140,180,0.30)",
};
function Timeline({ env, sched, simTime, domain, focusId, onScrub, onFocus, live }) {
const wrapRef = useRefTL(null);
const [w, setW] = useStateTL(1200);
const dragRef = useRefTL(false);
useEffectTL(() => {
const ro = new ResizeObserver((es) => setW(es[0].contentRect.width));
if (wrapRef.current) ro.observe(wrapRef.current);
return () => ro.disconnect();
}, []);
const areaW = Math.max(200, w - TL_GUTTER);
let viewStart = simTime - TL_LEAD * TL_SPAN;
viewStart = Math.max(domain.start, Math.min(viewStart, domain.end - TL_SPAN));
const viewEnd = viewStart + TL_SPAN;
const X = (t) => TL_GUTTER + ((t - viewStart) / TL_SPAN) * areaW;
const clampT = (t) => Math.max(viewStart, Math.min(viewEnd, t));
const sats = env.sats;
const rows = [];
let y = TL_AXIS;
let lastDomain = null;
sats.forEach((s) => {
if (s.domain !== lastDomain) { rows.push({ type: "group", label: s.domain, y }); y += 16; lastDomain = s.domain; }
rows.push({ type: "sat", sat: s, y });
y += TL_ROW;
});
const totalH = y + 6;
// axis ticks (hourly)
const ticks = [];
const hour = 3600e3;
let t0 = Math.ceil(viewStart / hour) * hour;
for (let t = t0; t <= viewEnd; t += hour) ticks.push(t);
function ptFromEvent(e) {
const r = wrapRef.current.getBoundingClientRect();
const x = e.clientX - r.left;
const frac = (x - TL_GUTTER) / areaW;
return viewStart + frac * TL_SPAN;
}
function down(e) {
if (e.clientX - wrapRef.current.getBoundingClientRect().left < TL_GUTTER) return;
dragRef.current = true; onScrub(ptFromEvent(e));
}
function move(e) { if (dragRef.current) onScrub(ptFromEvent(e)); }
function up() { dragRef.current = false; }
useEffectTL(() => {
window.addEventListener("mousemove", move);
window.addEventListener("mouseup", up);
return () => { window.removeEventListener("mousemove", move); window.removeEventListener("mouseup", up); };
});
const nowX = X(Date.now());
const headX = X(simTime);
return (
<div className="tl-wrap" ref={wrapRef} onMouseDown={down}>
<svg width={w} height={totalH} style={{ display: "block" }}>
<defs>
<pattern id="hatch" width="6" height="6" patternTransform="rotate(45)" patternUnits="userSpaceOnUse">
<rect width="6" height="6" fill="rgba(217,104,95,0.12)" />
<line x1="0" y1="0" x2="0" y2="6" stroke="rgba(217,104,95,0.6)" strokeWidth="2" />
</pattern>
</defs>
{/* axis */}
<line x1={TL_GUTTER} y1={TL_AXIS} x2={w} y2={TL_AXIS} stroke="rgba(255,255,255,0.08)" />
{ticks.map((t, i) => {
const x = X(t); const d = new Date(t);
const hh = String(d.getUTCHours()).padStart(2, "0");
const isDay = d.getUTCHours() === 0;
return (
<g key={i}>
<line x1={x} y1={TL_AXIS - 5} x2={x} y2={totalH} stroke="rgba(255,255,255,0.035)" />
<text x={x + 3} y={TL_AXIS - 9} className="tl-tick">
{isDay ? `${String(d.getUTCDate()).padStart(2, "0")}.${String(d.getUTCMonth() + 1).padStart(2, "0")}` : `${hh}:00`}
</text>
</g>
);
})}
{/* rows */}
{rows.map((r, i) => {
if (r.type === "group") {
return <text key={i} x={10} y={r.y + 11} className="tl-group">{r.label}</text>;
}
const s = r.sat;
const tr = sched[s.id] || {};
const dim = focusId && focusId !== s.id;
const cy = r.y + TL_ROW / 2;
const band = (iv, key, fill, h) => {
const x1 = X(Math.max(iv.start, viewStart)), x2 = X(Math.min(iv.end, viewEnd));
if (x2 <= TL_GUTTER || x1 >= w || x2 - x1 < 0.6) return null;
return <rect key={key} x={x1} y={cy - h / 2} width={Math.max(1.5, x2 - x1)} height={h}
fill={fill} rx="1" opacity={dim ? 0.3 : 1} />;
};
return (
<g key={i} onClick={() => onFocus(focusId === s.id ? null : s.id)} style={{ cursor: "pointer" }}>
<rect x={0} y={r.y} width={w} height={TL_ROW} fill={focusId === s.id ? "rgba(95,179,163,0.06)" : "transparent"} />
<line x1={TL_GUTTER} y1={r.y + TL_ROW} x2={w} y2={r.y + TL_ROW} stroke="rgba(255,255,255,0.025)" />
<circle cx={14} cy={cy} r={3} fill={s.color} opacity={dim ? 0.4 : 1} />
<text x={24} y={cy + 3.5} className="tl-name" opacity={dim ? 0.45 : 1}>{s.name}</text>
{/* baseline */}
<line x1={TL_GUTTER} y1={cy} x2={w} y2={cy} stroke="rgba(255,255,255,0.04)" />
{/* shadow (under) */}
{(tr.shadow || []).map((iv, k) => band(iv, "sh" + k, TL_COL.shadow, TL_ROW - 8))}
{/* downlink */}
{(tr.downlink || []).map((iv, k) => band(iv, "dl" + k, TL_COL.downlink, 8))}
{/* capture */}
{(tr.capture || []).map((iv, k) => band(iv, "cp" + k, TL_COL.capture, 10))}
{/* prohibit (hatched, full height) */}
{(tr.prohibit || []).map((iv, k) => {
const x1 = X(Math.max(iv.start, viewStart)), x2 = X(Math.min(iv.end, viewEnd));
if (x2 <= TL_GUTTER || x1 >= w || x2 - x1 < 0.6) return null;
return <rect key={"pr" + k} x={x1} y={r.y + 2} width={Math.max(2, x2 - x1)} height={TL_ROW - 4}
fill="url(#hatch)" stroke="rgba(217,104,95,0.5)" strokeWidth="0.8" opacity={dim ? 0.4 : 1} />;
})}
</g>
);
})}
{/* now marker (real wall clock) */}
{nowX > TL_GUTTER && nowX < w && (
<g>
<line x1={nowX} y1={TL_AXIS - 5} x2={nowX} y2={totalH} stroke="rgba(95,179,163,0.35)" strokeDasharray="2 3" />
<text x={nowX + 3} y={totalH - 3} className="tl-nowlbl">СЕЙЧАС</text>
</g>
)}
{/* playhead */}
<g>
<line x1={headX} y1={TL_AXIS - 6} x2={headX} y2={totalH} stroke={live ? "#5fb3a3" : "#d99a4e"} strokeWidth="1.5" />
<polygon points={`${headX - 5},${TL_AXIS - 6} ${headX + 5},${TL_AXIS - 6} ${headX},${TL_AXIS + 1}`} fill={live ? "#5fb3a3" : "#d99a4e"} />
</g>
</svg>
</div>
);
}
export default Timeline;
@@ -0,0 +1,183 @@
/* Fonts — remove this @import if your app already loads IBM Plex */
@import url("https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap");
:root {
--bg0:#0a0c0b; --bg1:#0e1211; --panel:#111514; --ins:#0a0e0d;
--line:rgba(255,255,255,0.06); --line2:rgba(255,255,255,0.11);
--hi:#e7ece9; --mid:#95a19d; --dim:#5b6662; --faint:#3c4644;
--teal:#5fb3a3; --teal-bg:rgba(95,179,163,0.13); --teal-br:rgba(95,179,163,0.35);
--amber:#d99a4e; --blue:#5b9bd5; --red:#d9685f; --green:#5cae7e; --violet:#a78bdb;
}
*{box-sizing:border-box;}
html,body{height:100%;margin:0;}
body{
background:var(--bg0); color:var(--hi);
font-family:'IBM Plex Sans',system-ui,sans-serif;
font-size:13px; -webkit-font-smoothing:antialiased;
overflow:hidden;
}
.mono,.clock-time,.sr-norad,.map-cursor,.tl-tick,.fc-meta{font-family:'IBM Plex Mono',monospace;}
button{font-family:inherit; cursor:pointer; color:inherit;}
::-webkit-scrollbar{width:8px;height:8px;}
::-webkit-scrollbar-thumb{background:rgba(255,255,255,0.07);border-radius:4px;}
::-webkit-scrollbar-track{background:transparent;}
.app{height:100vh; display:flex; flex-direction:column; overflow:hidden;}
/* ---------- header ---------- */
.hdr{
height:54px; flex:none; display:flex; align-items:center;
border-bottom:1px solid var(--line); background:linear-gradient(var(--bg1),var(--bg0));
padding:0 16px; gap:18px; position:relative; z-index:5;
}
.hdr-brand{display:flex; align-items:center; gap:11px; min-width:248px;}
.brand-mark{width:20px;height:20px;border-radius:50%;border:1.5px solid var(--teal);
box-shadow:0 0 10px rgba(95,179,163,0.4) inset, 0 0 6px rgba(95,179,163,0.3);}
.brand-1{font-weight:600; font-size:13px; letter-spacing:.04em; white-space:nowrap;}
.brand-2{font-family:'IBM Plex Mono',monospace; font-size:9px; color:var(--dim); letter-spacing:.12em; margin-top:1px;}
.hdr-transport{flex:1; display:flex; align-items:center; justify-content:center; gap:7px;}
.t-btn{background:var(--bg1); border:1px solid var(--line2); color:var(--mid);
height:30px; min-width:34px; padding:0 11px; border-radius:6px; font-size:11.5px; letter-spacing:.04em;
transition:.12s;}
.t-btn:hover{border-color:var(--teal-br); color:var(--hi);}
.t-play{font-size:12px; color:var(--teal); border-color:var(--teal-br); background:var(--teal-bg); width:42px;}
.t-play.on{background:rgba(95,179,163,0.2);}
.t-now{font-size:10.5px; letter-spacing:.1em;}
.t-now.live{background:rgba(92,174,126,0.16); border-color:rgba(92,174,126,0.5); color:var(--green);}
.t-sep{width:1px; height:20px; background:var(--line2); margin:0 4px;}
.speed-seg{display:flex; border:1px solid var(--line2); border-radius:6px; overflow:hidden; background:var(--bg1);}
.speed-seg .sp{background:transparent; border:0; color:var(--dim); height:28px; padding:0 11px; font-size:11px;
font-family:'IBM Plex Mono',monospace; border-right:1px solid var(--line); transition:.12s;}
.speed-seg .sp:last-child{border-right:0;}
.speed-seg .sp:hover{color:var(--mid);}
.speed-seg .sp.on{background:var(--teal-bg); color:var(--teal);}
.hdr-clock{display:flex; flex-direction:column; align-items:flex-end; min-width:240px; gap:1px;}
.live-badge{display:flex; align-items:center; gap:5px; font-size:9.5px; letter-spacing:.16em; color:var(--amber);
font-family:'IBM Plex Mono',monospace;}
.live-badge .live-dot{width:6px;height:6px;border-radius:50%;background:var(--amber);}
.live-badge.on{color:var(--green);}
.live-badge.on .live-dot{background:var(--green); box-shadow:0 0 6px var(--green); animation:pulse 1.4s infinite;}
@keyframes pulse{0%,100%{opacity:1;}50%{opacity:.35;}}
.clock-main{display:flex; align-items:baseline; gap:6px;}
.clock-time{font-size:22px; font-weight:500; letter-spacing:.02em; line-height:1;}
.clock-utc{font-size:9px; color:var(--dim); letter-spacing:.1em;}
.clock-date{font-size:10px; color:var(--mid); font-family:'IBM Plex Mono',monospace; letter-spacing:.06em;}
/* ---------- body ---------- */
.body{flex:1; display:flex; min-height:0;}
.side{width:328px; flex:none; border-right:1px solid var(--line); background:var(--bg1);
display:flex; flex-direction:column; overflow:hidden;}
.main{flex:1; display:flex; flex-direction:column; min-width:0;}
.sec-label{font-family:'IBM Plex Mono',monospace; font-size:9.5px; letter-spacing:.16em; color:var(--dim);
padding:13px 16px 8px; display:flex; align-items:center; gap:8px;}
.sec-label.tight{padding-top:6px;}
.sec-label .count{background:var(--bg0); border:1px solid var(--line2); border-radius:8px; padding:0 6px;
font-size:9px; color:var(--mid);}
/* summary */
.summary{padding:0 14px 6px;}
.summary-tiles{display:grid; grid-template-columns:repeat(4,1fr); gap:6px;}
.s-tile{background:var(--ins); border:1px solid var(--line); border-radius:7px; padding:9px 4px 7px; text-align:center;}
.s-num{font-family:'IBM Plex Mono',monospace; font-size:21px; font-weight:600; line-height:1;}
.s-lbl{font-size:8px; color:var(--dim); letter-spacing:.1em; margin-top:5px;}
.summary-foot{display:flex; align-items:center; gap:13px; padding:9px 2px 2px; font-size:10px;
color:var(--mid); font-family:'IBM Plex Mono',monospace;}
.summary-foot .dot,.r-domain .dot{width:7px;height:7px;border-radius:2px;display:inline-block;margin-right:5px;vertical-align:middle;}
.summary-foot .muted{margin-left:auto; color:var(--dim);}
/* search */
.side-search{padding:4px 14px 2px;}
.side-search input{width:100%; background:var(--ins); border:1px solid var(--line); border-radius:6px;
color:var(--hi); padding:7px 10px; font-size:11.5px; outline:none; font-family:'IBM Plex Mono',monospace;}
.side-search input::placeholder{color:var(--faint);}
.side-search input:focus{border-color:var(--teal-br);}
/* roster */
.roster{flex:1; min-height:0; overflow-y:auto; padding:0 8px 8px;}
.r-domain{font-family:'IBM Plex Mono',monospace; font-size:10px; letter-spacing:.14em; color:var(--mid);
padding:11px 8px 5px; display:flex; align-items:center;}
.r-group{font-size:9.5px; color:var(--faint); letter-spacing:.08em; padding:3px 8px 3px 20px; text-transform:uppercase;}
.sat-row{display:flex; gap:9px; padding:7px 8px 7px 10px; border-radius:7px; cursor:pointer;
align-items:center; transition:.1s; border:1px solid transparent;}
.sat-row:hover{background:rgba(255,255,255,0.025);}
.sat-row.focus{background:var(--teal-bg); border-color:var(--teal-br);}
.sr-payload{width:3px; align-self:stretch; border-radius:2px; flex:none;}
.sr-main{flex:1; min-width:0;}
.sr-top{display:flex; justify-content:space-between; align-items:baseline; gap:8px;}
.sr-name{font-size:12.5px; font-weight:500; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;}
.sr-status{font-size:9px; letter-spacing:.08em; font-family:'IBM Plex Mono',monospace; flex:none; display:flex; align-items:center; gap:3px;}
.sr-status .ecl{opacity:.85;}
.sr-bot{display:flex; align-items:center; gap:8px; margin-top:4px;}
.sr-norad{font-size:9px; color:var(--dim); flex:none;}
.mem-bar{flex:1; height:4px; background:rgba(255,255,255,0.07); border-radius:2px; overflow:hidden;}
.mem-bar span{display:block; height:100%; border-radius:2px; transition:width .2s;}
.sr-mem{font-size:9.5px; font-family:'IBM Plex Mono',monospace; flex:none; width:30px; text-align:right;}
/* activity */
.activity{flex:none; max-height:34%; display:flex; flex-direction:column; border-top:1px solid var(--line); overflow:hidden;}
.act-list{overflow-y:auto; padding:0 8px 8px;}
.act-empty{color:var(--faint); font-size:11px; padding:6px 10px; font-family:'IBM Plex Mono',monospace;}
.act-row{display:flex; gap:8px; padding:6px 8px; border-radius:6px; cursor:pointer; align-items:center;}
.act-row:hover{background:rgba(255,255,255,0.025);}
.act-row.focus{background:var(--teal-bg);}
.act-bar{width:3px; align-self:stretch; border-radius:2px; flex:none;}
.act-body{flex:1; min-width:0;}
.act-top{display:flex; gap:7px; align-items:baseline;}
.act-type{font-size:9.5px; letter-spacing:.1em; font-family:'IBM Plex Mono',monospace;}
.act-tgt{font-size:11px; color:var(--hi); white-space:nowrap; overflow:hidden; text-overflow:ellipsis;}
.act-sub{display:flex; justify-content:space-between; font-size:9.5px; color:var(--dim); margin-top:2px; font-family:'IBM Plex Mono',monospace;}
.act-rem{color:var(--mid);}
/* ---------- map ---------- */
.map-area{flex:1; position:relative; min-height:0; overflow:hidden; background:var(--ins);}
.map-wrap{position:absolute; inset:0;}
.map-wrap canvas{display:block;}
.map-caption{position:absolute; top:12px; left:12px; z-index:3;}
.cap-hint{font-family:'IBM Plex Mono',monospace; font-size:9.5px; letter-spacing:.12em; color:var(--faint);
background:rgba(10,14,13,0.6); border:1px solid var(--line); padding:6px 10px; border-radius:6px; white-space:nowrap;}
.focus-card{display:flex; align-items:stretch; gap:11px; background:rgba(11,16,15,0.92); backdrop-filter:blur(6px);
border:1px solid var(--teal-br); border-radius:8px; padding:10px 13px; min-width:330px;}
.fc-pay{width:3px; border-radius:2px; flex:none;}
.fc-info{flex:1;}
.fc-name{font-size:14px; font-weight:600; display:flex; justify-content:space-between; align-items:center;}
.fc-x{background:none; border:0; color:var(--dim); font-size:12px; padding:0 2px;}
.fc-x:hover{color:var(--hi);}
.fc-meta{font-size:9.5px; color:var(--mid); margin-top:4px; display:flex; gap:6px; align-items:center; letter-spacing:.04em;}
.fc-dot{color:var(--faint);}
.fc-pos{text-align:right; font-family:'IBM Plex Mono',monospace; font-size:10px; color:var(--mid); display:flex; flex-direction:column; justify-content:center; gap:4px;}
.fc-mem b{color:var(--hi);}
.map-legend{position:absolute; bottom:12px; left:12px; z-index:3; background:rgba(10,14,13,0.55);
border:1px solid var(--line); border-radius:7px; padding:8px 11px; display:flex; flex-direction:column; gap:5px;}
.ml-row{display:flex; align-items:center; gap:8px; font-family:'IBM Plex Mono',monospace; font-size:9px;
letter-spacing:.08em; color:var(--mid); white-space:nowrap;}
.ml-tri{width:0;height:0;border-left:4px solid transparent;border-right:4px solid transparent;border-bottom:7px solid #cdd6d3;}
.ml-sw{width:14px; height:3px; border-radius:2px; background:var(--teal);}
.ml-sw.amber{background:var(--amber);}
.ml-sw.blue{background:var(--blue);}
.ml-sw.blue.dash{background:repeating-linear-gradient(90deg,var(--blue) 0 4px,transparent 4px 7px);}
.ml-sw.night{background:rgba(90,120,170,0.5); height:9px; width:14px; border-radius:2px;}
.map-cursor{position:absolute; bottom:10px; right:14px; z-index:3; font-family:'IBM Plex Mono',monospace;
font-size:10px; color:var(--mid); background:rgba(10,14,13,0.6); padding:3px 8px; border-radius:5px; border:1px solid var(--line);}
.map-tip{position:absolute; z-index:4; pointer-events:none; font-family:'IBM Plex Mono',monospace; font-size:10px;
background:rgba(11,16,15,0.95); border:1px solid var(--line2); padding:3px 7px; border-radius:5px; color:var(--hi); white-space:nowrap;}
/* ---------- timeline ---------- */
.tl-area{height:248px; flex:none; border-top:1px solid var(--line); background:var(--bg1); display:flex; flex-direction:column;}
.tl-head{height:34px; flex:none; display:flex; align-items:center; gap:18px; padding:0 16px; border-bottom:1px solid var(--line);}
.tl-title{font-family:'IBM Plex Mono',monospace; font-size:10px; letter-spacing:.16em; color:var(--mid);}
.tl-legend{display:flex; gap:15px; flex:1;}
.leg-item{display:flex; align-items:center; gap:6px; font-family:'IBM Plex Mono',monospace; font-size:9px;
letter-spacing:.08em; color:var(--dim);}
.leg-sw{width:13px; height:8px; border-radius:2px; display:inline-block;}
.leg-sw.hatch{background:repeating-linear-gradient(45deg,rgba(217,104,95,0.7) 0 2px,rgba(217,104,95,0.12) 2px 5px); border:1px solid rgba(217,104,95,0.5);}
.tl-hint{font-family:'IBM Plex Mono',monospace; font-size:9.5px; color:var(--faint); letter-spacing:.06em;}
.tl-wrap{flex:1; overflow-y:auto; overflow-x:hidden; user-select:none; cursor:crosshair;}
.tl-tick{fill:var(--dim); font-size:9px; font-family:'IBM Plex Mono',monospace;}
.tl-group{fill:var(--faint); font-size:9px; letter-spacing:.14em; font-family:'IBM Plex Mono',monospace;}
.tl-name{fill:var(--mid); font-size:10.5px;}
.tl-nowlbl{fill:rgba(95,179,163,0.6); font-size:8px; letter-spacing:.12em; font-family:'IBM Plex Mono',monospace;}
@@ -0,0 +1,73 @@
/* ============================================================
data.js constellation model (ESM)
Группировка: ОПТИКА + ЛОКАЦИЯ, с группами КА.
См. INTEGRATION.md эти массивы предполагается заменить данными из API.
============================================================ */
export const COL = {
optic: "#56c2c8", // оптика — cyan
radar: "#a78bdb", // локация (РСА) — violet
};
// --- Satellites -------------------------------------------------
// orbit: latAmp(deg amplitude of sub-sat latitude), period(min),
// phase(0..1 of period), lon0(deg start longitude)
// mem0: initial onboard memory fill %
const sats = [
// ============ ОПТИКА ============
{ id: "res3", name: "Ресурс-П №3", norad: 41386, payload: "optic", domain: "ОПТИКА", group: "Ресурс-П",
orbit: { latAmp: 81, period: 94.2, phase: 0.02, lon0: 12 }, mem0: 38 },
{ id: "res4", name: "Ресурс-П №4", norad: 41387, payload: "optic", domain: "ОПТИКА", group: "Ресурс-П",
orbit: { latAmp: 81, period: 94.6, phase: 0.34, lon0: 155 }, mem0: 64 },
{ id: "res5", name: "Ресурс-П №5", norad: 41388, payload: "optic", domain: "ОПТИКА", group: "Ресурс-П",
orbit: { latAmp: 81, period: 94.4, phase: 0.67, lon0: -98 }, mem0: 21 },
{ id: "kan6", name: "Канопус-В №6", norad: 43657, payload: "optic", domain: "ОПТИКА", group: "Канопус-В",
orbit: { latAmp: 78, period: 96.1, phase: 0.12, lon0: 64 }, mem0: 52 },
{ id: "kan7", name: "Канопус-В №7", norad: 43658, payload: "optic", domain: "ОПТИКА", group: "Канопус-В",
orbit: { latAmp: 78, period: 96.4, phase: 0.55, lon0: -150 }, mem0: 79 },
// ============ ЛОКАЦИЯ (РСА) ============
{ id: "kon1", name: "Кондор-ФКА №1", norad: 57350, payload: "radar", domain: "ЛОКАЦИЯ", group: "Кондор-ФКА",
orbit: { latAmp: 74, period: 92.7, phase: 0.20, lon0: 33 }, mem0: 45 },
{ id: "kon2", name: "Кондор-ФКА №2", norad: 62138, payload: "radar", domain: "ЛОКАЦИЯ", group: "Кондор-ФКА",
orbit: { latAmp: 74, period: 92.9, phase: 0.62, lon0: 178 }, mem0: 17 },
{ id: "kon3", name: "Кондор-ФКА №3", norad: 62139, payload: "radar", domain: "ЛОКАЦИЯ", group: "Кондор-ФКА",
orbit: { latAmp: 74, period: 92.5, phase: 0.88, lon0: -70 }, mem0: 70 },
{ id: "obz1", name: "Обзор-Р №1", norad: 60001, payload: "radar", domain: "ЛОКАЦИЯ", group: "Обзор-Р",
orbit: { latAmp: 67, period: 97.8, phase: 0.30, lon0: 100 }, mem0: 33 },
{ id: "obz2", name: "Обзор-Р №2", norad: 60002, payload: "radar", domain: "ЛОКАЦИЯ", group: "Обзор-Р",
orbit: { latAmp: 67, period: 98.1, phase: 0.74, lon0: -25 }, mem0: 58 },
];
sats.forEach((s) => { s.color = COL[s.payload]; });
// --- Ground stations -------------------------------------------
const stations = [
{ id: "msk", name: "Москва", lon: 37.6, lat: 55.7 },
{ id: "mur", name: "Мурманск", lon: 33.1, lat: 68.9 },
{ id: "ana", name: "Анадырь", lon: 177.5, lat: 64.7 },
{ id: "kha", name: "Хабаровск", lon: 135.1, lat: 48.5 },
{ id: "kgd", name: "Калининград", lon: 20.5, lat: 54.7 },
{ id: "svb", name: "Шпицберген", lon: 15.6, lat: 78.2 },
{ id: "ant", name: "Прогресс", lon: 76.4, lat: -69.4 },
];
// --- Targets / ROIs (заявки на съёмку) -------------------------
// rect helper: center + half-width/height in degrees
function rect(lon, lat, hw, hh) {
return [
[lon - hw, lat - hh], [lon + hw, lat - hh],
[lon + hw, lat + hh], [lon - hw, lat + hh],
];
}
const rois = [
{ id: "gib", name: "Гибралтар", payload: "optic", poly: rect(-5.4, 36.0, 3.2, 2.4) },
{ id: "blk", name: "Чёрное море", payload: "optic", poly: rect(34.0, 43.5, 6.0, 3.0) },
{ id: "jpn", name: "Японские о-ва", payload: "radar", poly: rect(140.5, 37.5, 3.4, 6.5) },
{ id: "kam", name: "Камчатка", payload: "radar", poly: rect(159.0, 56.0, 4.5, 5.0) },
{ id: "cal", name: "Калифорния", payload: "optic", poly: rect(-119.5, 36.5, 3.5, 4.5) },
{ id: "syr", name: "Сирия", payload: "optic", poly: rect(38.5, 35.0, 3.0, 2.6) },
{ id: "sah", name: "о.Сахалин", payload: "radar", poly: rect(143.0, 50.5, 2.2, 4.8) },
{ id: "kur", name: "Курилы", payload: "radar", poly: rect(151.0, 46.0, 2.0, 3.6) },
];
export { sats as SATELLITES, stations as STATIONS, rois as ROIS };
@@ -0,0 +1,10 @@
/* index.js — public entry for the dashboard module */
export { default } from "./ConstellationDashboard.jsx";
export { default as ConstellationDashboard } from "./ConstellationDashboard.jsx";
// granular exports (in case you want to recompose the layout)
export { default as MapView } from "./MapView.jsx";
export { default as Timeline } from "./Timeline.jsx";
export { Header, SidePanel, STATUS_META, fmtDur } from "./Panels.jsx";
export * as SIM from "./sim.js";
export { SATELLITES, STATIONS, ROIS, COL } from "./data.js";
@@ -0,0 +1,158 @@
/* ============================================================
sim.js geometry + schedule (ESM)
Чистая геометрия подспутниковой точки, тень, расписание событий.
См. INTEGRATION.md subSat()/statusAt() можно заменить на реальную эфемериду/API.
============================================================ */
const D2R = Math.PI / 180, R2D = 180 / Math.PI;
const TWO_PI = Math.PI * 2;
const SIDEREAL_MIN = 1436.07; // earth rotation period (min)
function wrapLon(lon) {
lon = ((lon + 180) % 360 + 360) % 360 - 180;
return lon;
}
// sub-satellite point at sim time t(ms), given epoch ms.
function subSat(sat, tMs, epochMs) {
const m = (tMs - epochMs) / 60000; // minutes since epoch
const o = sat.orbit;
const M = o.phase * TWO_PI + TWO_PI * (m / o.period);
const lat = o.latAmp * Math.sin(M);
// one sine wave per orbit across full longitude, drifting west via earth rotation
const lon = wrapLon(o.lon0 + 360 * (m / o.period) - 360 * (m / SIDEREAL_MIN));
return { lon, lat };
}
// subsolar point (approx, declination ignored -> equatorial)
function subSolar(tMs) {
const secOfDay = (tMs / 1000) % 86400;
const lon = wrapLon(180 - (secOfDay / 86400) * 360);
// small seasonal declination wobble for life
const dayFrac = (tMs / 86400000) % 365.25;
const lat = 23.4 * Math.sin((dayFrac / 365.25) * TWO_PI - 1.4);
return { lon, lat };
}
// angular (great-circle) distance in degrees
function angDist(lon1, lat1, lon2, lat2) {
const φ1 = lat1 * D2R, φ2 = lat2 * D2R;
const = (lat2 - lat1) * D2R;
const = (lon2 - lon1) * D2R;
const a = Math.sin( / 2) ** 2 + Math.cos(φ1) * Math.cos(φ2) * Math.sin( / 2) ** 2;
return 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)) * R2D;
}
function inShadow(lon, lat, tMs) {
const ss = subSolar(tMs);
return angDist(lon, lat, ss.lon, ss.lat) > 90;
}
// point in polygon (ray casting), poly = [[lon,lat],...]
function pointInPoly(lon, lat, poly) {
let inside = false;
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
const xi = poly[i][0], yi = poly[i][1];
const xj = poly[j][0], yj = poly[j][1];
const intersect = (yi > lat) !== (yj > lat) &&
lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi;
if (intersect) inside = !inside;
}
return inside;
}
const STATION_RANGE = 22; // deg ground range for downlink visibility
// which ROI is the sat over (matching payload), null if none
function overROI(sat, p, rois) {
for (const r of rois) {
if (r.payload !== sat.payload) continue;
if (pointInPoly(p.lon, p.lat, r.poly)) return r;
}
return null;
}
// best (nearest in-range) station, null if none
function nearStation(p, stations) {
let best = null, bd = STATION_RANGE;
for (const st of stations) {
const d = angDist(p.lon, p.lat, st.lon, st.lat);
if (d < bd) { bd = d; best = st; }
}
return best;
}
// --- prohibition windows (интервалы запрета) per sat ----------
function buildProhibits(sats, epochMs) {
const H = 3600e3;
const out = {};
sats.forEach((s, i) => {
const list = [];
// deterministic-ish spread
const a0 = (i * 1.9) % 22 - 4; // hours from epoch
list.push({ start: epochMs + a0 * H, end: epochMs + (a0 + 0.7 + (i % 3) * 0.15) * H, reason: "ВНЗ" });
if (i % 2 === 0) {
const a1 = a0 + 8 + (i % 4);
list.push({ start: epochMs + a1 * H, end: epochMs + (a1 + 0.9) * H, reason: "ОГРАН. ПИТАНИЕ" });
}
out[s.id] = list;
});
return out;
}
function inProhibit(list, tMs) {
if (!list) return null;
for (const w of list) if (tMs >= w.start && tMs < w.end) return w;
return null;
}
// live status of a sat at time t (priority order)
// returns {state, roi, station, eclipse, prohibit}
function statusAt(sat, tMs, env) {
const p = subSat(sat, tMs, env.epochMs);
const prohibit = inProhibit(env.prohibits[sat.id], tMs);
const eclipse = inShadow(p.lon, p.lat, tMs);
if (prohibit) return { state: "prohibit", p, prohibit, eclipse };
const roi = overROI(sat, p, env.rois);
if (roi) return { state: "capture", p, roi, eclipse };
const st = nearStation(p, env.stations);
if (st) return { state: "downlink", p, station: st, eclipse };
return { state: "operational", p, eclipse };
}
// --- precompute event intervals over a domain for the timeline -
function buildSchedule(sats, env, domainStart, domainEnd, stepMin) {
const step = stepMin * 60000;
const sched = {};
for (const s of sats) {
const tracks = { capture: [], downlink: [], prohibit: [], shadow: [] };
// prohibits are explicit
(env.prohibits[s.id] || []).forEach((w) =>
tracks.prohibit.push({ start: w.start, end: w.end, label: w.reason }));
let cur = { capture: null, downlink: null, shadow: null };
for (let t = domainStart; t <= domainEnd; t += step) {
const p = subSat(s, t, env.epochMs);
const proh = inProhibit(env.prohibits[s.id], t);
const shadow = inShadow(p.lon, p.lat, t);
const roi = !proh ? overROI(s, p, env.rois) : null;
const st = !proh && !roi ? nearStation(p, env.stations) : null;
// shadow band
if (shadow) { if (!cur.shadow) cur.shadow = { start: t, end: t }; else cur.shadow.end = t; }
else if (cur.shadow) { tracks.shadow.push(cur.shadow); cur.shadow = null; }
// capture band
if (roi) { if (!cur.capture) cur.capture = { start: t, end: t, label: roi.name }; else cur.capture.end = t; }
else if (cur.capture) { tracks.capture.push(cur.capture); cur.capture = null; }
// downlink band
if (st) { if (!cur.downlink) cur.downlink = { start: t, end: t, label: st.name }; else cur.downlink.end = t; }
else if (cur.downlink) { tracks.downlink.push(cur.downlink); cur.downlink = null; }
}
if (cur.shadow) tracks.shadow.push(cur.shadow);
if (cur.capture) tracks.capture.push(cur.capture);
if (cur.downlink) tracks.downlink.push(cur.downlink);
sched[s.id] = tracks;
}
return sched;
}
export {
D2R, R2D, wrapLon, subSat, subSolar, angDist, inShadow,
pointInPoly, overROI, nearStation, buildProhibits, inProhibit,
statusAt, buildSchedule, STATION_RANGE,
};
@@ -0,0 +1,9 @@
/* util.js — small shared helpers (ESM) */
// "#rrggbb" + alpha -> "rgba(...)"; passes through non-hex strings unchanged
export function hexA(hex, a) {
if (typeof hex !== "string" || hex[0] !== "#") return hex;
const n = parseInt(hex.slice(1), 16);
const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;
return `rgba(${r},${g},${b},${a})`;
}
@@ -0,0 +1,115 @@
# Хэндофф для Claude Design — дашборд «Состояние группировки»
Этот файл — то, что прикладываем в чат Claude Design вместе со скриншотами текущего UI.
Цель: спроектировать **новый экран «Состояние группировки»** в едином стиле с уже сделанными
прототипами (`pcp-tgu-mission-ops`, `pcp-service-map`, `pcp-tgu-editor`), но на **реальных
токенах продукта**.
Эталон стиля — **реальный код** pcp-tgu-ops-ui (`src/styles/theme.css` + `editor-tokens.css`),
а не oklch/IBM Plex из прежних прототипов. Ниже — те же имена токенов, что в прототипах,
но с боевыми значениями. Используй их as-is.
---
## 1. Токены (вставить в `:root` нового прототипа)
```css
:root {
/* Фоны (тёмная пультовая тема) */
--bg-0: #111315; /* база */
--bg-1: #171a1c; /* панель */
--bg-2: #1f2325; /* панель 2 */
--bg-3: #262b2e; /* приподнятый элемент */
--line: #30373a;
--line-soft: #252b2d;
--grid: #252b2d;
/* Текст */
--ink: #e7ecec;
--ink-1: #e7ecec;
--ink-2: #a4b0b1;
--ink-3: #748183;
/* Акцент / статусы */
--accent: #68c3bd; /* бирюза — основной акцент */
--accent-strong: #82ded8;
--danger: #d64545;
--warning: #f0ad2e;
--success: #2f9e44;
--now: #f0ad2e; /* линия «сейчас» */
--warn: #f0ad2e;
/* Типы аппаратуры (ДЗЗ): оптика / радар / комбо */
--t-optical: #68c3bd;
--t-optical-dim: rgba(104, 195, 189, 0.18);
--t-radar: #f0ad2e;
--t-radar-dim: rgba(240, 173, 46, 0.16);
--t-combo: #d64545;
--t-combo-dim: rgba(214, 69, 69, 0.16);
/* Шрифты */
--sans: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
--mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
--radius: 7px;
--shadow: 0 16px 42px rgba(0, 0, 0, 0.28);
}
```
**Правила стиля, обязательные к соблюдению:**
- Все **числа, идентификаторы, времена** — моноширинным (`--mono`). Это узнаваемая черта UI.
- Подписи/заголовки — `--sans` (Inter).
- Никаких oklch и IBM Plex — только значения выше.
---
## 2. Что изменилось в продукте с момента прежних прототипов
Если будешь заодно подравнивать существующие морды — учти эти расхождения с тем, что было
сгенерировано раньше:
- **Редактор:** левая панель информации шире (≈17rem вместо 15.5rem) — числа больше не липнут к краю.
- **Слоты:** статусы слотов проставляются и красятся **только после закладки плана на КА**
(статус `LAID_IN`), а не по «принятию». До закладки — нейтральны.
- **Закладка:** кнопка «Подтвердить закладку» заблокирована, пока начало закладки в будущем.
- **Комплексный план (вкладка):** появились история прогонов (выпадающий список прошлых расчётов),
тумблер «Показать заявки» (оверлей заявок на карте) и ограничение интервала расчёта — **не более 10 суток**.
- **Редактор / правка включения:** инспектор включения read-only; правка идёт через
«Редактировать» → предзаполненное окно построения, которое заменяет маршрут.
- **Таймлайн:** стрелки цепочки версий плана между выдачами.
- **Статусы плана:** `LAID_IN` и `SUPERSEDED` теперь реальны; `ACCEPTED` не возникает.
---
## 3. ТЗ на новый экран «Состояние группировки»
Оперативный дашборд состояния орбитальной группировки ДЗЗ. Главное — «живая» карта с
проигрыванием и подсветкой того, что снимается прямо сейчас.
**Раскладка (предложение, можно улучшить):**
- Крупная карта мира (2D, как в `pcp-service-map`) — основная область.
- Снизу — компактная **полоса проигрывания**: кнопка ▶/⏸, скорость (1×/10×/60×/300×),
ползунок времени, текущее модельное время моноширинным.
- Слева/справа — узкая панель: список КА группировки со статусом (на связи / съёмка / сброс /
вне зоны), фильтр по типу аппаратуры (оптика/радар/комбо), счётчики.
- Верхняя строка — сводка: всего КА, сейчас снимают N, активных маршрутов M, ближайший сеанс.
**Поведение «плей»:**
- По ▶ модельное время идёт вперёд; **метки КА двигаются по трассам** в реальном (масштабируемом) времени.
- Маршруты/полосы, которые **снимаются в текущий момент времени**, подсвечиваются (акцентом/типом
аппаратуры), у КА в режиме съёмки — выделенное состояние.
- При паузе — всё замирает, можно кликнуть КА/маршрут и увидеть детали (как инспектор в редакторе).
- Линия «сейчас» (`--now`) отделяет факт от прогноза, если будет мини-таймлайн снизу.
**Данные (моки в `data.jsx`, сид-генерация как в прежних прототипах):**
- `spacecraft[]`: { id, name, group, type: optical|radar|combo, ... } + упрощённая трасса
(последовательность {t, lat, lon}) для анимации позиции по времени.
- `captures[]` (включения): { id, scId, tStart, tEnd, footprint (полигон/полоса), requestId? } —
по ним подсвечиваем «снимается сейчас», когда `tStart ≤ now ≤ tEnd`.
- `NOW` зафиксировать под дату проекта, диапазон — несколько суток.
**Формат сдачи:** новая папка `docs/ui-prototypes/pcp-grouping-dashboard/` в том же устройстве,
что прежние прототипы: `app.jsx` (корень) + `data.jsx` (моки) + модули (`mapview.jsx`,
`timeline.jsx`/`playbar.jsx`, `sidebar.jsx`, `details.jsx`, `util.jsx`) + HTML-обёртка
(React/Babel из CDN, блок `:root` из раздела 1). Переиспользуй `basemap.js`/`world.js`/`orbit.jsx`
из `pcp-service-map`, если подходят.
@@ -0,0 +1,310 @@
# Аудит сервиса заданий — pcp-request-service
Дата: 2026-06-06. Автор: Claude (Opus 4.8).
Охват: сервис `pcp-request-service` («сервис заданий») — задача [#26](бэклог-pcp-tgu-ops-ui.md).
Метод: статический разбор кода (контроллеры, Kafka-listener, сервисы сопоставления и жизненного
цикла, outbox, earth-grid), схема БД и Flyway-миграция, конфиг в `config-repo`, обзор тестов.
Парные документы: общий [аудит-проекта-2026-06-06.md](аудит-проекта-2026-06-06.md),
карты [PCP_ARCHITECTURE.md](architect/PCP_ARCHITECTURE.md) и
[PCP_SERVICE_INTERACTIONS.md](architect/PCP_SERVICE_INTERACTIONS.md).
Стык с сервисом паспорта маршрута (`pcp-route-processing-service`) — отдельная задача
[#28](бэклог-pcp-tgu-ops-ui.md), здесь только обозначен.
## Сводка по состоянию
Базовое здоровье — хорошее. Сервис держит жизненный цикл заявок на съёмку, ведёт учёт отснятой
территории геометрическим вычитанием и сопоставляет приходящие паспорта маршрутов с заявками.
Конкурентность и работа в нескольких инстансах продуманы: per-row пессимистичные блокировки,
`SKIP LOCKED` в outbox, advisory-lock на пересборку сетки, дедуп по уникальным ключам,
transactional outbox. 19 тестовых файлов с покрытием ключевых сервисов и репозиториев.
Главные риски — не в инфраструктуре, а в **бизнес-логике закрытия и сопоставления**:
закрытие «по времени» само по себе не срабатывает (нет планировщика истечения), а матчинг
засчитывает покрытие без проверки совместимости аппарата. Приоритеты: **RS1**, **RS2** (высокий),
далее RS3/RS4 (средний), затем долг RS5–RS7.
Порт 7005. БД `pcp_requests` (PostgreSQL + PostGIS), Flyway `V1`, `ddl-auto: validate`.
Пакет `org.nstart.dep265.requestservice` (несогласован с остальными — см. B3 общего аудита).
## Назначение и контур
Заявки создаются **только по HTTP**; паспорта маршрутов приходят **из Kafka**; завершение
заявки уходит наружу через **transactional outbox**.
```
HTTP (оператор / pcp-tgu-ops-ui через Vite-прокси)
POST/GET/DELETE /v1/requests · GET /v1/cells · /v1/grid
┌───────────────────────────────────┐
Kafka │ pcp-request-service │ Kafka
IN ───▶│ заявки · покрытие · lifecycle · │── OUT ──▶ pcp.request.status.v1 → geoportal
│ earth-grid · transactional outbox │ (outbox; COMPLETED/EXPIRED, #35)
└───────────────────────────────────┘
▲ │
│ ▼
pcp.route.georeference.v1 DB: pcp_requests (PostgreSQL + PostGIS)
pcp-route-processing-service ◀── связь для задачи #28
(«сервис паспорта маршрута»)
```
### Карта взаимодействий
| Направление | Канал | Контрагент | Примечание |
|---|---|---|---|
| IN (HTTP) | `/v1/requests` (POST/GET/`/map`/`/{id}`/`/{id}/cells`/DELETE) | UI / оператор | заявки заводятся только здесь |
| IN (HTTP) | `/v1/cells` (+`/priority-map`), `/v1/grid` (settings, `/rebuild`) | UI / оператор | сетка и карта важности |
| IN (Kafka) | `pcp.route.georeference.v1` | **pcp-route-processing-service** | `KafkaMessage<RoutePassportDto>`, контракт из `pcp-types-lib` |
| OUT (Kafka) | `pcp.request.status.v1` | **geoportal** (внешний) | outbox `REQUEST_TERMINAL_STATUS`; терминальный статус `COMPLETED`/`EXPIRED` в payload (#35) |
| — | `pcp.request.in.v1` (`app.kafka.topics.request`) | — | в конфиге есть, **слушателя нет** — мёртвый конфиг (RS4) |
| DB | `pcp_requests` (Postgres + PostGIS) | — | Flyway `V1`, `ddl-auto: validate` |
HTTP-поверхность: [RequestController](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/controller/RequestController.kt),
[CellsController](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/controller/CellsController.kt),
[GridManagementController](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/controller/GridManagementController.kt).
## Доменная модель и БД
Источник: [V1__create_schema.sql](../services/pcp-request-service/src/main/resources/db/migration/V1__create_schema.sql).
- **`requests`** — заявка/задание: `geometry` (исходная) + `remaining_geometry` (что осталось снять),
`geometry_area` / `remaining_area`, `coverage_required_percent`, окно `begin/end_date_time`,
`status`, `match_count`, `last_matched_at`, `completed_at`, мягкое удаление `deleted_at`,
`@Version version` (оптимистичная блокировка). Статусы CHECK-ограничением:
`ACCEPTED · ACTIVE · COMPLETED · EXPIRED · DELETED`.
- **`request_optics_params` / `request_rsa_params`** — параметры съёмки (разрешение, угол Солнца,
облачность / поляризация, интерферометрия). Хранятся, но в матчинге **не используются** (RS2).
- **`request_route_matches`** — аудит и идемпотентность сопоставления: `UNIQUE(request_id, route_id)`,
`original_intersection_*`, `applied_intersection_*`, `coverage_delta_percent`, `contributes_to_coverage`.
- **`outbox_events`** — transactional outbox: `UNIQUE(event_type, aggregate_id)`, статусы `NEW/PUBLISHED/FAILED`.
- **`earth_cells` / `request_cells` / `earth_grid_settings`** — сетка Земли и проекция заявок на неё;
PostGIS-триггеры заполняют `contour_geom` и пересчитывают `earth_cells.importance`
(= Σ `request_cell.importance × coverage_percent/100`). Это **карта важности**, не прогресс съёмки (RS6).
Геометрия хранится как WKT (`TEXT`); вычисления — JTS (`jts-core`), без hibernate-spatial.
## Учёт отснятой территории
Ядро — [RouteMatchingService](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RouteMatchingService.kt),
геометрия — [GeometryService](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/GeometryService.kt).
На каждый паспорт маршрута:
1. Кандидаты выбираются по **окну времени + статусу** (`ACCEPTED`/`ACTIVE`, не удалённые) —
[findRequestIdsForRoute](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/repository/RequestRepository.kt).
2. Идемпотентность: `existsByRequestIdAndRouteId` + `request_route_matches UNIQUE` + обработка гонки вставки.
3. На каждую заявку — своя транзакция с `findByIdForUpdate` (row-lock): маршрут пересекается с
**`remaining_geometry`** (не с исходной), остаток уменьшается `difference`, пересчитывается `remaining_area`.
4. `coveredPercent = (geometry_area remaining_area) / geometry_area × 100`.
Поскольку учёт идёт по остатку, **перекрывающиеся маршруты не задваиваются** — второй маршрут
засчитывает только новую площадь. Это корректная кумулятивная модель покрытия.
Ограничения учёта вынесены в находки **RS2** (игнор совместимости аппарата), **RS5** (планарная
площадь в градусах), **RS6** (earth-grid — это важность, а не покрытие).
## Жизненный цикл и закрытие заданий
Вся логика — пересчёт статуса «с нуля» в
[RequestLifecycleService.refreshStatus](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestLifecycleService.kt),
по приоритету сверху вниз:
| Условие | Статус |
|---|---|
| `coveredPercent ≥ coverage_required_percent` | **COMPLETED** (ставится `completed_at`) — приоритет над сроком |
| иначе `now < begin_date_time` | ACCEPTED |
| иначе `now > end_date_time` | **EXPIRED** |
| иначе | ACTIVE |
При переходе в терминальный статус (`COMPLETED` или `EXPIRED`) — транзакционный outbox-эвент
`REQUEST_TERMINAL_STATUS` (идемпотентно по `(event_type, aggregate_id)`,
[RequestStatusOutboxService](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestStatusOutboxService.kt)),
который затем публикуется
[OutboxPublisherService](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/OutboxPublisherService.kt)
в `pcp.request.status.v1` (geoportal). См. #35.
Ключевая особенность (бывший источник RS1): `refreshStatus` исторически вызывался **только из матчинга
маршрута**. После #29 его дополнительно дёргают планировщик истечения (`RequestExpiryService`) и
`createRequest`, поэтому закрытие «по времени» теперь срабатывает (см. RS1).
## Горизонтальное масштабирование (несколько инстансов)
Сервис **спроектирован под несколько одинаковых инстансов** — это сильная сторона.
Безопасно при N инстансов:
- **Матчинг**`findByIdForUpdate` (`SELECT … FOR UPDATE`, пессимистичная блокировка строки заявки)
в отдельной транзакции на заявку (`PROPAGATION_REQUIRED` из не-транзакционного listener →
одна независимая транзакция на заявку), плюс `@Version` и дедуп `(request_id, route_id)`.
Инстансы делят партиции топика (consumer-group `pcp-request-service-route`).
- **Outbox**`SELECT … FOR UPDATE SKIP LOCKED` + `UNIQUE(event_type, aggregate_id)`
параллельные инстансы не публикуют эвент дважды
([OutboxEventRepository](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/repository/OutboxEventRepository.kt)).
- **Пересборка сетки**`pg_try_advisory_xact_lock`
([GridRebuildLockService](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/GridRebuildLockService.kt)) →
ребилдит только один инстанс.
- **Создание заявки**`existsById` + PK + обработка `23505` → дубликат не пройдёт при гонке.
Семантика доставки — **at-least-once**; повторная обработка маршрута безопасна за счёт дедупа.
После #31 «ядовитое» сообщение уходит в DLQ (`DefaultErrorHandler` + bounded retry), а не блокирует
партицию. Outbox FAILED после #35 ретраится с backoff и пределом попыток. Параллелизм потребления
маршрутов ограничен числом партиций топика.
---
## Находки
### RS1 (высокий). Нет планировщика истечения — закрытие «по времени» не срабатывает — ✅ устранено (#29)
Файлы: [RequestLifecycleService.kt](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestLifecycleService.kt),
[RouteMatchingService.kt](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RouteMatchingService.kt)
`refreshStatus` — единственный путь смены статуса — вызывается **только при сопоставлении маршрута**.
Единственный `@Scheduled` в сервисе — публикация outbox. Поэтому если по заявке после `end_date_time`
**не пришло ни одного маршрута**, она навсегда останется `ACTIVE`/`ACCEPTED` и **никогда не станет
`EXPIRED`**. В `EXPIRED` заявка попадает лишь побочно — если какой-то маршрут случайно пересечётся
с ней уже после истечения срока. Дополнительно: `createRequest` всегда ставит `ACCEPTED`, даже если
окно уже в прошлом (`refreshStatus` при создании не вызывается).
Рекомендация: периодический job (`@Scheduled` + advisory-lock или `SKIP LOCKED`-выборка), который
переводит просроченные заявки в `EXPIRED` без участия маршрутов; вызывать `refreshStatus` и при создании.
**Готово (#29):** добавлен [RequestExpiryService](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestExpiryService.kt) —
`@Scheduled`-воркер переводит просроченные `ACCEPTED`/`ACTIVE` в `EXPIRED`, забирая заявки батчами через
`findExpirableForUpdateSkipLocked` (`FOR UPDATE SKIP LOCKED` + верхний лимит батчей — безопасно при нескольких
инстансах). `refreshStatus` теперь вызывается и в `createRequest`, поэтому статус приёма отражает окно
(`ACCEPTED`/`ACTIVE`/`EXPIRED`). Конфиг — `pcp.request.expiry.*`. Тесты: `RequestExpiryServiceTest`,
`RequestRepositoryJpaTest`.
### RS2 (высокий). Матчинг игнорирует совместимость аппарата
Файлы: [RouteMatchingService.kt](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RouteMatchingService.kt),
[RequestValidationService.kt](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestValidationService.kt)
Кандидаты подбираются только по окну времени и статусу, а покрытие засчитывается по любому
геометрическому пересечению. `survey_type` (оптика/РСА), разрешение, угол Солнца, облачность и
**крен** в фильтрации **не участвуют**: крен парсится и валидируется (0–90°), но как фильтр не
используется; параметры съёмки лежат в БД мёртвым грузом. Следствие: РСА-маршрут «закроет»
оптическую заявку, маршрут с негодным разрешением/освещённостью засчитается как покрытие.
Перекликается с UI-задачей #4.
Рекомендация: добавить проверку совместимости маршрут↔заявка перед зачётом покрытия (как минимум
survey_type и крен; в идеале — разрешение и угол Солнца). Требует, чтобы паспорт маршрута нёс тип
сенсора — согласовать в рамках #28.
**Решение (2026-06-06): отложено до #28.** `RoutePassportDto` сейчас не несёт явный `surveyType`
(есть `polarisation`, `resolutionRange`/`Azimuth`, `rollAngle`, `visirAngle`). Фиксируем контракт
паспорта в рамках аудита связи сервисов #28 и тогда же добавляем фильтр совместимости. До этого
matching не трогаем.
### RS3 (средний). Нет DLQ / обработчика ошибок Kafka — poison-сообщение блокирует партицию — ✅ устранено (#31)
Файл: [RequestKafkaListener.kt](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/kafka/RequestKafkaListener.kt)
Listener ловит исключение, логирует и **перебрасывает** его. DLQ и кастомный `CommonErrorHandler`
не настроены — работают дефолты Spring Kafka. Невалидное («ядовитое») сообщение маршрута будет
ретраиться бесконечно и **заблокирует партицию**. У парного `pcp-route-processing-service` DLQ есть,
здесь — нет.
Рекомендация: `DefaultErrorHandler` с ограниченными ретраями и `DeadLetterPublishingRecoverer`
(топик вида `pcp.route.georeference-dlq.v1`); невалидные по контракту сообщения отправлять в DLQ,
а не зацикливать.
**Готово (#31):** [KafkaConsumerConfig](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/config/KafkaConsumerConfig.kt) —
`DefaultErrorHandler` + `DeadLetterPublishingRecoverer` (DLQ `pcp.route.georeference-dlq.v1`),
экспоненциальный bounded retry; контрактные ошибки (`JsonProcessingException`/`ConstraintViolationException`)
не ретраятся, а сразу уходят в DLQ с диагностическими заголовками (`pcp-exception-class`,
`pcp-original-topic`/`-partition`/`-offset`). Настройки — `app.kafka.consumer.route.*`. По образцу
`pcp-route-processing-service`. Тест — `KafkaConsumerConfigTest`.
### RS4 (средний). Мёртвый конфиг: cleanup и входной топик заявок — ✅ устранено (#32)
Файл: [config-repo/pcp-request-service.yaml](../config-repo/pcp-request-service.yaml)
`app.request.cleanup-cron` / `cleanup-retention-days` были заданы, но **кода под них нет** — архивации
или удаления старых заявок не происходило. Аналогично `app.kafka.topics.request: pcp.request.in.v1`
был сконфигурирован, но **без слушателя** (заявки заводятся только по HTTP). Конфиг вводил в заблуждение.
Решение (#32): ключи удалены из `config-repo` (привязок `@Value` не было). Если cleanup/приём заявок
из Kafka понадобятся — заводить конфиг вместе с реализацией.
### RS5 (низкий). Планарная площадь в градусах вместо геодезической
Файл: [GeometryService.kt](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/GeometryService.kt)
Площади считаются JTS `geometry.area` в координатах EPSG:4326 (градусы). Для компактных заявок
процент покрытия как отношение почти корректен, но для крупных и высокоширотных областей даёт
систематическое искажение (градус долготы у полюса короче).
Рекомендация: при росте требований к точности — считать площадь геодезически (например, PostGIS
`ST_Area(geography)` или проекция в равновеликую СК), а не планарно в градусах.
**Решение (2026-06-06): отложено.** Покрытие считается отношением площадей, поэтому для обычных AOI
планарная оценка близка к геодезической. Переключение `area()` на геодезию ripple-эффектом затронет
учёт `coverageDeltaPercent` и точечные ассерты площадей в `RouteMatchingServiceTest`; делаем при
реальном росте требований к точности (крупные/высокоширотные заявки).
### RS6 (низкий). `coverage_required_percent` захардкожен в 100%; earth-grid ≠ покрытие — ✅ устранено (#34)
Файлы: [RequestService.kt](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestService.kt),
[RequestGridProjectionService.kt](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestGridProjectionService.kt)
`createRequest` всегда ставит `DEFAULT_COVERAGE_REQUIRED_PERCENT = 100.0`, игнорируя значение из API,
хотя колонка и поле его поддерживают. Отдельно стоит зафиксировать: `request_cells.coverage_percent`
и `/priority-map` — это **доля футпринта заявки в ячейке × важность** (карта приоритетов), считается
один раз при создании и **маршрутами не обновляется**. Реальный прогресс съёмки живёт только в
`remaining_geometry` / `remaining_area`. Это легко перепутать по названию поля.
Рекомендация: пробрасывать требуемый процент из API; в документации/именовании развести «важность
ячейки» и «прогресс покрытия».
**Готово (#34):** в `CreateRequestDto` добавлено опциональное поле `coverageRequiredPercent`
(0 < x ≤ 100, по умолчанию 100); оно пробрасывается в сущность и служит порогом `COMPLETED`,
константа `DEFAULT_COVERAGE_REQUIRED_PERCENT` убрана. В KDoc разведены «прогресс покрытия»
(`coverageRequiredPercent`/`remaining_area`) и «важность заявки» (`importance` → карта приоритетов
ячеек). Тесты — `RequestServiceTest`, `RequestApiDtoContractTest`.
### RS7 (средний). Уведомление geoportal о финале заявки; outbox FAILED без backoff — ✅ устранено (#35)
Файлы: [OutboxPublisherService.kt](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/OutboxPublisherService.kt),
[OutboxEventRepository.kt](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/repository/OutboxEventRepository.kt),
[RequestStatusOutboxService.kt](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestStatusOutboxService.kt)
Потребитель завершения заявок — внешний сервис **geoportal** (в этом монорепо его нет, поэтому в
[PCP_SERVICE_INTERACTIONS.md](architect/PCP_SERVICE_INTERACTIONS.md) консьюмер не виден). Ему нужно
сообщать про **оба** терминальных исхода: «выполнено» (`COMPLETED`) и «срок вышел» (`EXPIRED`). Сейчас
эмитится только `COMPLETED``pcp.request.completed.v1`), а `EXPIRED`-эвента нет вовсе. Плюс
публикуемая выборка берёт `NEW` + `FAILED`, поэтому FAILED **ретраится**, но без backoff и без cap:
при устойчивом сбое Kafka каждые 5 с все инстансы перевыбирают FAILED (нагрузка, но не потеря).
Решение: свести уведомления в единый топик **`pcp.request.status.v1`** (по аналогии с
`pcp.slots.status.v1`) с терминальным статусом в payload, заменив orphan `pcp.request.completed.v1`;
эмитить `EXPIRED`-эвент через тот же transactional outbox (зависит от планировщика истечения **RS1**,
который и создаёт переход в `EXPIRED`). Outbox дополнить `next_attempt_at`/экспоненциальным backoff и
пределом попыток — по образцу `RoutePassportOutboxWorker` из `pcp-route-processing-service`.
**Готово (#35):** единый топик `pcp.request.status.v1` с терминальным статусом в payload
(`COMPLETED`/`EXPIRED`, плюс `occurredAt`/`coveragePercent`) — заменил orphan `pcp.request.completed.v1`.
Единое событие `REQUEST_TERMINAL_STATUS` пишется в той же транзакции из двух мест:
[RouteMatchingService](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RouteMatchingService.kt)
(по достижению покрытия) и [RequestExpiryService](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestExpiryService.kt)
(по истечению срока). Outbox получил `attempts`/`next_attempt_at`: при сбое — удвоение backoff от
`initial-backoff-ms` до `max-backoff-ms`, после `max-attempts` событие становится терминальным `FAILED`
и из выборки выпадает. Конфиг — `pcp.outbox.*`. Мелкий зазор: заявка, **созданная** уже просроченной,
терминальное событие не порождает (вырожденный кейс; обычные пути ACTIVE→COMPLETED/EXPIRED покрыты).
## Что в порядке (намеренно не отмечаем как проблемы)
- Конкурентность и мультиинстанс: row-lock на заявку, `SKIP LOCKED` в outbox, advisory-lock на сетку,
`@Version`, дедуп по уникальным ключам — сделано грамотно.
- Transactional outbox: терминальный эвент `REQUEST_TERMINAL_STATUS` пишется в той же транзакции, что и завершение/истечение заявки.
- Идемпотентность сопоставления: повтор маршрута безопасен (`request_route_matches UNIQUE` + проверки).
- Кумулятивный учёт покрытия по остатку (без задвоения перекрытий).
- Тесты: `RouteMatchingServiceTest`, `OutboxPublisherServiceTest`,
`RequestStatusOutboxServiceTest`, `RequestExpiryServiceTest`, `RequestRepositoryJpaTest`, `MigrationSchemaTest`.
## Статус работ
Сделано:
- ✅ **RS1** — устранено (#29): планировщик истечения `RequestExpiryService` + `refreshStatus` при создании.
- ✅ **RS3** — устранено (#31): `DefaultErrorHandler` + DLQ `pcp.route.georeference-dlq.v1`.
- ✅ **RS4** — устранено (#32): мёртвый конфиг удалён из `config-repo`.
- ✅ **RS6** — устранено (#34): проброс `coverageRequiredPercent` из API + разведение терминов.
- ✅ **RS7** — устранено (#35): единый топик `pcp.request.status.v1` (COMPLETED/EXPIRED) + backoff/cap outbox.
Отложено (осознанно):
- ⏸ **RS2** (#30) — фильтр совместимости аппарата. Отложено **до #28**: паспорт маршрута не несёт явный `surveyType`, контракт фиксируется в аудите связи сервисов.
- ⏸ **RS5** (#33) — геодезическая площадь. Отложено до роста требований к точности (переключение `area()` затрагивает matching и тесты; покрытие — отношение площадей).
+97
View File
@@ -0,0 +1,97 @@
# Бэклог pcp-tgu-ops-ui
Список задач по сервису **pcp-tgu-ops-ui** (и связанным бэкендам).
Ведётся вручную и с помощью Claude: по просьбе — отмечаем выполненное и добавляем новые задачи.
**Обозначения статуса:**
- `[ ]` — не начато
- `[~]` — в работе
- `[x]` — выполнено (переносится в раздел «Выполнено» с пометкой)
У каждой задачи есть стабильный номер `#N` — он не меняется при правках списка, на него удобно ссылаться. Новые задачи получают следующий свободный номер.
> Задачи #12#25 заведены по результатам аудита. Подробности, обоснование и рекомендации
> по каждой — в [аудит-проекта-2026-06-06.md](аудит-проекта-2026-06-06.md) (ссылки на коды находок B/F/R/C).
_Обновлено: 2026-06-08_
---
## Активные задачи
### Карта и отображение
- [ ] **#1** — Отображать трассы полос исходя из возможности съёмки КА. Для оптики показывать только участки, попадающие в допустимый угол Солнца.
- [ ] **#2** — При клике в область, где пересекается несколько полос обзора, выводить список этих объектов на правой панели; при выборе элемента — выделять соответствующий объект на карте.
- [ ] **#3** — Показ забронированных слотов на вкладках «Карта» и «Редактор». Включает **подгрузку** данных по забронированным слотам с бэкенда (фетч), а не только отрисовку уже имеющихся. На вкладке «Редактор» они должны быть другого цвета и выделяться.
### Группировка
- [ ] **#36** — Заполнение бортового накопителя на вкладке «Группировка». Сейчас (MVP) считается на фронте из объёмов маршрутов плана (`volumeMb`): старт 0 на начало интервала плана, `+vol` на конце съёмки, `vol` на конце сброса, ёмкость борта — модельные 500 ГБ (`ONBOARD_CAPACITY_MB` в `features/constellation/realData.ts`). Довести до реального источника: брать ёмкость борта из НСИ платформы (не хардкод), а заполнение — из телеметрии/бортового учёта, когда такой канал появится; уточнить модель «съёмка копит / сброс опустошает» (частичные сбросы, очерёдность).
### Редактор
- [ ] **#4** — Показывать на вкладке «Редактор» только те заявки, которые может удовлетворить аппарат (например, для оптики не показывать заявку, для которой доступна только локация).
- [ ] **#5** — Дать возможность превратить план в статусе `PLANNED` (построенный цепочкой от последнего `LAID_IN`) в черновик и вручную наполнить его включениями — до того, как он будет автоматически выдан по таймеру.
- [ ] **#38** — Правка SLOTS-маршрута «телепортирует» его (исчезает с карты). При «Редактировать» редактор пересобирает контур через `survey-route/build`, передавая сохранённый `timeStart`. Для маршрутов `source=MANUAL` это корректно (их `timeStart` = время построения, контур воспроизводится). Для `source=SLOTS` `timeStart`/виток/контур посчитаны другим конвейером и не согласованы: напр. режим 174 хранит `timeStart=2026-06-07T01:51:38`, виток 152, контур над Чёрным морем (центр 45.42, 39.40), но в 01:51:38 спутник по flight-line над Индией (12–16° с.ш., виток 150) — пересборка даёт контур над Индией (10.73, 77.06), исходный прячется → маршрут «пропадает» из вида. Корень — та же баллистическая рассинхронизация, что и [#37]. _Решение (2026-06-08): чиним только на бэкенде — согласовать `timeStart`/виток/контур у SLOTS-режимов, чтобы `survey-route/build` воспроизводил их. UI-страховку (не прятать исходный контур / блокировать правку SLOTS) сознательно НЕ делаем — симптом остаётся до бэкенд-фикса._
### Комплексный план
- [ ] **#6** — Пересчитывать комплексный план при бронировании новых слотов или при поступлении новой заявки.
### Бэкенд / инфраструктура
- [ ] **#7** — Перевести сервис станций на ordinis (по примеру получения аппаратов в сервисе tgu).
- [ ] **#37** — Рассинхрон номеров витков между баллистическими источниками. Для одного и того же КА/времени `flight-line` (трасса, `/api/tgu-planning/spacecraft/{norad}/flight-line`) и `survey-route/build` (контур, pcp-mission-planing) отдают **разный номер витка**: на Emissio 1 (norad 2) 2026-06-06 12:5012:55 трасса помечена витком 142, а контур/сохранённые режимы — витком 144 (по широте ~74° с.ш. согласованы, расходится только нумерация). В редакторе карта фильтрует трассу/полосу обзора по видимому витку (`buildEditorMapScene``visibleRevolutions`), поэтому при сужении селектора до «виток N» точки трассы с другим номером выпадают и пропадает орбитальный контекст. Свести нумерацию витков к единому источнику (или маппинг на границе), чтобы трасса и маршрут согласовывались по витку.
### Проверка сервисов
- [ ] **#27** — Проверить сервис получения паспорта маршрута по отдельности.
- [ ] **#28** — Проверить связь сервиса заданий и сервиса получения паспорта маршрута между собой.
### Баги
- [ ] **#8** — Ошибка при попытке принять план на вкладке «Планы».
- [ ] **#39** — Авторасчёт плана выдаёт нереализуемые маршруты съёмки с креном > 45° (напр. `survey-211`, Emissio 1, крен 46.8°). Такой маршрут нельзя построить вручную: фронт зажимает `MAX_ROUTE_ROLL_DEG = 45` (`TguEditorTab.tsx`), а баллистика/геометрия маршрута и фильтр видимости (МПЛ, `abs(kren) <= 45°`) определены только до 45°. _Диагностика (2026-06-08): корень — данные каталога, не логика. slots-service строит крен слота как центр угловой полосы из каталога (`SlotService.kt:903-905`, `gamma=(begin+end)/2`) и предел 45° не учитывает; полосы берутся напрямую из `satellite_slot_angle`. В сиде каталога (`pcp-satellite-catalog-service/.../V1__initial.sql`) у **оптических** Emissio (id 1-6, 22) собственный профиль полос уходит до 50.42°, и полоса №17 [46.08°, 47.51°] даёт центр ровно 46.8°. Версия «посчитали по логике локации» проверена и НЕ подтвердилась: в slots-service нет ветвления по типу КА (оптика/локация) — все КА строятся одним `TestSatelliteImpl`, солвер выбирается стратегией запроса (GREEDY/CONTINUOUS), `type_code` не используется. Reflexio (локация) наоборот имеет узкие полосы ~17-25°. Решение (вернуться позже): либо обрезать/убрать в каталоге оптические полосы за 45° (поз. №16-20 у Emissio), либо добавить в slots-service зажим/отсев слотов по рабочему пределу крена КА. Истинный предел Emissio подтвердить у владельца каталога/баллистиков перед правкой._
### Аудит — надёжность и бэкенд
_Источник и рекомендации: [аудит-проекта-2026-06-06.md](аудит-проекта-2026-06-06.md)._
- [ ] **#12** — (B1, высокий) Camunda job-воркеры в `FlowService` молча глотают исключения (`catch { logger.warn(ex.message) }`) при `autoComplete=false`: задача не завершается и не падает, BPMN-процесс зависает без инцидента. Завершать через `newFailCommand`/проброс наверх, логировать `error(msg, ex)`.
- [ ] **#13** — (B2, средний) Снять симуляционную заглушку `Thread.sleep(5000)` («имитация подтверждения плана») в `FlowService.sendPlan` при появлении реального канала подтверждений.
- [ ] **#14** — (B3, средний) Привести в порядок несогласованные пакеты-копипасты (ballistics→`pcp_request_service`, complex-mission→`pcp_satellites_service`, ui-service и slots-service делят `slots_service`, request→`org.nstart.dep265`, tgu→без `.pcp.`). Отдельной задачей из-за широкого охвата.
- [ ] **#15** — (B4, низкий) Опечатка «srpring» в `pcp-srpring-boot-admin-server` растиражирована по каталогу, пакету, CI, docker-compose, gradle. Чинить вместе с #14.
- [ ] **#16** — (B5, низкий) ~9 мест `logger.warn/error(ex.message)` теряют stack trace — заменить на `logger.error("...", ex)`.
### Аудит — фронтенд
_Источник и рекомендации: [аудит-проекта-2026-06-06.md](аудит-проекта-2026-06-06.md)._
- [ ] **#17** — (F1, средний) Вынести общий `src/api/http.ts` (`fetchJson` + `extractErrorText` + маппинг ошибок): `extractErrorText` продублирован в 5 клиентах, fetch-обвязка повторяется ~22 раза. Заодно устранить расхождение приоритета полей ошибки (F5).
- [ ] **#18** — (F2, низкий) Перевести нетипизированный островок `features/constellation/*` (`.jsx`/`.js`, `allowJs`) на TS или добавить декларации типов на границе.
- [ ] **#19** — (F3, низкий) Удалить мёртвый barrel `features/constellation/index.js` (нигде не импортируется).
- [ ] **#20** — (F4, низкий) Декомпозировать God-компонент `TguEditorTab.tsx` (1727 строк): вынести хуки (`useDraftHistory`, `useRouteBuilder`), разнести панели.
### Аудит — гигиена репозитория и конфиги
_Источник и рекомендации: [аудит-проекта-2026-06-06.md](аудит-проекта-2026-06-06.md)._
- [ ] **#21** — (R1, средний) Убрать из git крупные дампы в корне (`pcp_requests_converted_create_requests.json` ~228 КБ, `response_заданияКубернетс.json` ~157 КБ, `response_заданияЕмельянов.json` ~66 КБ) — перенести в `src/test/resources`/`docs` или удалить с добавлением в `.gitignore`.
- [ ] **#22** — (R2, низкий) Удалить посторонние артефакты в корне: `node_modules/` + пустой `package-lock.json`, `obj_terra.zip`; при желании добавить `/package-lock.json` в `.gitignore`.
- [ ] **#23** — (R3, низкий) Прогнать `./gradlew clean` / удалить 26 устаревших каталогов `bin/` (задваивают исходники, шумят при grep).
- [ ] **#24** — (C1, средний) Унифицировать креды БД в config-repo: привести все сервисы к переопределяемому `password: ${DB_PASSWORD:...}` (как в tgu-service), убрать зашитые `password: password`.
- [ ] **#25** — (C2, низкий) Сузить CORS для прода в `pcp-ui-service` (`allowedOriginPatterns("*")` + `allowCredentials`) до конкретных origin; держать в курсе отсутствие CORS у напрямую вызываемых сервисов (работает только через прокси/nginx).
### Аудит — сервис заданий (pcp-request-service)
_Источник и рекомендации: [аудит-сервис-заданий.md](аудит-сервис-заданий.md) (коды находок RS1–RS7)._
- [ ] **#30** — (RS2, высокий) Матчинг засчитывает покрытие по любому геометрическому пересечению, игнорируя совместимость аппарата (`survey_type`, крен, разрешение, угол Солнца). Добавить фильтр совместимости маршрут↔заявка перед зачётом покрытия; тип сенсора в паспорте маршрута согласовать в рамках #28. _Отложено до #28 (решение, 2026-06-06): `RoutePassportDto` не несёт явный `surveyType` — контракт фиксируется в рамках аудита связи сервисов #28, тогда же делаем фильтр._
- [ ] **#33** — (RS5, низкий) Площади считаются планарно в градусах (JTS `area` на EPSG:4326) — искажение для крупных/высокоширотных заявок. Перейти на геодезию (`ST_Area(geography)` или равновеликая проекция), когда вырастут требования к точности. _Отложено по решению аудита: покрытие считается отношением площадей (планар ≈ геодезия для обычных AOI), а переключение `area()` затрагивает matching и точечные ассерты тестов — вводим при росте требований к точности._
---
## Выполнено
- [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`. Док: интеракции обновлены. Известный мелкий зазор: заявка, созданная уже просроченной, события не шлёт (вырожденный кейс)._
- [x] **#29** — (RS1) Планировщик истечения заявок. _Готово: `RequestExpiryService` (`@Scheduled`, `FOR UPDATE SKIP LOCKED`, батчи с верхним лимитом) переводит просроченные `ACCEPTED`/`ACTIVE` в `EXPIRED`; несколько инстансов безопасны. `refreshStatus` теперь вызывается и при создании заявки — статус приёма отражает окно (`ACCEPTED`/`ACTIVE`/`EXPIRED`), а не всегда `ACCEPTED`. Конфиг `pcp.request.expiry.*` в config-repo. Тесты: `RequestExpiryServiceTest` (юнит) + `RequestRepositoryJpaTest` (H2). Разблокирует EXPIRED-эмиссию в #35._
- [x] **#32** — (RS4) Удалить мёртвый конфиг сервиса заданий. _Готово: из `config-repo/pcp-request-service.yaml` убраны `app.request.cleanup-cron`/`cleanup-retention-days` и неиспользуемый топик `app.kafka.topics.request` (`pcp.request.in.v1`); кода под ними не было, `@Value`-привязок нет._
- [x] **#26** — Проверить сервис заданий (`pcp-request-service`) по отдельности. _Готово: аудит в [аудит-сервис-заданий.md](аудит-сервис-заданий.md) — карта взаимодействий, учёт покрытия, жизненный цикл, мультиинстанс; находки RS1–RS7 (RS1/RS2 — высокий приоритет)._
- [x] **#9** — Расширить вправо левую панель информации на вкладке «Редактор» (цифры у границы смотрелись плохо). _Готово: commit 69dfd80, рейл 15.5rem → 17rem._
- [x] **#10** — Проставлять статусы слотов только после того, как план заложен на КА. _Готово: commit 69dfd80, слоты занимаются по `LAID_IN`, а не по `ACCEPT`._
- [x] **#11** — Запретить нажимать «Подтвердить закладку», пока начало закладки в будущем. _Готово: commit 69dfd80._
Добавил вручную:
- убрать кнопки + - квадрат с вкладки редактор и карта с карты ибо ничего не делают
+1 -2
View File
@@ -1,7 +1,7 @@
# VERSION: 0.0.3
### Versions ###
versions.java=17
versions.java=21
# plugins
versions_kotlin=2.2.20
versions_spring_boot=4.0.2
@@ -15,7 +15,6 @@ versions.testcontainers=1.18.1
versions.otel=1.12.1
versions.lombok=1.18.36
versions.hibernate.spatial=6.5.1.Final
versions.camunda=8.4.15
versions.open-api=3.0.1
versions.spring.boot.admin=4.0.1
@@ -0,0 +1,46 @@
package space.nstart.pcp.pcp_types_lib.dto.satellite.mission
import io.swagger.v3.oas.annotations.media.Schema
import java.time.LocalDateTime
/**
* Запрос на ручное добавление сброса в план по выбранной зоне радиовидимости (ЗРВ).
*
* Оператор выбирает ЗРВ станции и набор уже запланированных маршрутов съёмки, время конца
* которых строго меньше начала зоны (т.е. съёмка успевает завершиться до сеанса сброса).
* Сброс сохраняется как [space.nstart.pcp.pcp_mission_planing_service]-режим DROP под основным
* планом миссии (planNumber = 1) с теми же связями съёмкасброс, что и при штатном расчёте.
*/
@Schema(description = "Запрос на ручное добавление сброса по выбранной зоне радиовидимости")
data class DropRouteSaveRequestDTO(
@field:Schema(description = "Идентификатор КА (NORAD)")
val satelliteId: Long = 0,
@field:Schema(description = "Идентификатор/номер станции приёма (из ЗРВ)")
val station: String = "",
@field:Schema(description = "Номер витка зоны радиовидимости")
val revolution: Long = 0,
@field:Schema(description = "Начало зоны радиовидимости (локальное время без зоны)")
val zoneStart: LocalDateTime = LocalDateTime.now(),
@field:Schema(description = "Конец зоны радиовидимости (локальное время без зоны)")
val zoneStop: LocalDateTime = LocalDateTime.now(),
@field:Schema(description = "Идентификаторы режимов съёмки (survey_modes), которые сбрасываются в этой зоне")
val surveyModeIds: List<Long> = emptyList(),
@field:Schema(
description = "Начало интервала плана (локальное время без зоны). Используется только при " +
"сохранении, если миссии/плана с таким id ещё нет — чтобы создать их с корректным окном.",
nullable = true
)
val planStart: LocalDateTime? = null,
@field:Schema(
description = "Конец интервала плана (локальное время без зоны). См. planStart.",
nullable = true
)
val planEnd: LocalDateTime? = null
)
@@ -0,0 +1,31 @@
package space.nstart.pcp.pcp_types_lib.dto.satellite.mission
/**
* Скорости накопления/сброса данных для оценки объёма режима.
*
* Объём (`volumeMb`) считается единообразно во всех сервисах в **мегабайтах (Мбайт)** как
* `скорость × длительность`, поэтому константы живут в общей библиотеке типов один источник
* правды для текущего плана (pcp-ui-service), редактора и комплексного плана (pcp-tgu-ops-ui).
*
* Внимание на единицы: съёмка задана в Мбайт/с, а скорость канала сброса в Мбит/с,
* поэтому при расчёте объёма сброса скорость приводится к Мбайт делением на 8.
*/
object ModeDataRates {
/**
* Накопление при съёмке единое для всех КА, **Мбайт/с**.
* Из расчёта 100 с маршрута = 12 ГБ (1 ГБ = 1000 Мбайт): 12000 Мбайт / 100 с = 120 Мбайт/с.
*/
const val SURVEY_MBYTE_PER_SEC: Double = 120.0
/** Сброс — постоянная скорость канала, **Мбит/с**. */
const val DROP_MBIT_PER_SEC: Double = 1200.0
/** Бит в байте — приведение скорости канала (Мбит/с) к объёму памяти (Мбайт). */
private const val BITS_PER_BYTE: Double = 8.0
/** Объём маршрута съёмки, Мбайт, по его длительности (секунды). */
fun surveyVolumeMb(durationSeconds: Double): Double = durationSeconds * SURVEY_MBYTE_PER_SEC
/** Объём сброса, Мбайт, по длительности сеанса (секунды): скорость канала Мбит/с → Мбайт. */
fun dropVolumeMb(durationSeconds: Double): Double = durationSeconds * DROP_MBIT_PER_SEC / BITS_PER_BYTE
}
@@ -1,9 +1,10 @@
package space.nstart.pcp.pcp_types_lib.dto.satellite.mission
import com.fasterxml.jackson.annotation.JsonProperty
import com.fasterxml.jackson.annotation.JsonSubTypes
import com.fasterxml.jackson.annotation.JsonTypeInfo
import io.swagger.v3.oas.annotations.media.Schema
import java.time.OffsetDateTime
import java.time.LocalDateTime
@Schema(
description = "Базовая модель режима",
@@ -28,20 +29,24 @@ sealed interface ModeResponseDTO {
val planId: Long
@get:Schema(description = "Время начала режима")
val timeStart: OffsetDateTime?
val timeStart: LocalDateTime?
@get:Schema(description = "Номер витка")
val revolution: Long
@get:Schema(description = "Тип режима", allowableValues = ["SURVEY", "DROP"])
val type: String
@get:Schema(description = "Объём режима, Мбайт (накопление съёмки 120 Мбайт/с, сброс — канал 1200 Мбит/с)")
@get:JsonProperty(access = JsonProperty.Access.READ_ONLY)
val volumeMb: Double
}
@Schema(description = "Режим съемки")
data class SurveyModeResponseDTO(
override val id: Long = 0,
override val planId: Long = 0,
override val timeStart: OffsetDateTime? = null,
override val timeStart: LocalDateTime? = null,
override val revolution: Long = 0,
override val type: String = "SURVEY",
val status: String = "",
@@ -54,16 +59,22 @@ data class SurveyModeResponseDTO(
val roll: Double = 0.0,
@get:Schema(description = "Идентификаторы забронированных слотов")
val bookedSlotIds: List<Long> = emptyList()
) : ModeResponseDTO
) : ModeResponseDTO {
@get:JsonProperty(access = JsonProperty.Access.READ_ONLY)
override val volumeMb: Double get() = ModeDataRates.surveyVolumeMb(duration)
}
@Schema(description = "Режим сброса")
data class DropModeResponseDTO(
override val id: Long = 0,
override val planId: Long = 0,
override val timeStart: OffsetDateTime? = null,
override val timeStart: LocalDateTime? = null,
override val revolution: Long = 0,
override val type: String = "DROP",
val station: String? = "",
val duration: Double = 0.0,
val surveys: List<Long> = emptyList()
) : ModeResponseDTO
) : ModeResponseDTO {
@get:JsonProperty(access = JsonProperty.Access.READ_ONLY)
override val volumeMb: Double get() = ModeDataRates.dropVolumeMb(duration)
}
@@ -1,5 +1,6 @@
package space.nstart.pcp.pcp_types_lib.dto.satellite.mission
import com.fasterxml.jackson.annotation.JsonProperty
import java.time.LocalDateTime
data class SatelliteModeResponseDTO(
@@ -19,4 +20,9 @@ data class SatelliteModeResponseDTO(
val cellNums: List<Long> = emptyList(),
val bookedSlotIds: List<Long> = emptyList(),
val snapshotId: Long? = null
)
) {
/** Объём режима, Мбайт: сброс — канал 1200 Мбит/с (÷8), иначе — накопление съёмки 120 Мбайт/с. */
@get:JsonProperty(access = JsonProperty.Access.READ_ONLY)
val volumeMb: Double
get() = if (type == "DROP") ModeDataRates.dropVolumeMb(duration) else ModeDataRates.surveyVolumeMb(duration)
}
@@ -0,0 +1,82 @@
package space.nstart.pcp.pcp_types_lib.dto.satellite.mission
import com.fasterxml.jackson.annotation.JsonProperty
import io.swagger.v3.oas.annotations.media.Schema
import jakarta.validation.constraints.DecimalMin
import java.time.LocalDateTime
/**
* Способ построения временного окна маршрута относительно момента видимости.
*/
enum class SurveyRouteAnchor {
/** Окно центрируется на моменте видимости: [time - duration/2, time + duration/2]. */
CENTERED,
/** Момент видимости — начало съёмки: [time, time + duration]. */
FORWARD
}
@Schema(description = "Запрос на построение одного маршрута съёмки по параметрам видимости точки")
data class SurveyRouteBuildRequestDTO(
@field:Schema(description = "Идентификатор КА (тот же, что используется в obj-view/mpl-point — NORAD)")
val satelliteId: Long = 0,
@field:Schema(description = "Момент видимости (локальное время без зоны)")
val time: LocalDateTime = LocalDateTime.now(),
@field:DecimalMin(value = "1", message = "Длительность съёмки должна быть не меньше 1 секунды")
@field:Schema(description = "Длительность съёмки, секунды")
val durationSeconds: Double = 60.0,
@field:Schema(description = "Крен (gamma), градусы")
val roll: Double = 0.0,
@field:Schema(description = "Угол захвата (полуширина полосы), градусы")
val captureAngle: Double = 1.5,
@field:Schema(description = "Номер витка (0 — определить по орбитальным точкам)")
val revolution: Long = 0,
@field:Schema(description = "Способ построения окна относительно момента видимости")
val anchor: SurveyRouteAnchor = SurveyRouteAnchor.CENTERED,
@field:Schema(
description = "Начало интервала плана (локальное время без зоны). Используется только при " +
"сохранении маршрута, если миссии/плана с таким id ещё нет — чтобы создать их с " +
"корректным окном (для расчёта пересечений и наложений). Игнорируется в /build.",
nullable = true
)
val planStart: LocalDateTime? = null,
@field:Schema(
description = "Конец интервала плана (локальное время без зоны). См. planStart.",
nullable = true
)
val planEnd: LocalDateTime? = null
)
@Schema(description = "Построенный маршрут съёмки")
data class SurveyRouteBuildResponseDTO(
val satelliteId: Long,
@field:Schema(description = "Начало съёмки (локальное время без зоны)")
val timeStart: LocalDateTime,
@field:Schema(description = "Длительность съёмки, секунды")
val duration: Double,
val revolution: Long,
@field:Schema(description = "Крен (gamma), градусы")
val roll: Double,
@field:Schema(description = "Угол захвата, градусы")
val captureAngle: Double,
@field:Schema(description = "Широта центра контура, градусы")
val lat: Double,
@field:Schema(description = "Долгота центра контура, градусы")
val longitude: Double,
@field:Schema(description = "Контур полосы съёмки, WKT POLYGON (lon lat, ...)")
val contourWkt: String,
@field:Schema(description = "Идентификатор сохранённого режима съёмки в БД (null — маршрут не сохранялся)")
val surveyModeId: Long? = null
) {
@get:Schema(description = "Объём маршрута, Мбайт (накопление съёмки 120 Мбайт/с × длительность)")
@get:JsonProperty(access = JsonProperty.Access.READ_ONLY)
val volumeMb: Double get() = ModeDataRates.surveyVolumeMb(duration)
}
@@ -1,22 +1,22 @@
package space.nstart.pcp.pcp_types_lib.utils.json
import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.databind.JsonSerializer
import com.fasterxml.jackson.databind.SerializerProvider
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
class LocalDateTimeTimeZoneSerializer: JsonSerializer<LocalDateTime?>() {
companion object {
val fmt: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSxxx")
}
override fun serialize(p0: LocalDateTime?, p1: JsonGenerator, p2: SerializerProvider) {
//todo: проверить корректность зоны по нормальным правилам
// p1.writeString(p0?.atZone(ZoneId.of("Europe/Moscow"))?.format(fmt))
p1.writeString(p0?.atZone(ZoneId.systemDefault())?.format(fmt))
}
}
package space.nstart.pcp.pcp_types_lib.utils.json
import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.databind.JsonSerializer
import com.fasterxml.jackson.databind.SerializerProvider
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
class LocalDateTimeTimeZoneSerializer: JsonSerializer<LocalDateTime?>() {
companion object {
val fmt: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSxxx")
}
override fun serialize(p0: LocalDateTime?, p1: JsonGenerator, p2: SerializerProvider) {
//todo: проверить корректность зоны по нормальным правилам
// p1.writeString(p0?.atZone(ZoneId.of("Europe/Moscow"))?.format(fmt))
p1.writeString(p0?.atZone(ZoneId.systemDefault())?.format(fmt))
}
}
@@ -6,6 +6,7 @@ import com.fasterxml.jackson.module.kotlin.readValue
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertInstanceOf
import org.junit.jupiter.api.Test
import kotlin.test.assertTrue
class ModeResponseDTOJacksonTest {
@@ -50,4 +51,26 @@ class ModeResponseDTOJacksonTest {
assertEquals("SURVEY", result[0].type)
assertEquals("DROP", result[1].type)
}
@Test
fun `serializes computed volume and tolerates it on read-back`() {
// Сервис-источник теперь сериализует volumeMb; потребитель (pcp-ui-service)
// десериализует тот же JSON обратно — read-only поле не должно ломать чтение.
val modes: List<ModeResponseDTO> = listOf(
SurveyModeResponseDTO(id = 1, planId = 10, duration = 100.0),
DropModeResponseDTO(id = 2, planId = 10, duration = 5.0)
)
val json = mapper.writeValueAsString(modes)
val tree = mapper.readTree(json)
// Съёмка: 100 с × 120 Мбайт/с = 12000 Мбайт. Сброс: 5 с × 1200 Мбит/с ÷ 8 = 750 Мбайт.
assertEquals(12000.0, tree[0]["volumeMb"].asDouble())
assertEquals(750.0, tree[1]["volumeMb"].asDouble())
val roundTrip: List<ModeResponseDTO> = mapper.readValue(json)
assertEquals(12000.0, roundTrip[0].volumeMb)
assertEquals(750.0, roundTrip[1].volumeMb)
assertTrue(roundTrip[0] is SurveyModeResponseDTO)
}
}
@@ -12,9 +12,9 @@ import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
import space.nstart.pcp.pcp_request_service.configuration.CustomValidationException
import space.nstart.pcp.pcp_request_service.service.SatelliteIcEventService
import space.nstart.pcp.pcp_request_service.service.SatelliteService
import space.nstart.pcp.pcp_types_lib.configuration.CustomValidationException
import space.nstart.pcp.pcp_types_lib.dto.ballistics.ExactTimePositionRequestDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.SatelliteICDTO
import java.time.LocalDateTime
@@ -102,6 +102,9 @@ class SatelliteService {
@Autowired
private lateinit var earthCellViewRepository: EarthCellViewRepository
@Autowired
private lateinit var stationCatalogClient: StationCatalogClient
val astro = AstronomerJ2000(EarthType.PZ90d02)
@@ -800,17 +803,32 @@ class SatelliteService {
bal.rollMin = 0.0
bal.rollMax = 45.0 / 180 * PI
// ЗРВ считаем по реальным станциям приёма — каталог берём у pcp-stations-service.
// Идентификатор PPI = station.number, поэтому RadioVisibilityAreaDTO.stationId
// совпадает с номером станции (клиент сопоставляет название по своему списку станций).
val stations = stationCatalogClient.allStations()
if (stations.isEmpty()) {
logger.warn("Нет зарегистрированных станций — ЗРВ для КА {} не рассчитываются", id)
return emptyList()
}
val r = bal.calculateZRV(
listOf(PPI(1,1,0.35, 0.45, 100.0, 0.0, PI/2)),
tstart,
tstop
)
val ppis = stations.map { station ->
PPI(
station.number, 1,
station.position.lat * PI / 180,
station.position.long * PI / 180,
station.position.height,
station.elevationMin * PI / 180,
station.elevationMax * PI / 180
)
}
val r = bal.calculateZRV(ppis, tstart, tstop)
if (r != BallisticsError.OK) {
logger.error("Ошибка при расчете параметров ЗРВ : $r")
return listOf()
} else
return bal.zrv.map { mkRVA(it, id)}
return bal.zrv.map { mkRVA(it, id) }.sortedBy { it.onStart.time }
}
@@ -820,7 +838,8 @@ class SatelliteService {
revolution = p.vit.toLong(),
onStart = mkTP(p.zoneIn),
onMaximum = mkTP(p.zoneMax),
onStop = mkTP(p.zoneOut)
onStop = mkTP(p.zoneOut),
duration = (p.zoneOut.t - p.zoneIn.t)
)
fun mkTP(p : VisibilityParametersZRV) = TargetPositionDTO(
toDateTime(p.t),
@@ -0,0 +1,40 @@
package space.nstart.pcp.pcp_request_service.service
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.ObjectProvider
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
import org.springframework.web.reactive.function.client.WebClient
import space.nstart.pcp.pcp_types_lib.dto.ballistics.StationDTO
/**
* Клиент к pcp-stations-service: получение списка наземных станций приёма.
*
* Расчёт зон радиовидимости (ЗРВ) ведётся по реальным станциям, поэтому баллистика
* сама забирает их каталог у сервиса станций (а не получает с фронта).
*/
@Service
class StationCatalogClient(webClientBuilderProvider: ObjectProvider<WebClient.Builder>) {
private val webClientBuilder: WebClient.Builder = webClientBuilderProvider.ifAvailable ?: WebClient.builder()
private val logger: Logger = LoggerFactory.getLogger(this::class.java)
@Value("\${settings.stations-service:stations-service}")
val url = ""
/** Возвращает все зарегистрированные станции; при ошибке — пустой список (ЗРВ тогда не строится). */
fun allStations(): List<StationDTO> =
runCatching {
webClientBuilder.build()
.get()
.uri("$url/api/stations")
.retrieve()
.bodyToFlux(StationDTO::class.java)
.collectList()
.block()
?: emptyList()
}.getOrElse { e ->
logger.error("Не удалось получить список станций из $url/api/stations: ${e.message}")
emptyList()
}
}
@@ -7,7 +7,7 @@ spring:
import: "configserver:"
cloud:
config:
uri: ${CONFIG_SERVER_URI:http://192.168.60.201:8888}
uri: ${CONFIG_SERVER_URI:http://localhost:8888}
fail-fast: ${CONFIG_SERVER_FAIL_FAST:true}
profile: ${SPRING_CLOUD_CONFIG_PROFILE:${SPRING_PROFILES_ACTIVE:${spring.profiles.default}}}
label: ${SPRING_CLOUD_CONFIG_LABEL:dev}
@@ -2,7 +2,6 @@ package space.nstart.pcp.pcp_satellites_service.dto
import space.nstart.pcp.pcp_satellites_service.entity.ComplexPlanRunStatus
import java.time.LocalDateTime
import java.time.OffsetDateTime
data class ComplexPlanRunResponseDTO(
val runId: Long,
@@ -11,9 +10,9 @@ data class ComplexPlanRunResponseDTO(
val intervalEnd: LocalDateTime,
val satelliteIds: List<Long>,
val requestedBy: String?,
val createdAt: OffsetDateTime,
val startedAt: OffsetDateTime?,
val finishedAt: OffsetDateTime?,
val createdAt: LocalDateTime,
val startedAt: LocalDateTime?,
val finishedAt: LocalDateTime?,
val durationMs: Long?,
val errorMessage: String?,
val calculatedModesCount: Int,
@@ -2,7 +2,6 @@ package space.nstart.pcp.pcp_satellites_service.dto
import space.nstart.pcp.pcp_satellites_service.entity.ComplexPlanRunStatus
import java.time.LocalDateTime
import java.time.OffsetDateTime
data class ComplexPlanRunSummaryDTO(
val runId: Long,
@@ -11,9 +10,9 @@ data class ComplexPlanRunSummaryDTO(
val intervalEnd: LocalDateTime,
val satelliteIds: List<Long>,
val requestedBy: String?,
val createdAt: OffsetDateTime,
val startedAt: OffsetDateTime?,
val finishedAt: OffsetDateTime?,
val createdAt: LocalDateTime,
val startedAt: LocalDateTime?,
val finishedAt: LocalDateTime?,
val durationMs: Long?,
val errorMessage: String?,
val calculatedModesCount: Int,
@@ -12,8 +12,11 @@ import jakarta.persistence.Id
import jakarta.persistence.OneToMany
import jakarta.persistence.JoinColumn
import jakarta.persistence.ManyToOne
import jakarta.persistence.PrePersist
import jakarta.persistence.PreUpdate
import jakarta.persistence.Table
import jakarta.persistence.Index
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.ModeDataRates
import java.time.LocalDateTime
@Entity
@@ -92,4 +95,14 @@ class SatelliteModeEntity(
fetch = FetchType.LAZY
)
var cells: MutableList<SatelliteModeCellEntity> = mutableListOf()
)
) {
/** Объём маршрута, Мбайт. Денормализация: считается из duration при построении (satellite_modes — только SURVEY). */
@Column(name = "volume_mb", nullable = false)
var volumeMb: Double = ModeDataRates.surveyVolumeMb(duration)
@PrePersist
@PreUpdate
fun computeVolumeMb() {
volumeMb = ModeDataRates.surveyVolumeMb(duration)
}
}
@@ -4,9 +4,6 @@ import org.springframework.stereotype.Component
import space.nstart.pcp.pcp_satellites_service.dto.ComplexPlanRunResponseDTO
import space.nstart.pcp.pcp_satellites_service.dto.ComplexPlanRunSummaryDTO
import space.nstart.pcp.pcp_satellites_service.entity.ComplexPlanRunEntity
import java.time.LocalDateTime
import java.time.OffsetDateTime
import java.time.ZoneOffset
@Component
class ComplexPlanRunMapper {
@@ -19,9 +16,9 @@ class ComplexPlanRunMapper {
intervalEnd = entity.intervalEnd,
satelliteIds = satelliteIds(entity),
requestedBy = entity.requestedBy,
createdAt = entity.createdAt.toUtcOffset(),
startedAt = entity.startedAt?.toUtcOffset(),
finishedAt = entity.finishedAt?.toUtcOffset(),
createdAt = entity.createdAt,
startedAt = entity.startedAt,
finishedAt = entity.finishedAt,
durationMs = entity.durationMs,
errorMessage = entity.errorMessage,
calculatedModesCount = entity.calculatedModesCount,
@@ -46,9 +43,9 @@ class ComplexPlanRunMapper {
intervalEnd = entity.intervalEnd,
satelliteIds = satelliteIds(entity),
requestedBy = entity.requestedBy,
createdAt = entity.createdAt.toUtcOffset(),
startedAt = entity.startedAt?.toUtcOffset(),
finishedAt = entity.finishedAt?.toUtcOffset(),
createdAt = entity.createdAt,
startedAt = entity.startedAt,
finishedAt = entity.finishedAt,
durationMs = entity.durationMs,
errorMessage = entity.errorMessage,
calculatedModesCount = entity.calculatedModesCount,
@@ -75,8 +72,4 @@ class ComplexPlanRunMapper {
.mapNotNull { it.id }
.distinct()
.sorted()
private fun LocalDateTime.toUtcOffset(): OffsetDateTime =
atOffset(ZoneOffset.UTC)
}
@@ -10,6 +10,7 @@ import space.nstart.pcp.pcp_satellites_service.entity.ComplexPlanRunEntity
import space.nstart.pcp.pcp_types_lib.dto.requests.ComplexPlanProcessRequestDTO
import space.nstart.pcp.pcp_types_lib.dto.requests.CoverageCalculationSource
import tools.jackson.databind.json.JsonMapper
import java.time.Duration
import kotlin.system.measureTimeMillis
@Service
@@ -25,6 +26,8 @@ class ComplexPlanRunOrchestrationService(
private val objectMapper = JsonMapper.builder().findAndAddModules().build()
fun process(request: ComplexPlanProcessRequestDTO, requestedBy: String?): ComplexPlanRunResponseDTO {
validateInterval(request)
lateinit var selectedSatelliteIds: List<Long>
val resolveSatelliteIdsMs = measureTimeMillis {
selectedSatelliteIds = resolveSatelliteIds(request)
@@ -161,6 +164,25 @@ class ComplexPlanRunOrchestrationService(
request.satelliteId?.let { add(it) }
addAll(request.satelliteIds)
}.distinct().ifEmpty { satelliteService.satellites.map { it.satelliteId }.distinct() }
/**
* Интервал расчёта обязан быть корректным и не длиннее [MAX_INTERVAL_DAYS] суток: считать
* комплексный план по всем КА на больший горизонт слишком тяжело и бессмысленно для оперативного
* плана. Проверка до создания run, чтобы не плодить заведомо отклонённые записи.
*/
private fun validateInterval(request: ComplexPlanProcessRequestDTO) {
if (!request.intervalEnd.isAfter(request.intervalStart)) {
throw CustomValidationException("Interval end must be after interval start")
}
if (Duration.between(request.intervalStart, request.intervalEnd) > Duration.ofDays(MAX_INTERVAL_DAYS)) {
throw CustomValidationException("Complex plan interval cannot exceed $MAX_INTERVAL_DAYS days")
}
}
companion object {
/** Максимальная длина интервала расчёта комплексного плана, суток. */
const val MAX_INTERVAL_DAYS = 10L
}
}
data class ComplexPlanCalculationResult(
@@ -7,7 +7,7 @@ spring:
import: "configserver:"
cloud:
config:
uri: ${CONFIG_SERVER_URI:http://192.168.60.201:8888}
uri: ${CONFIG_SERVER_URI:http://localhost:8888}
fail-fast: ${CONFIG_SERVER_FAIL_FAST:true}
profile: ${SPRING_CLOUD_CONFIG_PROFILE:${SPRING_PROFILES_ACTIVE:${spring.profiles.default}}}
label: ${SPRING_CLOUD_CONFIG_LABEL:dev}
@@ -0,0 +1,6 @@
-- Объём маршрута съёмки как атрибут, Мбайт. Считается при построении из длительности
-- (см. ModeDataRates в pcp-types-lib): накопление 120 Мбайт/с. satellite_modes — только SURVEY.
ALTER TABLE satellite_modes ADD COLUMN volume_mb DOUBLE PRECISION NOT NULL DEFAULT 0;
-- Бэкфилл существующих маршрутов из длительности.
UPDATE satellite_modes SET volume_mb = duration * 120;
@@ -17,7 +17,6 @@ import space.nstart.pcp.pcp_satellites_service.service.ComplexMissionService
import space.nstart.pcp.pcp_satellites_service.service.ComplexPlanRunOrchestrationService
import space.nstart.pcp.pcp_types_lib.dto.requests.ComplexPlanProcessRequestDTO
import java.time.LocalDateTime
import java.time.ZoneOffset
import kotlin.test.assertContains
import kotlin.test.assertEquals
import kotlin.test.assertTrue
@@ -50,9 +49,9 @@ class ComplexPlanControllerTest {
intervalEnd = LocalDateTime.of(2026, 3, 26, 12, 0),
satelliteIds = listOf(22L),
requestedBy = "tester",
createdAt = LocalDateTime.of(2026, 3, 26, 9, 59).atOffset(ZoneOffset.UTC),
startedAt = LocalDateTime.of(2026, 3, 26, 10, 0).atOffset(ZoneOffset.UTC),
finishedAt = LocalDateTime.of(2026, 3, 26, 10, 1).atOffset(ZoneOffset.UTC),
createdAt = LocalDateTime.of(2026, 3, 26, 9, 59),
startedAt = LocalDateTime.of(2026, 3, 26, 10, 0),
finishedAt = LocalDateTime.of(2026, 3, 26, 10, 1),
durationMs = 60000,
errorMessage = null,
calculatedModesCount = 1,
@@ -22,9 +22,9 @@ import space.nstart.pcp.pcp_satellites_service.service.ComplexPlanRunPersistence
import space.nstart.pcp.pcp_satellites_service.service.SatelliteModePersistenceService
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SatelliteModeResponseDTO
import java.time.LocalDateTime
import java.time.ZoneOffset
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class ComplexPlanRunApiTest {
@@ -81,7 +81,7 @@ class ComplexPlanRunApiTest {
assertEquals(10.0, response.totalRequestsArea)
assertEquals(4.0, response.coveredArea)
assertEquals(40.0, response.coveredAreaPercent)
assertEquals(ZoneOffset.UTC, response.createdAt.offset)
assertNotNull(response.createdAt)
}
@Test
@@ -16,7 +16,6 @@ import space.nstart.pcp.pcp_types_lib.dto.requests.CoverageCalculationSource
import space.nstart.pcp.pcp_types_lib.dto.requests.ComplexPlanProcessRequestDTO
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotCoverageStrategy
import java.time.LocalDateTime
import java.time.ZoneOffset
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
@@ -191,9 +190,9 @@ class ComplexPlanRunOrchestrationServiceTest {
intervalEnd = request.intervalEnd,
satelliteIds = listOf(22L),
requestedBy = "tester",
createdAt = request.intervalStart.minusMinutes(1).atOffset(ZoneOffset.UTC),
startedAt = request.intervalStart.atOffset(ZoneOffset.UTC),
finishedAt = request.intervalStart.plusMinutes(1).atOffset(ZoneOffset.UTC),
createdAt = request.intervalStart.minusMinutes(1),
startedAt = request.intervalStart,
finishedAt = request.intervalStart.plusMinutes(1),
durationMs = 60_000,
errorMessage = null,
calculatedModesCount = 1,
@@ -301,9 +300,9 @@ class ComplexPlanRunOrchestrationServiceTest {
intervalEnd = request.intervalEnd,
satelliteIds = listOf(22L),
requestedBy = "tester",
createdAt = request.intervalStart.minusMinutes(1).atOffset(ZoneOffset.UTC),
startedAt = request.intervalStart.atOffset(ZoneOffset.UTC),
finishedAt = request.intervalStart.plusMinutes(1).atOffset(ZoneOffset.UTC),
createdAt = request.intervalStart.minusMinutes(1),
startedAt = request.intervalStart,
finishedAt = request.intervalStart.plusMinutes(1),
durationMs = 60_000,
errorMessage = null,
calculatedModesCount = 0,
@@ -7,7 +7,7 @@ spring:
import: "configserver:"
cloud:
config:
uri: ${CONFIG_SERVER_URI:http://192.168.60.201:8888}
uri: ${CONFIG_SERVER_URI:http://localhost:8888}
fail-fast: ${CONFIG_SERVER_FAIL_FAST:true}
profile: ${SPRING_CLOUD_CONFIG_PROFILE:${SPRING_PROFILES_ACTIVE:${spring.profiles.default}}}
label: ${SPRING_CLOUD_CONFIG_LABEL:dev}
@@ -6,30 +6,15 @@ import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
import com.fasterxml.jackson.module.kotlin.registerKotlinModule
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
@Configuration
class JacksonConfig {
@Bean
fun objectMapper(): ObjectMapper {
val mapper = ObjectMapper()
// Настраиваем формат для LocalDateTime
// val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.zzz")
// module.addSerializer<LocalDateTime?>(
// LocalDateTime::class.java,
// LocalDateTimeSerializer(formatter)
// )
// module.addDeserializer<LocalDateTime?>(
// LocalDateTime::class.java,
// LocalDateTimeDeserializer(formatter)
// )
mapper.registerModule(JavaTimeModule())
.registerKotlinModule()
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
return mapper
}
}
@@ -841,7 +841,7 @@ class BasicPlanCalculator(
val resultFilePath = if (request.saveResultToFile && status == ComplexPlanCalculationStatus.COMPLETED) {
observationParametersDebugFileWriter.write(
ObservationParametersDebugFileDTO(
createdAt = Instant.now(),
createdAt = LocalDateTime.now(),
requestId = request.requestId,
satelliteIds = selectedSatelliteIds,
missingSatelliteIds = missingSatelliteIds,
@@ -7,7 +7,7 @@ import org.springframework.web.client.HttpClientErrorException
import org.springframework.web.client.RestClient
import org.springframework.web.util.UriComponentsBuilder
import space.nstart.pcp.complan.types.RequestItemDTO
import java.time.OffsetDateTime
import java.time.LocalDateTime
import java.util.UUID
/** Получение заявок из pcp-request-service /v1 API. */
@@ -43,8 +43,9 @@ class RequestServiceClient {
id = id,
name = name,
geometry = geometry,
intervalBegin = beginDateTime.toLocalDateTime(),
intervalEnd = endDateTime.toLocalDateTime(),
// pcp-request-service шлёт naive LocalDateTime без зоны — берём как есть (настенные часы UTC).
intervalBegin = beginDateTime,
intervalEnd = endDateTime,
resolution = resolution(),
importance = importance,
)
@@ -63,16 +64,16 @@ data class RequestResponseDto(
val surveyType: String = "",
val geometry: String,
val importance: Double,
val beginDateTime: OffsetDateTime,
val endDateTime: OffsetDateTime,
val beginDateTime: LocalDateTime,
val endDateTime: LocalDateTime,
val kpp: List<Int> = emptyList(),
val highPriorityTransmit: Boolean = false,
val optics: SurveyParamsDto? = null,
val rsa: SurveyParamsDto? = null,
val coverage: CoverageStateDto = CoverageStateDto(),
val createdAt: OffsetDateTime? = null,
val updatedAt: OffsetDateTime? = null,
val deletedAt: OffsetDateTime? = null,
val createdAt: LocalDateTime? = null,
val updatedAt: LocalDateTime? = null,
val deletedAt: LocalDateTime? = null,
)
@JsonIgnoreProperties(ignoreUnknown = true)
@@ -3,7 +3,6 @@ package space.nstart.pcp.complan.types
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.annotation.JsonProperty
import io.swagger.v3.oas.annotations.media.Schema
import java.time.Instant
import java.time.LocalDateTime
import java.util.UUID
@@ -116,7 +115,7 @@ data class ObservationParametersDebugFileDTO(
@get:JsonProperty("createdAt")
@param:JsonProperty("createdAt")
val createdAt: Instant,
val createdAt: LocalDateTime,
@get:JsonProperty("requestId")
@param:JsonProperty("requestId")
@@ -7,7 +7,7 @@ spring:
import: "configserver:"
cloud:
config:
uri: ${CONFIG_SERVER_URI:http://192.168.60.201:8888}
uri: ${CONFIG_SERVER_URI:http://localhost:8888}
fail-fast: ${CONFIG_SERVER_FAIL_FAST:true}
profile: ${SPRING_CLOUD_CONFIG_PROFILE:${SPRING_PROFILES_ACTIVE:${spring.profiles.default}}}
label: ${SPRING_CLOUD_CONFIG_LABEL:master}
@@ -15,7 +15,7 @@ spring:
# max-in-memory-size: 16MB
datasource:
driver-class-name: org.postgresql.Driver
url: jdbc:postgresql://192.168.60.201:5432/complan
url: jdbc:postgresql://${PCP_POSTGRES_HOST:localhost}:${PCP_POSTGRES_PORT:5432}/complan
username: postgres
password: password
jpa:
@@ -9,7 +9,6 @@ import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign
import space.nstart.pcp.pcp_types_lib.dto.ballistics.SquareViewParamDTO
import java.nio.file.Files
import java.nio.file.Path
import java.time.Instant
import java.time.LocalDateTime
import java.util.UUID
import kotlin.test.Test
@@ -29,7 +28,7 @@ class ObservationParametersDebugFileWriterTest {
outputDir = tempDir.resolve("observation-parameters").toString()
)
val artifact = ObservationParametersDebugFileDTO(
createdAt = Instant.parse("2026-05-05T10:15:30Z"),
createdAt = LocalDateTime.parse("2026-05-05T10:15:30"),
requestId = requestId,
satelliteIds = listOf(1001L),
missingSatelliteIds = emptyList(),
@@ -65,6 +65,8 @@ class RequestServiceClientTest {
private fun serverWithRequestResponse(
requestedUris: MutableList<URI>,
requestId: UUID,
beginDateTime: String = "2026-01-02T03:04:05",
endDateTime: String = "2026-01-03T04:05:06",
): HttpServer =
HttpServer.create(InetSocketAddress(0), 0).apply {
createContext("/v1/requests") { exchange ->
@@ -77,8 +79,8 @@ class RequestServiceClientTest {
"surveyType": "OPTICS",
"geometry": "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))",
"importance": 7.5,
"beginDateTime": "2026-01-02T03:04:05Z",
"endDateTime": "2026-01-03T04:05:06Z",
"beginDateTime": "$beginDateTime",
"endDateTime": "$endDateTime",
"kpp": [1],
"highPriorityTransmit": false,
"optics": {
@@ -90,8 +92,8 @@ class RequestServiceClientTest {
},
"rsa": null,
"coverage": {"currentPercent": 0.0},
"createdAt": "2026-01-01T00:00:00Z",
"updatedAt": "2026-01-01T00:00:00Z",
"createdAt": "2026-01-01T00:00:00",
"updatedAt": "2026-01-01T00:00:00",
"deletedAt": null
}
""".trimIndent()
@@ -2,10 +2,16 @@ package space.nstart.pcp.pcp_mission_planing_service
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import org.springframework.context.annotation.Import
import org.springframework.kafka.annotation.EnableKafka
import space.nstart.pcp.pcp_types_lib.configuration.GlobalExceptionHandler
@EnableKafka
@SpringBootApplication
// GlobalExceptionHandler лежит в пакете pcp-types-lib вне базового component-scan этого сервиса,
// поэтому регистрируем @ControllerAdvice явно — иначе CustomErrorException/CustomValidationException
// отдаются дефолтным WebFlux-хендлером как голый 500 без тела с сообщением.
@Import(GlobalExceptionHandler::class)
class PcpMissionPlaningServiceApplication
fun main(args: Array<String>) {
@@ -0,0 +1,31 @@
package space.nstart.pcp.pcp_mission_planing_service.controller
import java.time.LocalDateTime
import java.util.UUID
/** Допустимые значения [MissionCloneRequest.mode]. */
object CloneMode {
const val REPLACE = "replace"
const val FORK_FORWARD = "fork-forward"
}
data class MissionCloneRequest(
/** [CloneMode.REPLACE] — полный клон; [CloneMode.FORK_FORWARD] — только включения, перекрывающиеся с окном. */
val mode: String,
/** Начало окна нового плана (naive, локальное время). Обязательно для fork-forward. */
val windowStart: LocalDateTime? = null,
/** Конец окна нового плана (naive, локальное время). Обязательно для fork-forward. */
val windowStop: LocalDateTime? = null
)
data class MissionCloneResponse(val newMissionId: UUID)
/** Запрос создания пустой миссии (план новой цепочки «без истории»). */
data class MissionBlankRequest(
/** КА (= noradId). */
val satelliteId: Long,
/** Начало окна миссии (naive, локальное время). */
val missionStart: LocalDateTime,
/** Конец окна миссии (naive, локальное время). */
val missionStop: LocalDateTime
)
@@ -12,6 +12,7 @@ import jakarta.validation.Valid
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PatchMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.PutMapping
@@ -20,8 +21,10 @@ import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.ModeResponseDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.DropRouteSaveRequestDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.MissionRequestDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.MissionSurveyCalculationResponseDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SurveyRouteBuildRequestDTO
import space.nstart.pcp.pcp_mission_planing_service.service.MissionPlaningService
import java.time.LocalDateTime
import java.util.UUID
@@ -34,6 +37,54 @@ class MissionController(
private val missionPlaningService: MissionPlaningService
) {
@Operation(
summary = "Построить один маршрут съёмки",
description = "Строит контур полосы съёмки по параметрам видимости точки (момент, крен, угол захвата). " +
"Орбитальные точки берутся из pcp-ballistics-service, контур — тем же алгоритмом, что и при расчёте плана съёмки. " +
"Маршрут не сохраняется в БД, только возвращается клиенту (live-превью)."
)
@PostMapping("/survey-route/build")
fun buildSurveyRoute(@Valid @RequestBody body: SurveyRouteBuildRequestDTO) =
missionPlaningService.buildSurveyRoute(body)
@Operation(
summary = "Построить и сохранить маршрут съёмки по выбранному плану",
description = "Строит контур полосы съёмки тем же алгоритмом, что и /survey-route/build, " +
"и сохраняет его в БД как режим съёмки (source = MANUAL) под основным планом миссии. " +
"planId — идентификатор выбранного плана в pcp-tgu-service, совпадает с mission_id этого сервиса. " +
"Адресуется существующая миссия-черновик (DRAFT-клон); если миссии с таким id нет — 404."
)
@ApiResponses(
value = [
ApiResponse(responseCode = "200", description = "Маршрут построен и сохранён")
]
)
@PostMapping("/{plan_id}/survey-route")
fun saveSurveyRoute(
@Parameter(description = "Идентификатор выбранного плана (= mission_id)")
@PathVariable("plan_id") planId: UUID,
@Valid @RequestBody body: SurveyRouteBuildRequestDTO
) = missionPlaningService.saveSurveyRoute(planId, body)
@Operation(
summary = "Добавить сброс вручную по выбранной зоне радиовидимости",
description = "Сохраняет режим сброса (DROP) под основным планом миссии по выбранной оператором ЗРВ " +
"и набору маршрутов съёмки, заканчивающихся строго до начала зоны. " +
"planId — идентификатор выбранного плана в pcp-tgu-service (= mission_id). " +
"Адресуется существующая миссия-черновик (DRAFT-клон); если миссии с таким id нет — 404."
)
@ApiResponses(
value = [
ApiResponse(responseCode = "200", description = "Сброс добавлен")
]
)
@PostMapping("/{plan_id}/drop-route")
fun saveDropRoute(
@Parameter(description = "Идентификатор выбранного плана (= mission_id)")
@PathVariable("plan_id") planId: UUID,
@Valid @RequestBody body: DropRouteSaveRequestDTO
) = missionPlaningService.saveDropRoute(planId, body)
@Operation(summary = "Получить список миссий")
@GetMapping
fun all() = missionPlaningService.all()
@@ -161,6 +212,90 @@ class MissionController(
return ResponseEntity.noContent().build()
}
@Operation(
summary = "Перестроить маршрут съёмки",
description = "Перестраивает контур существующего режима съёмки по новым параметрам (окно/крен/" +
"угол захвата) тем же алгоритмом, что и /survey-route/build, и сохраняет результат на месте. " +
"Используется для правки включения в плане-черновике."
)
@ApiResponses(
value = [
ApiResponse(responseCode = "200", description = "Маршрут перестроен"),
ApiResponse(responseCode = "404", description = "Режим съёмки не найден")
]
)
@PatchMapping("/survey-modes/{mode_id}")
fun updateSurveyRoute(
@Parameter(description = "Идентификатор режима съёмки")
@PathVariable("mode_id") modeId: Long,
@Valid @RequestBody body: SurveyRouteBuildRequestDTO
) = missionPlaningService.updateSurveyRoute(modeId, body)
@Operation(
summary = "Удалить включение (съёмку или сброс)",
description = "Удаляет режим (SURVEY или DROP) из плана-черновика по его id. Для съёмки снимаются " +
"брони слотов и связи со сбросами, осиротевший сброс (без единой съёмки) тоже удаляется. " +
"Для сброса удаляются только его связи; съёмки сохраняются."
)
@ApiResponses(
value = [
ApiResponse(responseCode = "204", description = "Включение удалено"),
ApiResponse(responseCode = "404", description = "Режим не найден")
]
)
@DeleteMapping("/modes/{mode_id}")
fun deleteMode(
@Parameter(description = "Идентификатор режима (survey или drop)")
@PathVariable("mode_id") modeId: Long
): ResponseEntity<Void> {
missionPlaningService.deleteMode(modeId)
return ResponseEntity.noContent().build()
}
@Operation(
summary = "Очистить включения плана",
description = "Удаляет включения плана миссии вместе с бронями слотов и связями съёмка↔сброс. " +
"Сбросы удаляются всегда. Для съёмок поведение выбирает оператор флагом keepManual: " +
"keepManual=true — ручные маршруты съёмки (source=MANUAL) сохраняются, удаляются только " +
"рассчитанные (как при пересчёте плана); keepManual=false (по умолчанию) — удаляются ВСЕ " +
"съёмки, включая ручные. Используется кнопкой «Очистить план» в редакторе."
)
@ApiResponses(
value = [
ApiResponse(responseCode = "204", description = "План очищен"),
ApiResponse(responseCode = "404", description = "Миссия не найдена")
]
)
@DeleteMapping("/{mission_id}/modes")
fun clearPlan(
@Parameter(description = "Идентификатор миссии")
@PathVariable("mission_id") id: UUID,
@Parameter(description = "Сохранить ручные маршруты съёмки (source=MANUAL)")
@RequestParam(name = "keepManual", required = false, defaultValue = "false") keepManual: Boolean
): ResponseEntity<Void> {
missionPlaningService.clearPlan(id, keepManual)
return ResponseEntity.noContent().build()
}
@Operation(
summary = "Проверить миссию-черновик на конфликты",
description = "Считает конфликты/наложения включений плана-черновика и возвращает дифф по " +
"режимам. MVP: валидатор-заглушка — контракт корректен, список конфликтов пуст; " +
"реальные правила (пересечения по времени, видимость цели, ЗРВ, порядок сброса, борт) — " +
"отдельной задачей."
)
@ApiResponses(
value = [
ApiResponse(responseCode = "200", description = "Валидация выполнена, конфликты в теле ответа"),
ApiResponse(responseCode = "404", description = "Миссия не найдена")
]
)
@PostMapping("/{mission_id}/validate")
fun validateMission(
@Parameter(description = "Идентификатор миссии-черновика")
@PathVariable("mission_id") id: UUID
): MissionValidationResponse = missionPlaningService.validateMission(id)
@Operation(summary = "Создать миссию")
@@ -178,4 +313,25 @@ class MissionController(
missionPlaningService.delete(id)
return ResponseEntity.noContent().build()
}
@Operation(
summary = "Клонировать миссию",
description = "Создаёт полный (replace) или частичный (fork-forward) клон миссии с новым missionId. " +
"Для fork-forward клонируются только включения, перекрывающиеся с окном [windowStart, windowStop). " +
"Возвращает newMissionId, который tgu-service использует как planId черновика."
)
@PostMapping("/{mission_id}/clone")
fun cloneMission(
@PathVariable("mission_id") id: UUID,
@RequestBody body: MissionCloneRequest
): MissionCloneResponse = missionPlaningService.cloneMission(id, body)
@Operation(
summary = "Создать пустую миссию",
description = "Создаёт миссию без режимов на заданное окно — для плана новой цепочки «без истории». " +
"Возвращает newMissionId, который tgu-service использует как planId черновика."
)
@PostMapping("/blank")
fun createBlankMission(@RequestBody body: MissionBlankRequest): MissionCloneResponse =
missionPlaningService.createBlankMission(body)
}
@@ -0,0 +1,55 @@
package space.nstart.pcp.pcp_mission_planing_service.controller
import java.util.UUID
/**
* Коды правил валидации. MVP: валидатор-заглушка их **не вычисляет** список существует как
* стабильный контракт для фронта, чтобы появление реальных правил (раздел 6 дизайн-дока) не ломало схему.
*/
object ConflictRule {
/** Пересечение по времени между включениями (съёмка↔съёмка, съёмка↔сброс, сброс↔сброс). */
const val TIME_OVERLAP = "TIME_OVERLAP"
/** Окно съёмки вне окна видимости цели (баллистика). */
const val TARGET_VISIBILITY = "TARGET_VISIBILITY"
/** Окно сброса вне зоны радиовидимости станции. */
const val COMMS_SESSION = "COMMS_SESSION"
/** Сбрасываемая съёмка не завершается строго до начала зоны сброса. */
const val DROP_ORDER = "DROP_ORDER"
/** Бортовые ограничения: длительность/скважность, энергетика, память. */
const val ONBOARD_LIMIT = "ONBOARD_LIMIT"
}
/** Уровень конфликта: [ERROR] блокирует «Применить», [WARNING] — информативный. */
object ConflictSeverity {
const val ERROR = "error"
const val WARNING = "warning"
}
/**
* Один конфликт на затронутом включении. Возвращается диффом по режимам (а не как весь план),
* чтобы фронт мог точечно подсвечивать проблемные включения.
*
* @param modeId id режима (survey или drop), к которому относится конфликт.
* @param rule код правила, см. [ConflictRule].
* @param severity уровень, см. [ConflictSeverity].
* @param message человекочитаемое описание для оператора.
* @param relatedModeId id второго участника парного правила (например, пересечения по времени), если есть.
*/
data class ModeConflict(
val modeId: Long,
val rule: String,
val severity: String,
val message: String,
val relatedModeId: Long? = null
)
/**
* Результат валидации миссии-черновика.
*
* MVP: [conflicts] всегда пуст реальные правила ещё не считаются (TODO раздела 6 дизайн-дока).
* Контракт стабилен: фронт уже завязывается на него и не сломается, когда правила появятся.
*/
data class MissionValidationResponse(
val missionId: UUID,
val conflicts: List<ModeConflict>
)
@@ -3,8 +3,11 @@ package space.nstart.pcp.pcp_mission_planing_service.entity
import jakarta.persistence.Column
import jakarta.persistence.DiscriminatorValue
import jakarta.persistence.Entity
import jakarta.persistence.PrePersist
import jakarta.persistence.PreUpdate
import jakarta.persistence.Table
import java.time.OffsetDateTime
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.ModeDataRates
import java.time.LocalDateTime
@Entity
@Table(name = "drop_modes")
@@ -12,7 +15,7 @@ import java.time.OffsetDateTime
class DropModeEntity(
id: Long? = null,
plan: PlanEntity? = null,
timeStart: OffsetDateTime? = null,
timeStart: LocalDateTime? = null,
revolution: Long = 0,
@Column(name = "station")
@@ -20,4 +23,14 @@ class DropModeEntity(
@Column(name = "duration", nullable = false)
var duration: Double = 0.0
) : ModeEntity(id, plan, timeStart, revolution)
) : ModeEntity(id, plan, timeStart, revolution) {
/** Объём сброса, Мбайт. Денормализация: считается из duration (канал 1200 Мбит/с ÷ 8) при сохранении. */
@Column(name = "volume_mb", nullable = false)
var volumeMb: Double = ModeDataRates.dropVolumeMb(duration)
@PrePersist
@PreUpdate
fun computeVolumeMb() {
volumeMb = ModeDataRates.dropVolumeMb(duration)
}
}
@@ -13,7 +13,7 @@ import jakarta.persistence.InheritanceType
import jakarta.persistence.JoinColumn
import jakarta.persistence.ManyToOne
import jakarta.persistence.Table
import java.time.OffsetDateTime
import java.time.LocalDateTime
@Entity
@Table(name = "modes")
@@ -29,7 +29,7 @@ abstract class ModeEntity(
open var plan: PlanEntity? = null,
@Column(name = "time_start")
open var timeStart: OffsetDateTime? = null,
open var timeStart: LocalDateTime? = null,
@Column(name = "revolution", nullable = false)
open var revolution: Long = 0
@@ -5,9 +5,12 @@ import jakarta.persistence.DiscriminatorValue
import jakarta.persistence.Entity
import jakarta.persistence.EnumType
import jakarta.persistence.Enumerated
import jakarta.persistence.PrePersist
import jakarta.persistence.PreUpdate
import jakarta.persistence.Table
import space.nstart.pcp.pcp_types_lib.dto.routes.SurveyModeStatus
import java.time.OffsetDateTime
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.ModeDataRates
import java.time.LocalDateTime
@Entity
@Table(name = "survey_modes")
@@ -15,7 +18,7 @@ import java.time.OffsetDateTime
class SurveyModeEntity(
id: Long? = null,
plan: PlanEntity? = null,
timeStart: OffsetDateTime? = null,
timeStart: LocalDateTime? = null,
revolution: Long = 0,
@Column(name = "lat", nullable = false)
@@ -46,4 +49,14 @@ class SurveyModeEntity(
@Enumerated(EnumType.STRING)
@Column(name = "source", nullable = false, length = 20)
var source: SurveyModeSource = SurveyModeSource.SLOT
) : ModeEntity(id, plan, timeStart, revolution)
) : ModeEntity(id, plan, timeStart, revolution) {
/** Объём маршрута съёмки, Мбайт. Денормализация: считается из duration при построении/сохранении. */
@Column(name = "volume_mb", nullable = false)
var volumeMb: Double = ModeDataRates.surveyVolumeMb(duration)
@PrePersist
@PreUpdate
fun computeVolumeMb() {
volumeMb = ModeDataRates.surveyVolumeMb(duration)
}
}
@@ -7,4 +7,7 @@ import java.util.UUID
interface DropModeSurveyRepository : JpaRepository<DropModeSurveyEntity, Long> {
fun findAllByDropMode_IdOrderBySurveyMode_TimeStartAsc(dropModeId: Long): List<DropModeSurveyEntity>
fun findAllByDropMode_Plan_Mission_MissionId(missionId: UUID): List<DropModeSurveyEntity>
/** Связи сброс↔съёмка по конкретной съёмке (для удаления съёмки и поиска осиротевших сбросов). */
fun findAllBySurveyMode_Id(surveyModeId: Long): List<DropModeSurveyEntity>
}
@@ -6,7 +6,7 @@ import org.springframework.data.jpa.repository.Modifying
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
import space.nstart.pcp.pcp_mission_planing_service.entity.ModeEntity
import java.time.OffsetDateTime
import java.time.LocalDateTime
import java.util.UUID
interface ModeRepository : JpaRepository<ModeEntity, Long> {
@@ -14,7 +14,7 @@ interface ModeRepository : JpaRepository<ModeEntity, Long> {
fun findAllByPlan_Mission_MissionIdOrderByTimeStartAsc(missionId: UUID): List<ModeEntity>
fun findAllByPlan_Mission_SatelliteIdAndTimeStartIsNotNullAndTimeStartLessThanEqualOrderByTimeStartAsc(
satelliteId: Long,
timeStop: OffsetDateTime
timeStop: LocalDateTime
): List<ModeEntity>
fun countByPlan_Mission_SatelliteId(satelliteId: Long): Long
@@ -24,6 +24,20 @@ interface ModeRepository : JpaRepository<ModeEntity, Long> {
@Query("DELETE FROM modes WHERE plan_id = :planId AND type = 'SURVEY'", nativeQuery = true)
fun deleteSurveyModesByPlanId(@Param("planId") planId: Long): Int
/**
* Удаляет только пересчитываемые маршруты съёмки плана, сохраняя ручные
* (source = 'MANUAL'). Используется при пересчёте плана (этап 5, loadSlots),
* чтобы не затирать добавленные оператором съёмки.
*/
@Modifying
@Transactional
@Query(
"DELETE FROM modes WHERE plan_id = :planId AND type = 'SURVEY' " +
"AND id NOT IN (SELECT id FROM survey_modes WHERE source = 'MANUAL')",
nativeQuery = true
)
fun deleteRecalculableSurveyModesByPlanId(@Param("planId") planId: Long): Int
@Modifying
@Transactional
@Query("DELETE FROM modes WHERE plan_id = :planId AND type = 'DROP'", nativeQuery = true)
@@ -4,7 +4,7 @@ import org.springframework.data.jpa.repository.Query
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.repository.query.Param
import space.nstart.pcp.pcp_mission_planing_service.entity.SurveyModeEntity
import java.time.OffsetDateTime
import java.time.LocalDateTime
import java.util.UUID
interface SurveyModeRepository : JpaRepository<SurveyModeEntity, Long> {
@@ -26,7 +26,7 @@ interface SurveyModeRepository : JpaRepository<SurveyModeEntity, Long> {
fun findAllBySatelliteNumberAndRevolutionAndTimeStartBetween(
@Param("satelliteNumber") satelliteNumber: String,
@Param("revolution") revolution: Long,
@Param("timeStartFrom") timeStartFrom: OffsetDateTime,
@Param("timeStartTo") timeStartTo: OffsetDateTime
@Param("timeStartFrom") timeStartFrom: LocalDateTime,
@Param("timeStartTo") timeStartTo: LocalDateTime
): List<SurveyModeEntity>
}
@@ -20,11 +20,20 @@ import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import org.springframework.web.server.ResponseStatusException
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.DropModeResponseDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.DropRouteSaveRequestDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.MissionRequestDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.MissionResponseDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.MissionSurveyCalculationResponseDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.ModeResponseDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SurveyModeResponseDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SurveyRouteAnchor
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SurveyRouteBuildRequestDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SurveyRouteBuildResponseDTO
import space.nstart.pcp.pcp_mission_planing_service.controller.CloneMode
import space.nstart.pcp.pcp_mission_planing_service.controller.MissionBlankRequest
import space.nstart.pcp.pcp_mission_planing_service.controller.MissionCloneRequest
import space.nstart.pcp.pcp_mission_planing_service.controller.MissionCloneResponse
import space.nstart.pcp.pcp_mission_planing_service.controller.MissionValidationResponse
import space.nstart.pcp.pcp_mission_planing_service.entity.DropModeEntity
import space.nstart.pcp.pcp_mission_planing_service.entity.DropModeSurveyEntity
import space.nstart.pcp.pcp_mission_planing_service.entity.MissionEntity
@@ -53,7 +62,6 @@ import space.nstart.pcp.pcp_types_lib.dto.requests.slots.BookedSlotStatus
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SatelliteModeResponseDTO
import java.time.Duration
import java.time.LocalDateTime
import java.time.ZoneOffset
import java.util.UUID
import kotlin.math.PI
import kotlin.math.abs
@@ -120,6 +128,157 @@ class MissionPlaningService(
missionRepository.deleteById(id)
}
/**
* Клонирует миссию и все её включения в новый `missionId`.
*
* Режим `replace` полный глубокий клон: Mission + Plan + все Mode (SurveyMode/DropMode).
* Режим `fork-forward` клонирует только включения, чей интервал `[timeStart, timeStart+duration)`
* пересекается с окном `[windowStart, windowStop)`. DropMode клонируется только если хотя бы одна
* из его связанных съёмок тоже попала в клон (иначе сброс становится пустым).
*
* Намеренные особенности fork-forward:
* - Времена включений переносятся как есть. Включение, начавшееся до `windowStart` и закончившееся
* внутри окна, клонируется со своим исходным `timeStart` он может быть раньше `missionStart`
* клона. Границы миссии описывают слот целевой ЗРВ, а не оболочку времён её включений.
* - Съёмка переносится, даже если её сброс не попал в окно: сброс будет перевыдан в новом плане.
* Обратное неверно сброс без единой перенесённой съёмки отбрасывается.
*
* Новый `missionId` передаётся в ответе; он используется как `planId` черновика в pcp-tgu-service
* (инвариант planId == missionId сохраняется).
*/
@Transactional
fun cloneMission(sourceMissionId: UUID, req: MissionCloneRequest): MissionCloneResponse {
if (req.mode != CloneMode.REPLACE && req.mode != CloneMode.FORK_FORWARD) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Unknown clone mode: ${req.mode}")
}
val isForkForward = req.mode == CloneMode.FORK_FORWARD
if (isForkForward && (req.windowStart == null || req.windowStop == null)) {
throw ResponseStatusException(
HttpStatus.BAD_REQUEST,
"windowStart and windowStop are required for fork-forward clone"
)
}
val source = missionRepository.findById(sourceMissionId).orElseThrow { notFound(sourceMissionId) }
val allModes = modeRepository.findAllByPlan_Mission_MissionIdOrderByTimeStartAsc(sourceMissionId)
val modesToClone: List<ModeEntity> = if (isForkForward) {
allModes.filter { mode -> mode.intersects(req.windowStart!!, req.windowStop!!) }
} else {
allModes
}
val (newMissionStart, newMissionStop) = if (isForkForward) {
req.windowStart!! to req.windowStop!!
} else {
source.missionStart to source.missionStop
}
val newMission = missionRepository.save(
MissionEntity(
satelliteId = source.satelliteId,
station = source.station,
missionStart = newMissionStart,
missionStop = newMissionStop,
status = "NEW"
)
)
val newPlan = planRepository.save(
PlanEntity(
mission = newMission,
planStart = newMissionStart,
planStop = newMissionStop,
planNumber = 1
)
)
// Клонируем съёмки и строим маппинг старый id → новая сущность для перевязки сбросов.
val surveyIdMapping = mutableMapOf<Long, SurveyModeEntity>()
modesToClone.filterIsInstance<SurveyModeEntity>().forEach { src ->
val cloned = surveyModeRepository.save(
SurveyModeEntity(
plan = newPlan,
timeStart = src.timeStart,
revolution = src.revolution,
lat = src.lat,
long = src.long,
duration = src.duration,
contourWkt = src.contourWkt,
roll = src.roll,
status = SurveyModeStatus.PLANNED,
source = src.source
)
)
src.id?.let { surveyIdMapping[it] = cloned }
}
// Клонируем сбросы; пропускаем если все их связанные съёмки вышли за окно (пустой сброс).
modesToClone.filterIsInstance<DropModeEntity>().forEach { src ->
val srcId = src.id ?: return@forEach
val links = dropModeSurveyRepository.findAllByDropMode_IdOrderBySurveyMode_TimeStartAsc(srcId)
val remappedSurveys = links.mapNotNull { link -> link.surveyMode?.id?.let { surveyIdMapping[it] } }
if (remappedSurveys.isEmpty()) {
return@forEach
}
val newDrop = dropModeRepository.save(
DropModeEntity(
plan = newPlan,
timeStart = src.timeStart,
revolution = src.revolution,
station = src.station,
duration = src.duration
)
)
remappedSurveys.forEach { survey ->
dropModeSurveyRepository.save(DropModeSurveyEntity(dropMode = newDrop, surveyMode = survey))
}
}
val clonedDropCount = modesToClone.count { it is DropModeEntity }
logger.info(
"Клонирована миссия {}: mode={}, новый id={}, съёмок={}, сбросов={}",
sourceMissionId, req.mode, newMission.missionId, surveyIdMapping.size, clonedDropCount
)
return MissionCloneResponse(newMissionId = newMission.missionId)
}
/**
* Создаёт пустую миссию (без режимов) на заданное окно для плана новой цепочки «без истории».
* Возвращает новый missionId, который tgu использует как planId черновика; включения оператор
* добавляет сам в редакторе. Создаётся миссия (status=NEW) и её основной план (planNumber=1).
*/
fun createBlankMission(req: MissionBlankRequest): MissionCloneResponse {
if (!req.missionStop.isAfter(req.missionStart)) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "missionStop must be after missionStart")
}
val mission = missionRepository.save(
MissionEntity(
satelliteId = req.satelliteId,
station = null,
missionStart = req.missionStart,
missionStop = req.missionStop,
status = "NEW"
)
)
planRepository.save(
PlanEntity(
mission = mission,
planStart = req.missionStart,
planStop = req.missionStop,
planNumber = 1
)
)
logger.info(
"Создана пустая миссия {}: satelliteId={}, окно [{}{}]",
mission.missionId, req.satelliteId, req.missionStart, req.missionStop
)
return MissionCloneResponse(newMissionId = mission.missionId)
}
fun modesByMission(id: UUID): List<ModeResponseDTO> {
if (!missionRepository.existsById(id)) {
throw notFound(id)
@@ -144,7 +303,7 @@ class MissionPlaningService(
val modes = modeRepository
.findAllByPlan_Mission_SatelliteIdAndTimeStartIsNotNullAndTimeStartLessThanEqualOrderByTimeStartAsc(
satelliteId,
timeStop.atOffset(ZoneOffset.UTC)
timeStop
)
.filter { it.intersects(timeStart, timeStop) }
@@ -185,14 +344,18 @@ class MissionPlaningService(
.mapValues { (_, slotIds) -> slotIds.filter { it > 0 }.distinct() }
}
/**
* Пересечение интервала включения с окном `[intervalStart, intervalStop)` (правый конец открыт).
* Применяется при fork-forward-клоне: включения строго вне окна не клонируются.
*/
private fun ModeEntity.intersects(intervalStart: LocalDateTime, intervalStop: LocalDateTime): Boolean {
val modeStart = timeStart?.toLocalDateTime() ?: return false
val modeStart = timeStart ?: return false
val modeStop = modeStop() ?: return false
return !modeStop.isBefore(intervalStart) && !modeStart.isAfter(intervalStop)
return modeStop.isAfter(intervalStart) && modeStart.isBefore(intervalStop)
}
private fun ModeEntity.modeStop(): LocalDateTime? {
val modeStart = timeStart?.toLocalDateTime() ?: return null
val modeStart = timeStart ?: return null
val durationSeconds = when (this) {
is SurveyModeEntity -> duration
is DropModeEntity -> duration
@@ -349,7 +512,12 @@ class MissionPlaningService(
survs.count { it.bookedSlotIds.isNotEmpty() }
)
modeRepository.deleteSurveyModesByPlanId(planId)
val removedRecalculable = modeRepository.deleteRecalculableSurveyModesByPlanId(planId)
logger.info(
"Пересчёт плана {}: удалено {} пересчитываемых маршрутов съёмки, ручные (source=MANUAL) сохранены",
planId,
removedRecalculable
)
if (survs.isEmpty()) {
val finishedAt = LocalDateTime.now()
@@ -499,6 +667,400 @@ class MissionPlaningService(
)
/**
* Построение одного маршрута съёмки по параметрам видимости точки
* (используется вкладкой «Редактор» в pcp-tgu-ops-ui для построения включений съёмок).
*
* Stateless: орбитальные точки берутся из pcp-ballistics-service (extract-time), контур
* полосы строится тем же алгоритмом [contour], что и при штатном расчёте маршрутов
* (этап 5, [loadSlots] snapshot этапа 4). Маршрут только возвращается клиенту для
* отображения на картооснове (live-превью при выборе момента видимости), без персистенции.
* Сохранение построенного маршрута выполняет [saveSurveyRoute].
*/
fun buildSurveyRoute(req: SurveyRouteBuildRequestDTO): SurveyRouteBuildResponseDTO =
buildContour(req).toResponse(surveyModeId = null)
/**
* Построение и сохранение одного маршрута съёмки в БД по идентификатору выбранного плана
* в сервисе pcp-tgu-service.
*
* Идентификатор плана (`planId` из PlannedPlanEntity в pcp-tgu-service) совпадает с
* `MissionEntity.missionId` в этом сервисе (см. BPMN createSatelliteMission: переменная
* `satellitePlanId` `MissionRequestDTO.missionId`). Маршрут сохраняется как
* [SurveyModeEntity] с источником [SurveyModeSource.MANUAL] под основным планом миссии
* (planNumber = 1) той же таблицей `survey_modes`, что и при штатном расчёте плана.
*
* Адресуется только **существующая** миссия-черновик: правки редактора идут в DRAFT-клон,
* созданный заранее (`POST /api/missions/{id}/clone`). Авто-создание живой миссии на лету
* убрано нельзя молча писать правки в несуществующий/живой план. Если миссии с таким id
* нет 404.
*
* Ручные маршруты (source = MANUAL) переживают пересчёт плана: [loadSlots] удаляет только
* пересчитываемые маршруты ([ModeRepository.deleteRecalculableSurveyModesByPlanId]).
*/
@Transactional
fun saveSurveyRoute(planId: UUID, req: SurveyRouteBuildRequestDTO): SurveyRouteBuildResponseDTO {
// Проверяем существование миссии-черновика до дорогого расчёта контура (fail-fast на 404).
val mission = missionRepository.findById(planId).orElseThrow { notFound(planId) }
val plan = planRepository.findFirstByMissionMissionIdAndPlanNumber(mission.missionId, 1)
.orElseGet {
planRepository.save(
PlanEntity(
mission = mission,
planStart = mission.missionStart,
planStop = mission.missionStop,
planNumber = 1
)
)
}
val built = buildContour(req)
val saved = surveyModeRepository.save(
SurveyModeEntity(
plan = plan,
timeStart = built.timeStart,
revolution = built.revolution,
lat = built.lat,
long = built.longitude,
duration = built.durationSeconds,
contourWkt = built.contourWkt,
roll = built.roll,
status = SurveyModeStatus.PLANNED,
source = SurveyModeSource.MANUAL
)
)
logger.info(
"Сохранён ручной маршрут съёмки (survey_mode id={}) для плана/миссии {}: revolution={}, timeStart={}, duration={}",
saved.id,
planId,
built.revolution,
built.timeStart,
built.durationSeconds
)
return built.toResponse(surveyModeId = saved.id)
}
/**
* Ручное добавление сброса в план по выбранной оператором зоне радиовидимости (ЗРВ).
*
* Оператор на вкладке «Редактор» выбирает ЗРВ станции (из pcp-ballistics) и набор уже
* запланированных маршрутов съёмки, время конца которых строго меньше начала зоны. Сброс
* сохраняется как [DropModeEntity] под основным планом миссии (planNumber = 1) со связями
* съёмкасброс ([DropModeSurveyEntity]) той же моделью, что и при штатном расчёте
* ([calculateDrops]).
*
* Идентификатор плана (`planId`) совпадает с `MissionEntity.missionId` (см. saveSurveyRoute).
* Адресуется только **существующая** миссия-черновик (DRAFT-клон, созданный заранее). Авто-создание
* живой миссии на лету убрано если миссии с таким id нет, возвращается 404.
*
* Внимание: ручные сбросы НЕ защищены от пересчёта штатный [calculateDrops] удаляет все
* сбросы плана ([ModeRepository.deleteDropModesByPlanId]).
*/
@Transactional
fun saveDropRoute(planId: UUID, req: DropRouteSaveRequestDTO): DropModeResponseDTO {
if (req.surveyModeIds.isEmpty()) {
throw CustomErrorException("Не выбрано ни одного маршрута съёмки для сброса")
}
val mission = missionRepository.findById(planId).orElseThrow { notFound(planId) }
val plan = planRepository.findFirstByMissionMissionIdAndPlanNumber(mission.missionId, 1)
.orElseGet {
planRepository.save(
PlanEntity(
mission = mission,
planStart = mission.missionStart,
planStop = mission.missionStop,
planNumber = 1
)
)
}
// Грузим выбранные съёмки и валидируем: принадлежат этому плану и заканчиваются строго
// до начала зоны (съёмка успевает завершиться к сеансу сброса).
val surveys = surveyModeRepository.findAllById(req.surveyModeIds)
val foundIds = surveys.mapNotNull { it.id }.toSet()
val missing = req.surveyModeIds.filterNot { it in foundIds }
if (missing.isNotEmpty()) {
throw CustomErrorException("Маршруты съёмки не найдены: $missing")
}
surveys.forEach { survey ->
if (survey.plan?.id != plan.id) {
throw CustomErrorException("Маршрут съёмки ${survey.id} не относится к плану $planId")
}
val start = survey.timeStart
?: throw CustomErrorException("У маршрута съёмки ${survey.id} не задано время начала")
val surveyEnd = start
.plusNanos((survey.duration.coerceAtLeast(0.0) * 1_000_000_000L).toLong())
if (!surveyEnd.isBefore(req.zoneStart)) {
throw CustomErrorException(
"Маршрут съёмки ${survey.id} заканчивается ($surveyEnd) не раньше начала зоны (${req.zoneStart}) — сброс невозможен"
)
}
}
// Длительность сброса — сумма длительностей сброса по каждой съёмке (как в calculateDrops).
val usedDuration = surveys.sumOf { calculateDropDuration(it).coerceAtLeast(0.0) }
val zoneDuration = Duration.between(req.zoneStart, req.zoneStop).toMillis().toDouble() / 1000.0
if (zoneDuration > 0.0 && usedDuration > zoneDuration) {
throw CustomErrorException(
"Суммарная длительность сброса ($usedDuration с) превышает длительность зоны ($zoneDuration с)"
)
}
val dropMode = dropModeRepository.save(
DropModeEntity(
plan = plan,
timeStart = req.zoneStart,
revolution = req.revolution,
station = req.station,
duration = usedDuration
)
)
val links = surveys.map { survey -> DropModeSurveyEntity(dropMode = dropMode, surveyMode = survey) }
dropModeSurveyRepository.saveAll(links)
logger.info(
"Сохранён ручной сброс (drop_mode id={}) для плана/миссии {}: станция={}, виток={}, зона={}, съёмок={}, длительность={}",
dropMode.id, planId, req.station, req.revolution, req.zoneStart, surveys.size, usedDuration
)
return DropModeResponseDTO(
id = dropMode.id ?: 0,
planId = plan.id ?: 0,
timeStart = dropMode.timeStart,
revolution = dropMode.revolution,
station = dropMode.station,
duration = dropMode.duration,
surveys = surveys.mapNotNull { it.id }
)
}
/**
* Перестраивает контур существующего маршрута съёмки по новым параметрам (окно/крен/угол захвата)
* тем же алгоритмом [buildContour], что и [buildSurveyRoute]/[saveSurveyRoute], и сохраняет
* результат на месте (правка включения в черновике).
*
* Меняются: время начала, виток, центр (lat/long), длительность, контур (WKT) и крен. Статус
* сохраняется. Источник переводится в [SurveyModeSource.MANUAL] отредактированный оператором
* маршрут переживает пересчёт плана ([loadSlots] удаляет только пересчитываемые, не MANUAL).
*/
@Transactional
fun updateSurveyRoute(modeId: Long, req: SurveyRouteBuildRequestDTO): SurveyRouteBuildResponseDTO {
val survey = surveyModeRepository.findById(modeId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Survey mode with id $modeId not found")
}
val built = buildContour(req)
survey.timeStart = built.timeStart
survey.revolution = built.revolution
survey.lat = built.lat
survey.long = built.longitude
survey.duration = built.durationSeconds
survey.contourWkt = built.contourWkt
survey.roll = built.roll
survey.source = SurveyModeSource.MANUAL
val saved = surveyModeRepository.save(survey)
logger.info(
"Перестроен маршрут съёмки (survey_mode id={}): revolution={}, timeStart={}, duration={}, roll={}",
saved.id, built.revolution, built.timeStart, built.durationSeconds, built.roll
)
return built.toResponse(surveyModeId = saved.id)
}
/**
* Удаляет включение (SURVEY или DROP) из плана-черновика по его id.
*
* Съёмка: снимаются её брони слотов ([ModeBookingEntity]) и связи съёмкасброс
* ([DropModeSurveyEntity]); сброс, оставшийся без единой съёмки, тоже удаляется (осиротевший
* сеанс не имеет смысла). Другие съёмки того же сброса не трогаются.
*
* Сброс: удаляются только его связи и сам сброс; съёмки независимы и сохраняются.
*/
@Transactional
fun deleteMode(modeId: Long) {
val mode = modeRepository.findById(modeId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Mode with id $modeId not found")
}
when (mode) {
is SurveyModeEntity -> deleteSurveyMode(mode)
is DropModeEntity -> deleteDropMode(mode)
else -> throw ResponseStatusException(
HttpStatus.BAD_REQUEST,
"Unsupported mode type for id $modeId: ${mode::class.qualifiedName}"
)
}
}
private fun deleteSurveyMode(survey: SurveyModeEntity) {
val surveyId = survey.id ?: throw IllegalStateException("Survey mode id is null")
val bookings = modeBookingRepository.findAllByMode_IdIn(listOf(surveyId))
if (bookings.isNotEmpty()) {
modeBookingRepository.deleteAll(bookings)
}
val links = dropModeSurveyRepository.findAllBySurveyMode_Id(surveyId)
val affectedDropIds = links.mapNotNull { it.dropMode?.id }.distinct()
if (links.isNotEmpty()) {
dropModeSurveyRepository.deleteAll(links)
}
// Сброс, у которого после удаления этой съёмки не осталось ни одной другой, — осиротел.
affectedDropIds.forEach { dropId ->
val hasOtherSurvey = dropModeSurveyRepository
.findAllByDropMode_IdOrderBySurveyMode_TimeStartAsc(dropId)
.any { it.surveyMode?.id != null && it.surveyMode?.id != surveyId }
if (!hasOtherSurvey) {
dropModeRepository.deleteById(dropId)
}
}
surveyModeRepository.delete(survey)
logger.info("Удалена съёмка (survey_mode id={}), снято броней={}, осиротевших сбросов проверено={}",
surveyId, bookings.size, affectedDropIds.size)
}
private fun deleteDropMode(drop: DropModeEntity) {
val dropId = drop.id ?: throw IllegalStateException("Drop mode id is null")
val links = dropModeSurveyRepository.findAllByDropMode_IdOrderBySurveyMode_TimeStartAsc(dropId)
if (links.isNotEmpty()) {
dropModeSurveyRepository.deleteAll(links)
}
dropModeRepository.delete(drop)
logger.info("Удалён сброс (drop_mode id={}), снято связей со съёмками={}", dropId, links.size)
}
/**
* Очистка плана. Всегда удаляет все сбросы и брони слотов; для съёмок поведение выбирает
* оператор флагом [keepManual]:
* - `keepManual=true` сохраняются ручные маршруты съёмки (source=MANUAL), удаляются только
* рассчитанные (как и при пересчёте плана [loadSlots]/[calculateDrops]);
* - `keepManual=false` удаляются ВСЕ съёмки, включая ручные (полная очистка).
* Сбросы удаляются первыми, чтобы снять связи до удаления съёмок и не плодить проверку
* осиротевших сбросов.
*/
@Transactional
fun clearPlan(missionId: UUID, keepManual: Boolean) {
missionRepository.findById(missionId).orElseThrow { notFound(missionId) }
val modes = modeRepository.findAllByPlan_Mission_MissionIdOrderByTimeStartAsc(missionId)
val drops = modes.filterIsInstance<DropModeEntity>()
val surveys = modes.filterIsInstance<SurveyModeEntity>()
val (keptSurveys, deletedSurveys) =
if (keepManual) surveys.partition { it.source == SurveyModeSource.MANUAL }
else emptyList<SurveyModeEntity>() to surveys
drops.forEach { deleteDropMode(it) }
deletedSurveys.forEach { deleteSurveyMode(it) }
logger.info(
"План миссии {} очищен (keepManual={}): удалено сбросов={}, съёмок={}, сохранено ручных съёмок (MANUAL)={}",
missionId, keepManual, drops.size, deletedSurveys.size, keptSurveys.size
)
}
/**
* Валидация миссии-черновика на конфликты/наложения включений.
*
* MVP-заглушка: проверяет существование миссии (404, если нет) и возвращает корректный контракт
* с **пустым** списком конфликтов. Реальные правила (пересечения по времени, видимость цели, ЗРВ
* станции, порядок сброса, бортовые ограничения) TODO раздела 6 дизайн-дока. Контракт
* ([MissionValidationResponse]/[ModeConflict]) стабилен, чтобы фронт не ломался при их появлении.
*/
fun validateMission(missionId: UUID): MissionValidationResponse {
if (!missionRepository.existsById(missionId)) {
throw notFound(missionId)
}
return MissionValidationResponse(missionId = missionId, conflicts = emptyList())
}
/**
* Строит контур полосы съёмки по параметрам видимости точки.
* Чистый расчёт без персистенции; переиспользуется [buildSurveyRoute] и [saveSurveyRoute].
*/
private fun buildContour(req: SurveyRouteBuildRequestDTO): BuiltSurveyRoute {
if (req.durationSeconds <= 0.0) {
throw CustomErrorException("Длительность съёмки должна быть положительной")
}
val halfNanos = (req.durationSeconds / 2.0 * 1_000_000_000L).toLong()
val fullNanos = (req.durationSeconds * 1_000_000_000L).toLong()
val (tn, tk) = when (req.anchor) {
SurveyRouteAnchor.CENTERED -> req.time.minusNanos(halfNanos) to req.time.plusNanos(halfNanos)
SurveyRouteAnchor.FORWARD -> req.time to req.time.plusNanos(fullNanos)
}
val orbPoints = ballisticsService.exactTime(
req.satelliteId,
ExactTimePositionRequestDTO(time = tn, timeStop = tk, stepMs = 5000)
).toList()
if (orbPoints.isEmpty()) {
throw CustomErrorException(
"Нет орбитальных точек для КА ${req.satelliteId} на интервале [$tn - $tk]"
)
}
val stepper = RungeStepper(
orbPoints.map { p ->
OrbitalPoint(
t = fromDateTime(p.time),
vit = p.revolution.toInt(),
r = Vector3D(p.x, p.y, p.z),
v = Vector3D(p.vx, p.vy, p.vz)
)
}.toMutableList(),
EarthType.PZ90d02
)
val wkt = contour(stepper, tn, tk, req.roll, req.captureAngle)
val (lat, lon) = extractCenterLatLon(wkt)
val revolution = if (req.revolution != 0L) req.revolution else orbPoints.first().revolution
return BuiltSurveyRoute(
satelliteId = req.satelliteId,
timeStart = tn,
durationSeconds = req.durationSeconds,
revolution = revolution,
roll = req.roll,
captureAngle = req.captureAngle,
lat = lat,
longitude = lon,
contourWkt = wkt
)
}
/** Результат расчёта контура одного маршрута съёмки (до сохранения в БД). */
private data class BuiltSurveyRoute(
val satelliteId: Long,
val timeStart: LocalDateTime,
val durationSeconds: Double,
val revolution: Long,
val roll: Double,
val captureAngle: Double,
val lat: Double,
val longitude: Double,
val contourWkt: String
)
private fun BuiltSurveyRoute.toResponse(surveyModeId: Long?): SurveyRouteBuildResponseDTO =
SurveyRouteBuildResponseDTO(
satelliteId = satelliteId,
timeStart = timeStart,
duration = durationSeconds,
revolution = revolution,
roll = roll,
captureAngle = captureAngle,
lat = lat,
longitude = longitude,
contourWkt = contourWkt,
surveyModeId = surveyModeId
)
private fun contour(
stepper: AbstractStepper,
tn: LocalDateTime,
@@ -617,12 +1179,11 @@ class MissionPlaningService(
val iterator = unassigned.iterator()
while (iterator.hasNext()) {
val survey = iterator.next()
val surveyStartOffset = survey.timeStart
if (surveyStartOffset == null) {
val surveyStart = survey.timeStart
if (surveyStart == null) {
iterator.remove()
continue
}
val surveyStart = surveyStartOffset.toLocalDateTime()
val surveyEnd = surveyStart.plusNanos((survey.duration.coerceAtLeast(0.0) * 1_000_000_000L).toLong())
if (!surveyEnd.isBefore(zoneStart)) {
@@ -646,7 +1207,7 @@ class MissionPlaningService(
val dropMode = dropModeRepository.save(
DropModeEntity(
plan = plan,
timeStart = zoneStart.atOffset(ZoneOffset.UTC),
timeStart = zoneStart,
revolution = zone.revolution,
station = zone.stationId.toString(),
duration = usedDuration
@@ -747,8 +1308,8 @@ class MissionPlaningService(
@Transactional(readOnly = true)
fun findMode(routePassport: RoutePassportDto): SurveyModeEntity? {
val searchStart = routePassport.intervalBegin.atOffset(ZoneOffset.UTC).minusSeconds(1)
val searchEnd = routePassport.intervalBegin.atOffset(ZoneOffset.UTC).plusSeconds(1)
val searchStart = routePassport.intervalBegin.minusSeconds(1)
val searchEnd = routePassport.intervalBegin.plusSeconds(1)
val candidates = surveyModeRepository.findAllBySatelliteNumberAndRevolutionAndTimeStartBetween(
satelliteNumber = routePassport.kaShort.trim(),
revolution = routePassport.orbitNumber,
@@ -758,7 +1319,7 @@ class MissionPlaningService(
return candidates.minByOrNull { candidate ->
val candidateStart = candidate.timeStart ?: return@minByOrNull Long.MAX_VALUE
abs(Duration.between(routePassport.intervalBegin.atOffset(ZoneOffset.UTC), candidateStart).toMillis())
abs(Duration.between(routePassport.intervalBegin, candidateStart).toMillis())
}
}
@@ -6,7 +6,6 @@ import space.nstart.pcp.pcp_mission_planing_service.entity.SurveyModeSource
import space.nstart.pcp.pcp_types_lib.dto.routes.SurveyModeStatus
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SatelliteModeResponseDTO
import java.time.ZoneOffset
abstract class LegacySatelliteMissionTypeSupport : SatelliteMissionType {
override fun prepareSurvey(
@@ -15,7 +14,7 @@ abstract class LegacySatelliteMissionTypeSupport : SatelliteMissionType {
plan: PlanEntity
): SurveyModeEntity = SurveyModeEntity(
plan = plan,
timeStart = surv.startTime.atOffset(ZoneOffset.UTC),
timeStart = surv.startTime,
revolution = surv.revolution,
lat = surv.lat,
long = surv.longitude,
@@ -7,7 +7,7 @@ spring:
import: "configserver:"
cloud:
config:
uri: ${CONFIG_SERVER_URI:http://192.168.60.201:8888}
uri: ${CONFIG_SERVER_URI:http://localhost:8888}
fail-fast: ${CONFIG_SERVER_FAIL_FAST:true}
profile: ${SPRING_CLOUD_CONFIG_PROFILE:${SPRING_PROFILES_ACTIVE:${spring.profiles.default}}}
label: ${SPRING_CLOUD_CONFIG_LABEL:dev}
@@ -0,0 +1,8 @@
-- Объём данных как атрибут маршрута, Мбайт. Считается при построении из длительности
-- (см. ModeDataRates в pcp-types-lib): съёмка 120 Мбайт/с; сброс — канал 1200 Мбит/с ÷ 8 = 150 Мбайт/с.
ALTER TABLE survey_modes ADD COLUMN volume_mb DOUBLE PRECISION NOT NULL DEFAULT 0;
ALTER TABLE drop_modes ADD COLUMN volume_mb DOUBLE PRECISION NOT NULL DEFAULT 0;
-- Бэкфилл существующих маршрутов из длительности.
UPDATE survey_modes SET volume_mb = duration * 120;
UPDATE drop_modes SET volume_mb = duration * 1200.0 / 8;
@@ -0,0 +1,297 @@
package space.nstart.pcp.pcp_mission_planing_service.service
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import org.mockito.Mockito.mock
import org.mockito.Mockito.`when`
import org.springframework.web.server.ResponseStatusException
import space.nstart.pcp.pcp_mission_planing_service.controller.MissionCloneRequest
import space.nstart.pcp.pcp_mission_planing_service.entity.DropModeEntity
import space.nstart.pcp.pcp_mission_planing_service.entity.DropModeSurveyEntity
import space.nstart.pcp.pcp_mission_planing_service.entity.MissionEntity
import space.nstart.pcp.pcp_mission_planing_service.entity.PlanEntity
import space.nstart.pcp.pcp_mission_planing_service.entity.SurveyModeEntity
import space.nstart.pcp.pcp_mission_planing_service.entity.SurveyModeSource
import space.nstart.pcp.pcp_mission_planing_service.repository.DropModeRepository
import space.nstart.pcp.pcp_mission_planing_service.repository.DropModeSurveyRepository
import space.nstart.pcp.pcp_mission_planing_service.repository.MissionRepository
import space.nstart.pcp.pcp_mission_planing_service.repository.ModeBookingRepository
import space.nstart.pcp.pcp_mission_planing_service.repository.ModeRepository
import space.nstart.pcp.pcp_mission_planing_service.repository.PlanRepository
import space.nstart.pcp.pcp_mission_planing_service.repository.SurveyModeRepository
import space.nstart.pcp.pcp_mission_planing_service.service.satellite_type.DefaultSatelliteMissionType
import space.nstart.pcp.pcp_mission_planing_service.service.satellite_type.EmissioSatelliteMissionType
import space.nstart.pcp.pcp_mission_planing_service.service.satellite_type.ReflixioSatelliteMissionType
import space.nstart.pcp.pcp_mission_planing_service.service.satellite_type.SatelliteMissionTypeFactory
import space.nstart.pcp.pcp_types_lib.dto.routes.SurveyModeStatus
import java.time.LocalDateTime
import java.util.Optional
import java.util.UUID
class MissionCloneTest {
private val missionStore = linkedMapOf<UUID, MissionEntity>()
private val planStore = linkedMapOf<Long, PlanEntity>()
private val surveyStore = linkedMapOf<Long, SurveyModeEntity>()
private val dropStore = linkedMapOf<Long, DropModeEntity>()
private val dropSurveyStore = mutableListOf<DropModeSurveyEntity>()
private var missionAutoId = 1L
private var planAutoId = 100L
private var modeAutoId = 10L
private val missionRepository = mock(MissionRepository::class.java).also { repo ->
`when`(repo.findById(anyUUID())).thenAnswer { inv ->
Optional.ofNullable(missionStore[inv.arguments[0]])
}
`when`(repo.save(anyNonNull<MissionEntity>())).thenAnswer { inv ->
val m = inv.arguments[0] as MissionEntity
missionStore[m.missionId] = m
m
}
}
private val planRepository = mock(PlanRepository::class.java).also { repo ->
`when`(repo.save(anyNonNull<PlanEntity>())).thenAnswer { inv ->
val p = inv.arguments[0] as PlanEntity
val id = ++planAutoId
val saved = PlanEntity(id = id, mission = p.mission, planNumber = p.planNumber, planStart = p.planStart, planStop = p.planStop)
planStore[id] = saved
saved
}
}
private val modeRepository = mock(ModeRepository::class.java).also { repo ->
`when`(repo.findAllByPlan_Mission_MissionIdOrderByTimeStartAsc(anyUUID())).thenAnswer { inv ->
val missionId = inv.arguments[0] as UUID
(surveyStore.values.filter { it.plan?.mission?.missionId == missionId } +
dropStore.values.filter { it.plan?.mission?.missionId == missionId })
}
}
private val surveyModeRepository = mock(SurveyModeRepository::class.java).also { repo ->
`when`(repo.save(anyNonNull<SurveyModeEntity>())).thenAnswer { inv ->
val s = inv.arguments[0] as SurveyModeEntity
val id = ++modeAutoId
val saved = SurveyModeEntity(id = id, plan = s.plan, timeStart = s.timeStart, revolution = s.revolution,
lat = s.lat, long = s.long, duration = s.duration, contourWkt = s.contourWkt, roll = s.roll,
status = s.status, source = s.source)
surveyStore[id] = saved
saved
}
}
private val dropModeRepository = mock(DropModeRepository::class.java).also { repo ->
`when`(repo.save(anyNonNull<DropModeEntity>())).thenAnswer { inv ->
val d = inv.arguments[0] as DropModeEntity
val id = ++modeAutoId
val saved = DropModeEntity(id = id, plan = d.plan, timeStart = d.timeStart, revolution = d.revolution,
station = d.station, duration = d.duration)
dropStore[id] = saved
saved
}
}
private val dropModeSurveyRepository = mock(DropModeSurveyRepository::class.java).also { repo ->
`when`(repo.findAllByDropMode_IdOrderBySurveyMode_TimeStartAsc(anyLong())).thenAnswer { inv ->
val dropId = inv.arguments[0] as Long
dropSurveyStore.filter { it.dropMode?.id == dropId }
}
`when`(repo.save(anyNonNull<DropModeSurveyEntity>())).thenAnswer { inv ->
val link = inv.arguments[0] as DropModeSurveyEntity
dropSurveyStore.add(link)
link
}
}
private val service = MissionPlaningService(
missionRepository = missionRepository,
modeRepository = modeRepository,
planRepository = planRepository,
surveyModeRepository = surveyModeRepository,
dropModeRepository = dropModeRepository,
dropModeSurveyRepository = dropModeSurveyRepository,
modeBookingRepository = mock(ModeBookingRepository::class.java),
bookedSlotsEventPublisher = mock(BookedSlotsEventPublisher::class.java),
ballisticsService = mock(BallisticsService::class.java),
complexMissionService = mock(ComplexMissionService::class.java),
satelliteCatalogClient = mock(space.nstart.pcp.pcp_mission_planing_service.service.catalog.SatelliteCatalogClient::class.java),
satelliteMissionTypeFactory = SatelliteMissionTypeFactory(
listOf(DefaultSatelliteMissionType(), EmissioSatelliteMissionType(), ReflixioSatelliteMissionType()),
DefaultSatelliteMissionType()
)
)
// --- replace: полный клон ---
@Test
fun `replace clone copies all survey and drop modes with remapped survey links`() {
val (missionId, _, survey1, survey2, drop) = buildSourceMission(t(1), t(5))
addDropLink(drop, survey1)
addDropLink(drop, survey2)
val result = service.cloneMission(missionId, MissionCloneRequest(mode = "replace"))
val clonedSurveys = surveyStore.values.filter { it.plan?.mission?.missionId == result.newMissionId }
val clonedDrops = dropStore.values.filter { it.plan?.mission?.missionId == result.newMissionId }
assertEquals(2, clonedSurveys.size)
assertEquals(1, clonedDrops.size)
// Ссылки переведены на новые survey IDs
val clonedLinks = dropSurveyStore.filter { it.dropMode?.plan?.mission?.missionId == result.newMissionId }
assertEquals(2, clonedLinks.size)
assertTrue(clonedLinks.all { it.surveyMode?.plan?.mission?.missionId == result.newMissionId })
}
// --- fork-forward: фильтрация по окну ---
@Test
fun `fork-forward clone includes mode that starts before window but ends inside`() {
// Включение начинается за 5 мин до окна и заканчивается внутри → пересекается.
// buildSourceMission создаёт 2 survey (оба перекрывают окно) + 1 drop.
val (missionId) = buildSourceMission(t(0).minusMinutes(5), t(0).plusMinutes(10))
val result = service.cloneMission(
missionId,
MissionCloneRequest(mode = "fork-forward", windowStart = t(0), windowStop = t(1))
)
val cloned = surveyStore.values.filter { it.plan?.mission?.missionId == result.newMissionId }
assertEquals(2, cloned.size)
}
@Test
fun `fork-forward clone excludes mode whose stop equals window start (closed left is open right semantics)`() {
// Включение заканчивается ровно в windowStart → не пересекается (modeStop > windowStart is false).
// Явно создаём одну съёмку длительностью 30 минут, заканчивающуюся в t(0).
val source = MissionEntity(satelliteId = 100)
missionStore[source.missionId] = source
val plan = PlanEntity(id = ++planAutoId, mission = source, planNumber = 1)
planStore[plan.id!!] = plan
// duration 1800s = 30 min; начало: t(0) - 30min = строго до окна, конец ровно в t(0).
survey(plan, t(0).minusMinutes(30), duration = 1800.0)
val result = service.cloneMission(
source.missionId,
MissionCloneRequest(mode = "fork-forward", windowStart = t(0), windowStop = t(1))
)
val cloned = surveyStore.values.filter { it.plan?.mission?.missionId == result.newMissionId }
assertTrue(cloned.isEmpty())
}
@Test
fun `fork-forward clone excludes mode starting exactly at window stop (open upper bound)`() {
// Включение начинается ровно в windowStop → не пересекается (open right bound [start, stop)).
// Явно создаём одну съёмку: start = t(1) (= windowStop), duration 10min.
val source = MissionEntity(satelliteId = 100)
missionStore[source.missionId] = source
val plan = PlanEntity(id = ++planAutoId, mission = source, planNumber = 1)
planStore[plan.id!!] = plan
survey(plan, t(1), duration = 600.0)
val result = service.cloneMission(
source.missionId,
MissionCloneRequest(mode = "fork-forward", windowStart = t(0), windowStop = t(1))
)
val cloned = surveyStore.values.filter { it.plan?.mission?.missionId == result.newMissionId }
assertTrue(cloned.isEmpty())
}
@Test
fun `fork-forward drops drop mode when all its surveys are outside window`() {
// Две съёмки вне окна; сброс на них тоже не клонируется (пустой drop).
val source = MissionEntity(satelliteId = 100)
missionStore[source.missionId] = source
val sourcePlan = PlanEntity(id = ++planAutoId, mission = source, planNumber = 1)
planStore[sourcePlan.id!!] = sourcePlan
val outSurvey = survey(sourcePlan, start = t(0).minusHours(2), duration = 60.0)
val drop = drop(sourcePlan, start = t(0).minusMinutes(30))
addDropLink(drop, outSurvey)
val result = service.cloneMission(
source.missionId,
MissionCloneRequest(mode = "fork-forward", windowStart = t(1), windowStop = t(2))
)
val clonedDrops = dropStore.values.filter { it.plan?.mission?.missionId == result.newMissionId }
assertTrue(clonedDrops.isEmpty())
}
@Test
fun `fork-forward keeps a survey even when its drop falls outside the window`() {
// Съёмка внутри окна [t(1), t(2)), но её сброс — до окна. Сброс не клонируется,
// съёмка остаётся (будет пересброшена в новом плане) — намеренная семантика fork-forward.
val source = MissionEntity(satelliteId = 100)
missionStore[source.missionId] = source
val plan = PlanEntity(id = ++planAutoId, mission = source, planNumber = 1)
planStore[plan.id!!] = plan
val inSurvey = survey(plan, start = t(1).plusMinutes(10), duration = 600.0)
val outDrop = drop(plan, start = t(0))
addDropLink(outDrop, inSurvey)
val result = service.cloneMission(
source.missionId,
MissionCloneRequest(mode = "fork-forward", windowStart = t(1), windowStop = t(2))
)
val clonedSurveys = surveyStore.values.filter { it.plan?.mission?.missionId == result.newMissionId }
val clonedDrops = dropStore.values.filter { it.plan?.mission?.missionId == result.newMissionId }
assertEquals(1, clonedSurveys.size)
assertTrue(clonedDrops.isEmpty())
}
@Test
fun `fork-forward requires windowStart and windowStop`() {
val (missionId) = buildSourceMission(t(0), t(1))
assertThrows<ResponseStatusException> {
service.cloneMission(missionId, MissionCloneRequest(mode = "fork-forward"))
}
}
// --- helpers ---
data class SourceFixture(
val missionId: UUID,
val plan: PlanEntity,
val survey1: SurveyModeEntity,
val survey2: SurveyModeEntity,
val drop: DropModeEntity
)
private fun buildSourceMission(surveyStart: LocalDateTime, surveyStop: LocalDateTime): SourceFixture {
val mission = MissionEntity(satelliteId = 100)
missionStore[mission.missionId] = mission
val plan = PlanEntity(id = ++planAutoId, mission = mission, planNumber = 1)
planStore[plan.id!!] = plan
val duration = java.time.Duration.between(surveyStart, surveyStop).toMillis().toDouble() / 1000.0
val s1 = survey(plan, surveyStart, duration)
val s2 = survey(plan, surveyStart.plusMinutes(1), duration)
val d = drop(plan, surveyStart.plusMinutes(5))
return SourceFixture(mission.missionId, plan, s1, s2, d)
}
private fun survey(plan: PlanEntity, start: LocalDateTime, duration: Double = 300.0): SurveyModeEntity {
val id = ++modeAutoId
val s = SurveyModeEntity(id = id, plan = plan, timeStart = start, revolution = id,
lat = 55.0, long = 37.0, duration = duration, roll = 0.0, source = SurveyModeSource.MANUAL,
status = SurveyModeStatus.PLANNED)
surveyStore[id] = s
return s
}
private fun drop(plan: PlanEntity, start: LocalDateTime): DropModeEntity {
val id = ++modeAutoId
val d = DropModeEntity(id = id, plan = plan, timeStart = start,
revolution = id, station = "102", duration = 60.0)
dropStore[id] = d
return d
}
private fun addDropLink(drop: DropModeEntity, survey: SurveyModeEntity) {
dropSurveyStore.add(DropModeSurveyEntity(dropMode = drop, surveyMode = survey))
}
private fun t(hour: Int): LocalDateTime = LocalDateTime.of(2026, 6, 10, hour, 0, 0)
@Suppress("UNCHECKED_CAST")
private fun <T> anyNonNull(): T { org.mockito.ArgumentMatchers.any<T>(); return null as T }
private fun anyUUID(): UUID { org.mockito.ArgumentMatchers.any<UUID>(); return UUID.randomUUID() }
private fun anyLong(): Long { org.mockito.ArgumentMatchers.anyLong(); return 0L }
}
@@ -0,0 +1,293 @@
package space.nstart.pcp.pcp_mission_planing_service.service
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import org.mockito.Mockito.doAnswer
import org.mockito.Mockito.mock
import org.mockito.Mockito.`when`
import org.springframework.http.HttpStatus
import org.springframework.web.server.ResponseStatusException
import space.nstart.pcp.pcp_mission_planing_service.entity.DropModeEntity
import space.nstart.pcp.pcp_mission_planing_service.entity.DropModeSurveyEntity
import space.nstart.pcp.pcp_mission_planing_service.entity.ModeBookingEntity
import space.nstart.pcp.pcp_mission_planing_service.entity.ModeEntity
import space.nstart.pcp.pcp_mission_planing_service.entity.PlanEntity
import space.nstart.pcp.pcp_mission_planing_service.entity.SurveyModeEntity
import space.nstart.pcp.pcp_mission_planing_service.entity.SurveyModeSource
import space.nstart.pcp.pcp_mission_planing_service.repository.DropModeRepository
import space.nstart.pcp.pcp_mission_planing_service.repository.DropModeSurveyRepository
import space.nstart.pcp.pcp_mission_planing_service.repository.MissionRepository
import space.nstart.pcp.pcp_mission_planing_service.repository.ModeBookingRepository
import space.nstart.pcp.pcp_mission_planing_service.repository.ModeRepository
import space.nstart.pcp.pcp_mission_planing_service.repository.PlanRepository
import space.nstart.pcp.pcp_mission_planing_service.repository.SurveyModeRepository
import space.nstart.pcp.pcp_mission_planing_service.service.satellite_type.DefaultSatelliteMissionType
import space.nstart.pcp.pcp_mission_planing_service.service.satellite_type.EmissioSatelliteMissionType
import space.nstart.pcp.pcp_mission_planing_service.service.satellite_type.ReflixioSatelliteMissionType
import space.nstart.pcp.pcp_mission_planing_service.service.satellite_type.SatelliteMissionTypeFactory
import space.nstart.pcp.pcp_types_lib.dto.routes.SurveyModeStatus
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.DropRouteSaveRequestDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SurveyRouteBuildRequestDTO
import java.time.LocalDateTime
import java.util.UUID
class MissionEditModesTest {
private val surveyStore = linkedMapOf<Long, SurveyModeEntity>()
private val dropStore = linkedMapOf<Long, DropModeEntity>()
private val dropSurveyStore = mutableListOf<DropModeSurveyEntity>()
private val bookingStore = mutableListOf<ModeBookingEntity>()
private var autoId = 10L
private val missionRepository = mock(MissionRepository::class.java)
private val modeRepository = mock(ModeRepository::class.java).also { repo ->
`when`(repo.findById(anyLong())).thenAnswer { inv ->
val id = inv.arguments[0] as Long
java.util.Optional.ofNullable<ModeEntity>(surveyStore[id] ?: dropStore[id])
}
}
private val surveyModeRepository = mock(SurveyModeRepository::class.java).also { repo ->
doAnswer { inv ->
val s = inv.arguments[0] as SurveyModeEntity
surveyStore.remove(s.id)
null
}.`when`(repo).delete(anyNonNull())
}
private val dropModeRepository = mock(DropModeRepository::class.java).also { repo ->
doAnswer { inv ->
val d = inv.arguments[0] as DropModeEntity
dropStore.remove(d.id)
null
}.`when`(repo).delete(anyNonNull())
doAnswer { inv ->
dropStore.remove(inv.arguments[0] as Long)
null
}.`when`(repo).deleteById(anyLong())
}
private val dropModeSurveyRepository = mock(DropModeSurveyRepository::class.java).also { repo ->
`when`(repo.findAllBySurveyMode_Id(anyLong())).thenAnswer { inv ->
val sid = inv.arguments[0] as Long
dropSurveyStore.filter { it.surveyMode?.id == sid }
}
`when`(repo.findAllByDropMode_IdOrderBySurveyMode_TimeStartAsc(anyLong())).thenAnswer { inv ->
val did = inv.arguments[0] as Long
dropSurveyStore.filter { it.dropMode?.id == did }
}
doAnswer { inv ->
@Suppress("UNCHECKED_CAST")
val links = inv.arguments[0] as Iterable<DropModeSurveyEntity>
dropSurveyStore.removeAll(links.toSet())
null
}.`when`(repo).deleteAll(anyNonNull())
}
private val modeBookingRepository = mock(ModeBookingRepository::class.java).also { repo ->
`when`(repo.findAllByMode_IdIn(anyNonNull())).thenAnswer { inv ->
@Suppress("UNCHECKED_CAST")
val ids = inv.arguments[0] as Collection<Long>
bookingStore.filter { it.mode?.id in ids }
}
doAnswer { inv ->
@Suppress("UNCHECKED_CAST")
val bs = inv.arguments[0] as Iterable<ModeBookingEntity>
bookingStore.removeAll(bs.toSet())
null
}.`when`(repo).deleteAll(anyNonNull())
}
private val service = MissionPlaningService(
missionRepository = missionRepository,
modeRepository = modeRepository,
planRepository = mock(PlanRepository::class.java),
surveyModeRepository = surveyModeRepository,
dropModeRepository = dropModeRepository,
dropModeSurveyRepository = dropModeSurveyRepository,
modeBookingRepository = modeBookingRepository,
bookedSlotsEventPublisher = mock(BookedSlotsEventPublisher::class.java),
ballisticsService = mock(BallisticsService::class.java),
complexMissionService = mock(ComplexMissionService::class.java),
satelliteCatalogClient = mock(space.nstart.pcp.pcp_mission_planing_service.service.catalog.SatelliteCatalogClient::class.java),
satelliteMissionTypeFactory = SatelliteMissionTypeFactory(
listOf(DefaultSatelliteMissionType(), EmissioSatelliteMissionType(), ReflixioSatelliteMissionType()),
DefaultSatelliteMissionType()
)
)
private val plan = PlanEntity(id = 1L, planNumber = 1)
// --- DELETE mode ---
@Test
fun `deleting a survey removes its bookings, links and the orphaned drop`() {
val survey = survey()
val drop = drop()
link(drop, survey)
booking(survey)
service.deleteMode(survey.id!!)
assertFalse(surveyStore.containsKey(survey.id))
assertTrue(bookingStore.isEmpty())
assertFalse(dropStore.containsKey(drop.id), "сброс без единственной съёмки должен быть удалён")
assertTrue(dropSurveyStore.isEmpty())
}
@Test
fun `deleting a survey keeps a drop still referenced by another survey`() {
val survey1 = survey()
val survey2 = survey()
val drop = drop()
link(drop, survey1)
link(drop, survey2)
service.deleteMode(survey1.id!!)
assertFalse(surveyStore.containsKey(survey1.id))
assertTrue(surveyStore.containsKey(survey2.id))
assertTrue(dropStore.containsKey(drop.id), "сброс с оставшейся съёмкой не удаляется")
assertEquals(1, dropSurveyStore.size)
assertEquals(survey2.id, dropSurveyStore.single().surveyMode?.id)
}
@Test
fun `deleting a drop removes the drop and its links but keeps surveys`() {
val survey = survey()
val drop = drop()
link(drop, survey)
service.deleteMode(drop.id!!)
assertFalse(dropStore.containsKey(drop.id))
assertTrue(dropSurveyStore.isEmpty())
assertTrue(surveyStore.containsKey(survey.id), "съёмка независима от сброса и сохраняется")
}
@Test
fun `deleting an unknown mode yields 404`() {
val ex = assertThrows<ResponseStatusException> { service.deleteMode(999L) }
assertEquals(HttpStatus.NOT_FOUND, ex.statusCode)
}
// --- save routes больше не создают живую миссию ---
@Test
fun `saveSurveyRoute on a missing mission yields 404 instead of auto-creating`() {
// missionRepository не застаблен → findById возвращает пустой Optional.
val ex = assertThrows<ResponseStatusException> {
service.saveSurveyRoute(UUID.randomUUID(), SurveyRouteBuildRequestDTO(satelliteId = 25544))
}
assertEquals(HttpStatus.NOT_FOUND, ex.statusCode)
}
@Test
fun `saveDropRoute on a missing mission yields 404 instead of auto-creating`() {
val ex = assertThrows<ResponseStatusException> {
service.saveDropRoute(
UUID.randomUUID(),
DropRouteSaveRequestDTO(satelliteId = 25544, surveyModeIds = listOf(1L))
)
}
assertEquals(HttpStatus.NOT_FOUND, ex.statusCode)
}
// --- validate (заглушка) ---
@Test
fun `validate of an existing mission returns an empty conflict list`() {
val missionId = UUID.randomUUID()
`when`(missionRepository.existsById(missionId)).thenReturn(true)
val result = service.validateMission(missionId)
assertEquals(missionId, result.missionId)
assertTrue(result.conflicts.isEmpty(), "MVP-заглушка не считает конфликтов")
}
@Test
fun `validate of a missing mission yields 404`() {
val ex = assertThrows<ResponseStatusException> { service.validateMission(UUID.randomUUID()) }
assertEquals(HttpStatus.NOT_FOUND, ex.statusCode)
}
// --- createBlankMission (план новой цепочки «без истории») ---
@Test
fun `createBlankMission persists a mission and its plan with the given window`() {
val saved = arrayOfNulls<space.nstart.pcp.pcp_mission_planing_service.entity.MissionEntity>(1)
`when`(missionRepository.save(anyNonNull())).thenAnswer { inv ->
val m = inv.arguments[0] as space.nstart.pcp.pcp_mission_planing_service.entity.MissionEntity
saved[0] = m
m
}
val start = LocalDateTime.of(2026, 6, 10, 12, 0)
val stop = LocalDateTime.of(2026, 6, 10, 12, 10)
val response = service.createBlankMission(
space.nstart.pcp.pcp_mission_planing_service.controller.MissionBlankRequest(
satelliteId = 25544, missionStart = start, missionStop = stop
)
)
assertEquals(saved[0]!!.missionId, response.newMissionId)
assertEquals(25544L, saved[0]!!.satelliteId)
assertEquals(start, saved[0]!!.missionStart)
assertEquals(stop, saved[0]!!.missionStop)
assertEquals("NEW", saved[0]!!.status)
}
@Test
fun `createBlankMission with stop not after start yields 400`() {
val t = LocalDateTime.of(2026, 6, 10, 12, 0)
val ex = assertThrows<ResponseStatusException> {
service.createBlankMission(
space.nstart.pcp.pcp_mission_planing_service.controller.MissionBlankRequest(
satelliteId = 25544, missionStart = t, missionStop = t
)
)
}
assertEquals(HttpStatus.BAD_REQUEST, ex.statusCode)
}
// --- helpers ---
private fun survey(): SurveyModeEntity {
val id = ++autoId
val s = SurveyModeEntity(
id = id, plan = plan, timeStart = LocalDateTime.of(2026, 6, 10, 12, 0),
revolution = id, lat = 55.0, long = 37.0, duration = 60.0, roll = 0.0,
status = SurveyModeStatus.PLANNED, source = SurveyModeSource.MANUAL
)
surveyStore[id] = s
return s
}
private fun drop(): DropModeEntity {
val id = ++autoId
val d = DropModeEntity(
id = id, plan = plan, timeStart = LocalDateTime.of(2026, 6, 10, 13, 0),
revolution = id, station = "102", duration = 60.0
)
dropStore[id] = d
return d
}
private fun link(drop: DropModeEntity, survey: SurveyModeEntity) {
dropSurveyStore.add(DropModeSurveyEntity(dropMode = drop, surveyMode = survey))
}
private fun booking(survey: SurveyModeEntity) {
bookingStore.add(ModeBookingEntity(id = ++autoId, mode = survey, slotId = autoId))
}
@Suppress("UNCHECKED_CAST")
private fun <T> anyNonNull(): T { org.mockito.ArgumentMatchers.any<T>(); return null as T }
private fun anyLong(): Long { org.mockito.ArgumentMatchers.anyLong(); return 0L }
}
@@ -41,8 +41,6 @@ import space.nstart.pcp.pcp_mission_planing_service.service.satellite_type.Refli
import space.nstart.pcp.pcp_mission_planing_service.service.satellite_type.SatelliteMissionTypeFactory
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteDTO
import java.time.LocalDateTime
import java.time.OffsetDateTime
import java.time.ZoneOffset
import java.util.UUID
import java.math.BigDecimal
@@ -93,7 +91,7 @@ class MissionPlaningServiceTest {
val overlappingSurvey = SurveyModeEntity(
id = 1L,
plan = plan,
timeStart = intervalStart.minusMinutes(5).atOffset(ZoneOffset.UTC),
timeStart = intervalStart.minusMinutes(5),
revolution = 10,
lat = 55.0,
long = 37.0,
@@ -105,7 +103,7 @@ class MissionPlaningServiceTest {
val inWindowSurvey = SurveyModeEntity(
id = 2L,
plan = plan,
timeStart = intervalStart.plusMinutes(10).atOffset(ZoneOffset.UTC),
timeStart = intervalStart.plusMinutes(10),
revolution = 11,
lat = 56.0,
long = 38.0,
@@ -117,7 +115,7 @@ class MissionPlaningServiceTest {
val inWindowDrop = DropModeEntity(
id = 3L,
plan = plan,
timeStart = intervalStart.plusMinutes(20).atOffset(ZoneOffset.UTC),
timeStart = intervalStart.plusMinutes(20),
revolution = 12,
station = "station-1",
duration = 120.0
@@ -126,7 +124,7 @@ class MissionPlaningServiceTest {
`when`(
modeRepository.findAllByPlan_Mission_SatelliteIdAndTimeStartIsNotNullAndTimeStartLessThanEqualOrderByTimeStartAsc(
77,
intervalStop.atOffset(ZoneOffset.UTC)
intervalStop
)
).thenReturn(listOf(overlappingSurvey, inWindowSurvey, inWindowDrop))
`when`(modeBookingRepository.findAllByMode_IdIn(listOf(1L, 2L))).thenReturn(
@@ -188,12 +186,12 @@ class MissionPlaningServiceTest {
)
val exactMode = SurveyModeEntity(
id = 10L,
timeStart = routePassport.intervalBegin.atOffset(ZoneOffset.UTC),
timeStart = routePassport.intervalBegin,
revolution = 321
)
val nearMode = SurveyModeEntity(
id = 11L,
timeStart = routePassport.intervalBegin.plusSeconds(1).atOffset(ZoneOffset.UTC),
timeStart = routePassport.intervalBegin.plusSeconds(1),
revolution = 321
)
@@ -201,8 +199,8 @@ class MissionPlaningServiceTest {
surveyModeRepository.findAllBySatelliteNumberAndRevolutionAndTimeStartBetween(
"22",
321,
OffsetDateTime.of(routePassport.intervalBegin.minusSeconds(1), ZoneOffset.UTC),
OffsetDateTime.of(routePassport.intervalBegin.plusSeconds(1), ZoneOffset.UTC)
routePassport.intervalBegin.minusSeconds(1),
routePassport.intervalBegin.plusSeconds(1)
)
).thenReturn(listOf(nearMode, exactMode))
@@ -0,0 +1,131 @@
package org.nstart.dep265.requestservice.config
import com.fasterxml.jackson.core.JsonProcessingException
import jakarta.validation.ConstraintViolationException
import org.apache.kafka.clients.consumer.ConsumerConfig
import org.apache.kafka.clients.consumer.ConsumerRecord
import org.apache.kafka.common.TopicPartition
import org.apache.kafka.common.header.Headers
import org.apache.kafka.common.header.internals.RecordHeader
import org.apache.kafka.common.header.internals.RecordHeaders
import org.apache.kafka.common.serialization.StringDeserializer
import org.springframework.beans.factory.annotation.Value
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory
import org.springframework.kafka.core.ConsumerFactory
import org.springframework.kafka.core.DefaultKafkaConsumerFactory
import org.springframework.kafka.core.KafkaTemplate
import org.springframework.kafka.listener.DeadLetterPublishingRecoverer
import org.springframework.kafka.listener.DefaultErrorHandler
import org.springframework.util.backoff.ExponentialBackOff
import java.nio.charset.StandardCharsets
/**
* Обработка ошибок Kafka-консьюмера маршрутов: bounded retry + DLQ.
*
* Без этого «ядовитое» сообщение бесконечно ретраится и блокирует партицию. Невалидные по контракту
* (ошибка десериализации/валидации) уходят в DLQ сразу, прочие после ограниченного числа попыток.
*
* Активируется только при заданном `spring.kafka.bootstrap-servers`, чтобы не требовать Kafka в тестах
* без брокера.
*/
@Configuration
@ConditionalOnProperty(name = ["spring.kafka.bootstrap-servers"], matchIfMissing = false)
class KafkaConsumerConfig(
@param:Value("\${spring.kafka.bootstrap-servers}")
private val bootstrapServers: String,
@param:Value("\${spring.kafka.consumer.group-id}")
private val groupId: String,
@param:Value("\${spring.kafka.consumer.auto-offset-reset:latest}")
private val autoOffsetReset: String,
) {
@Bean
fun routeConsumerFactory(): ConsumerFactory<String, String> =
DefaultKafkaConsumerFactory(
mutableMapOf<String, Any>(
ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG to bootstrapServers,
ConsumerConfig.GROUP_ID_CONFIG to groupId,
ConsumerConfig.AUTO_OFFSET_RESET_CONFIG to autoOffsetReset,
ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG to false,
ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG to StringDeserializer::class.java,
ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG to StringDeserializer::class.java,
),
)
@Bean
fun kafkaListenerContainerFactory(
routeKafkaErrorHandler: DefaultErrorHandler,
): ConcurrentKafkaListenerContainerFactory<String, String> =
ConcurrentKafkaListenerContainerFactory<String, String>().apply {
setConsumerFactory(routeConsumerFactory())
setCommonErrorHandler(routeKafkaErrorHandler)
}
@Bean
fun routeDeadLetterPublishingRecoverer(
kafkaTemplate: KafkaTemplate<String, String>,
routeConsumerProperties: RouteConsumerProperties,
): DeadLetterPublishingRecoverer =
DeadLetterPublishingRecoverer(kafkaTemplate) { record, _ ->
TopicPartition(routeConsumerProperties.dlqTopic, record.partition())
}.apply {
addHeadersFunction { record, exception -> routeDlqHeaders(record, exception) }
}
@Bean
fun routeKafkaErrorHandler(
routeDeadLetterPublishingRecoverer: DeadLetterPublishingRecoverer,
routeConsumerProperties: RouteConsumerProperties,
): DefaultErrorHandler =
DefaultErrorHandler(
routeDeadLetterPublishingRecoverer,
routeConsumerBackOff(routeConsumerProperties),
).apply {
// Контрактные ошибки (битый JSON, провал валидации) бесполезно ретраить — сразу в DLQ.
addNotRetryableExceptions(
JsonProcessingException::class.java,
ConstraintViolationException::class.java,
)
setCommitRecovered(true)
}
private fun routeConsumerBackOff(properties: RouteConsumerProperties): ExponentialBackOff =
ExponentialBackOff().apply {
initialInterval = properties.backoffMs
multiplier = RETRY_BACKOFF_MULTIPLIER
maxInterval = properties.maxBackoffMs
maxAttempts = (properties.maxAttempts - 1).coerceAtLeast(0)
}
private fun routeDlqHeaders(
record: ConsumerRecord<*, *>,
exception: Exception,
): Headers =
RecordHeaders().apply {
addStringHeader(HEADER_SERVICE_NAME, SERVICE_NAME)
addStringHeader(HEADER_EXCEPTION_CLASS, exception::class.java.name)
addStringHeader(HEADER_EXCEPTION_MESSAGE, exception.message.orEmpty())
addStringHeader(HEADER_ORIGINAL_TOPIC, record.topic())
addStringHeader(HEADER_ORIGINAL_PARTITION, record.partition().toString())
addStringHeader(HEADER_ORIGINAL_OFFSET, record.offset().toString())
}
private fun Headers.addStringHeader(name: String, value: String) {
add(RecordHeader(name, value.toByteArray(StandardCharsets.UTF_8)))
}
private companion object {
const val SERVICE_NAME = "pcp-request-service"
const val RETRY_BACKOFF_MULTIPLIER = 2.0
const val HEADER_SERVICE_NAME = "pcp-service-name"
const val HEADER_EXCEPTION_CLASS = "pcp-exception-class"
const val HEADER_EXCEPTION_MESSAGE = "pcp-exception-message"
const val HEADER_ORIGINAL_TOPIC = "pcp-original-topic"
const val HEADER_ORIGINAL_PARTITION = "pcp-original-partition"
const val HEADER_ORIGINAL_OFFSET = "pcp-original-offset"
}
}
@@ -15,7 +15,7 @@ class KafkaProducerConfig {
@Bean
@ConditionalOnMissingBean
fun requestProducerFactory(
@Value("\${spring.kafka.bootstrap-servers:192.168.100.160:19092}") bootstrapServers: String,
@Value("\${spring.kafka.bootstrap-servers:localhost:29092}") bootstrapServers: String,
): ProducerFactory<String, String> {
return DefaultKafkaProducerFactory(
mapOf(
@@ -4,7 +4,14 @@ import org.springframework.boot.context.properties.ConfigurationProperties
@ConfigurationProperties(prefix = "pcp.outbox")
data class OutboxProperties(
val requestCompletedTopic: String = "pcp.request.completed.v1",
/** Топик терминальных статусов заявки (COMPLETED/EXPIRED) для geoportal. */
val requestStatusTopic: String = "pcp.request.status.v1",
val publishBatchSize: Int = 50,
val publishFixedDelayMs: Long = 5_000,
/** Сколько раз пытаемся опубликовать событие до перевода в терминальный FAILED. */
val maxAttempts: Int = 10,
/** Backoff после первой неудачной публикации. */
val initialBackoffMs: Long = 1_000,
/** Верхняя граница backoff между попытками. */
val maxBackoffMs: Long = 60_000,
)
@@ -22,4 +22,15 @@ class OutboxTransactionConfig {
propagationBehavior = TransactionDefinition.PROPAGATION_REQUIRES_NEW
}
}
/**
* Каждый батч планировщика истечения коммитится отдельно, поэтому между батчами
* row-level блокировки (FOR UPDATE SKIP LOCKED) освобождаются.
*/
@Bean("requestExpiryTransactionOperations")
fun requestExpiryTransactionOperations(transactionManager: PlatformTransactionManager): TransactionOperations {
return TransactionTemplate(transactionManager).apply {
propagationBehavior = TransactionDefinition.PROPAGATION_REQUIRES_NEW
}
}
}
@@ -0,0 +1,19 @@
package org.nstart.dep265.requestservice.config
import org.springframework.boot.context.properties.ConfigurationProperties
/**
* Настройки планировщика истечения заявок: периодически переводит просроченные заявки
* (status ACCEPTED/ACTIVE, окно которых уже закончилось) в EXPIRED.
*/
@ConfigurationProperties(prefix = "pcp.request.expiry")
data class RequestExpiryProperties(
/** Включён ли планировщик. */
val enabled: Boolean = true,
/** Период запуска (мс), читается также в @Scheduled. */
val fixedDelayMs: Long = 60_000,
/** Сколько заявок захватывать за одну транзакцию (FOR UPDATE SKIP LOCKED). */
val batchSize: Int = 200,
/** Верхняя граница числа батчей за один прогон, чтобы не удерживать поток планировщика. */
val maxBatchesPerRun: Int = 50,
)
@@ -0,0 +1,28 @@
package org.nstart.dep265.requestservice.config
import jakarta.validation.constraints.Min
import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.validation.annotation.Validated
/**
* Настройки обработки ошибок Kafka-консьюмера маршрутов: DLQ-топик и bounded retry.
* «Ядовитое» (невалидное по контракту) сообщение уходит в DLQ, а не блокирует партицию.
*/
@Validated
@ConfigurationProperties(prefix = "app.kafka.consumer.route")
data class RouteConsumerProperties(
/** Топик dead-letter для маршрутов, не прошедших обработку. */
val dlqTopic: String = "pcp.route.georeference-dlq.v1",
/** Максимум попыток обработки, включая первичную доставку. */
@field:Min(1)
val maxAttempts: Long = 5,
/** Начальная задержка retry, мс. */
@field:Min(0)
val backoffMs: Long = 1_000,
/** Верхняя граница задержки retry, мс. */
@field:Min(0)
val maxBackoffMs: Long = 30_000,
)
@@ -22,7 +22,7 @@ import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
import java.time.OffsetDateTime
import java.time.LocalDateTime
import java.util.UUID
@RestController
@@ -41,10 +41,10 @@ class RequestController(
@RequestParam(required = false) surveyType: SurveyTypeDto?,
@RequestParam(required = false) kpp: Int?,
@RequestParam(required = false) highPriorityTransmit: Boolean?,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) beginFrom: OffsetDateTime?,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) beginTo: OffsetDateTime?,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) endFrom: OffsetDateTime?,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) endTo: OffsetDateTime?,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) beginFrom: LocalDateTime?,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) beginTo: LocalDateTime?,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) endFrom: LocalDateTime?,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) endTo: LocalDateTime?,
@RequestParam(defaultValue = "false") includeDeleted: Boolean,
@RequestParam(defaultValue = "0") page: Int,
@RequestParam(defaultValue = "50") size: Int,
@@ -73,10 +73,10 @@ class RequestController(
@RequestParam(required = false) surveyType: SurveyTypeDto?,
@RequestParam(required = false) kpp: Int?,
@RequestParam(required = false) highPriorityTransmit: Boolean?,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) beginFrom: OffsetDateTime?,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) beginTo: OffsetDateTime?,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) endFrom: OffsetDateTime?,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) endTo: OffsetDateTime?,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) beginFrom: LocalDateTime?,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) beginTo: LocalDateTime?,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) endFrom: LocalDateTime?,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) endTo: LocalDateTime?,
@RequestParam(defaultValue = "false") includeDeleted: Boolean,
@RequestParam(defaultValue = "0") page: Int,
@RequestParam(defaultValue = "50") size: Int,
@@ -3,7 +3,7 @@ package org.nstart.dep265.requestservice.dto
import com.fasterxml.jackson.annotation.JsonFormat
import jakarta.validation.constraints.DecimalMin
import jakarta.validation.constraints.NotNull
import java.time.OffsetDateTime
import java.time.LocalDateTime
/**
* Текущие настройки сетки.
@@ -38,5 +38,5 @@ data class GridRebuildResponseDto(
/** Дата-время завершения перестроения в UTC. */
@field:JsonFormat(shape = JsonFormat.Shape.STRING)
val rebuiltAt: OffsetDateTime,
val rebuiltAt: LocalDateTime,
)
@@ -11,7 +11,7 @@ import jakarta.validation.constraints.Min
import jakarta.validation.constraints.NotBlank
import jakarta.validation.constraints.NotNull
import jakarta.validation.constraints.Size
import java.time.OffsetDateTime
import java.time.LocalDateTime
import java.util.UUID
/**
@@ -48,11 +48,11 @@ data class CreateRequestDto(
/** Начало временного окна выполнения заявки. */
@field:NotNull
val beginDateTime: OffsetDateTime,
val beginDateTime: LocalDateTime,
/** Окончание временного окна выполнения заявки. */
@field:NotNull
val endDateTime: OffsetDateTime,
val endDateTime: LocalDateTime,
/** Список номеров КПП для сброса. */
val kpp: List<Int> = emptyList(),
@@ -60,6 +60,15 @@ data class CreateRequestDto(
/** Признак высокоприоритетной передачи. */
val highPriorityTransmit: Boolean = false,
/**
* Требуемый процент покрытия заявки (0 < x 100), при достижении которого заявка
* считается выполненной (COMPLETED). Это **прогресс съёмки**, не путать с [importance]
* (важность заявки для карты приоритетов ячеек). По умолчанию 100%.
*/
@field:DecimalMin(value = "0", inclusive = false)
@field:DecimalMax("100")
val coverageRequiredPercent: Double = 100.0,
/** Параметры оптической съемки. */
@field:Valid
val optics: OpticsParamsDto? = null,
@@ -120,10 +129,10 @@ data class RequestResponseDto(
val importance: Double,
/** Начало временного окна выполнения заявки. */
val beginDateTime: OffsetDateTime,
val beginDateTime: LocalDateTime,
/** Окончание временного окна выполнения заявки. */
val endDateTime: OffsetDateTime,
val endDateTime: LocalDateTime,
/** Список номеров КПП для сброса. */
val kpp: List<Int> = emptyList(),
@@ -141,13 +150,13 @@ data class RequestResponseDto(
val coverage: CoverageStateDto,
/** Время создания заявки. */
val createdAt: OffsetDateTime,
val createdAt: LocalDateTime,
/** Время последнего обновления заявки. */
val updatedAt: OffsetDateTime,
val updatedAt: LocalDateTime,
/** Время soft delete, если заявка удалена. */
val deletedAt: OffsetDateTime? = null,
val deletedAt: LocalDateTime? = null,
)
/**
@@ -170,10 +179,10 @@ data class RequestSummaryResponseDto(
val importance: Double,
/** Начало временного окна выполнения заявки. */
val beginDateTime: OffsetDateTime,
val beginDateTime: LocalDateTime,
/** Окончание временного окна выполнения заявки. */
val endDateTime: OffsetDateTime,
val endDateTime: LocalDateTime,
/** Список номеров КПП для сброса. */
val kpp: List<Int> = emptyList(),
@@ -185,13 +194,13 @@ data class RequestSummaryResponseDto(
val coveragePercent: Double,
/** Время создания заявки. */
val createdAt: OffsetDateTime,
val createdAt: LocalDateTime,
/** Время последнего обновления заявки. */
val updatedAt: OffsetDateTime,
val updatedAt: LocalDateTime,
/** Время soft delete, если заявка удалена. */
val deletedAt: OffsetDateTime? = null,
val deletedAt: LocalDateTime? = null,
)
/**
@@ -237,10 +246,10 @@ data class RequestMapItemDto(
val importance: Double,
/** Начало временного окна выполнения заявки. */
val beginDateTime: OffsetDateTime,
val beginDateTime: LocalDateTime,
/** Окончание временного окна выполнения заявки. */
val endDateTime: OffsetDateTime,
val endDateTime: LocalDateTime,
/** Список номеров КПП для сброса. */
val kpp: List<Int> = emptyList(),
@@ -252,13 +261,13 @@ data class RequestMapItemDto(
val coveragePercent: Double,
/** Время создания заявки. */
val createdAt: OffsetDateTime,
val createdAt: LocalDateTime,
/** Время последнего обновления заявки. */
val updatedAt: OffsetDateTime,
val updatedAt: LocalDateTime,
/** Время soft delete, если заявка удалена. */
val deletedAt: OffsetDateTime? = null,
val deletedAt: LocalDateTime? = null,
)
/**
@@ -317,7 +326,7 @@ data class DeleteRequestResponseDto(
val id: UUID,
/** Время soft delete. */
val deletedAt: OffsetDateTime,
val deletedAt: LocalDateTime,
)
/**
@@ -421,16 +430,16 @@ data class RequestListFilter(
val highPriorityTransmit: Boolean? = null,
/** Нижняя граница beginDateTime, включительно. */
val beginFrom: OffsetDateTime? = null,
val beginFrom: LocalDateTime? = null,
/** Верхняя граница beginDateTime, включительно. */
val beginTo: OffsetDateTime? = null,
val beginTo: LocalDateTime? = null,
/** Нижняя граница endDateTime, включительно. */
val endFrom: OffsetDateTime? = null,
val endFrom: LocalDateTime? = null,
/** Верхняя граница endDateTime, включительно. */
val endTo: OffsetDateTime? = null,
val endTo: LocalDateTime? = null,
/** Включать soft-deleted заявки в результат. */
val includeDeleted: Boolean = false,
@@ -48,6 +48,14 @@ class OutboxEventEntity(
@Column(name = "created_at", nullable = false)
var createdAt: LocalDateTime,
/** Число выполненных попыток публикации. */
@Column(name = "attempts", nullable = false)
var attempts: Int = 0,
/** Не раньше этого момента событие снова попадёт в выборку публикации (backoff). */
@Column(name = "next_attempt_at", nullable = false)
var nextAttemptAt: LocalDateTime = createdAt,
@Column(name = "published_at")
var publishedAt: LocalDateTime? = null,
@@ -16,8 +16,9 @@ interface OutboxEventRepository : JpaRepository<OutboxEventEntity, UUID> {
fun findByStatusOrderByCreatedAtAsc(status: OutboxEventStatus, pageable: Pageable): List<OutboxEventEntity>
/**
* Locks publishable REQUEST_COMPLETED events so parallel service instances skip rows already
* being published by another transaction.
* Locks publishable events so parallel service instances skip rows already being published by
* another transaction. Учитывает backoff (`next_attempt_at`) и предел попыток (`attempts`):
* терминальные FAILED (`attempts >= maxAttempts`) и ещё не «дозревшие» строки исключаются.
*/
@Query(
value = """
@@ -25,6 +26,8 @@ interface OutboxEventRepository : JpaRepository<OutboxEventEntity, UUID> {
FROM outbox_events
WHERE event_type = :eventType
AND status IN ('NEW', 'FAILED')
AND attempts < :maxAttempts
AND next_attempt_at <= :now
ORDER BY
CASE WHEN status = 'NEW' THEN 0 ELSE 1 END,
created_at ASC
@@ -33,8 +36,10 @@ interface OutboxEventRepository : JpaRepository<OutboxEventEntity, UUID> {
""",
nativeQuery = true,
)
fun findRequestCompletedPublishableForUpdateSkipLocked(
fun findPublishableForUpdateSkipLocked(
@Param("eventType") eventType: String,
@Param("now") now: LocalDateTime,
@Param("maxAttempts") maxAttempts: Int,
@Param("batchSize") batchSize: Int,
): List<OutboxEventEntity>
@@ -52,6 +57,8 @@ interface OutboxEventRepository : JpaRepository<OutboxEventEntity, UUID> {
aggregate_id,
payload,
status,
attempts,
next_attempt_at,
created_at,
published_at,
error_message
@@ -63,6 +70,8 @@ interface OutboxEventRepository : JpaRepository<OutboxEventEntity, UUID> {
:aggregateId,
CAST(:payload AS jsonb),
:status,
0,
:createdAt,
:createdAt,
NULL,
NULL
@@ -106,4 +106,27 @@ interface RequestRepository : JpaRepository<RequestEntity, UUID> {
): List<RequestEntity>
fun findByStatusIn(statuses: Collection<RequestStatus>): List<RequestEntity>
/**
* Захватывает порцию просроченных заявок (окно уже закончилось, статус ещё незавершённый)
* под row-level lock. FOR UPDATE SKIP LOCKED позволяет нескольким инстансам разбирать
* непересекающиеся строки, не конкурируя за одни и те же заявки.
*/
@Query(
value = """
SELECT *
FROM requests
WHERE status IN ('ACCEPTED', 'ACTIVE')
AND end_date_time < :now
AND deleted_at IS NULL
ORDER BY end_date_time ASC
LIMIT :batchSize
FOR UPDATE SKIP LOCKED
""",
nativeQuery = true,
)
fun findExpirableForUpdateSkipLocked(
@Param("now") now: LocalDateTime,
@Param("batchSize") batchSize: Int,
): List<RequestEntity>
}
@@ -7,8 +7,7 @@ import org.nstart.dep265.requestservice.repository.RequestCellRepository
import org.nstart.dep265.requestservice.repository.RequestRepository
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.OffsetDateTime
import java.time.ZoneOffset
import java.time.LocalDateTime
class GridRebuildAlreadyRunningException : RuntimeException("Grid rebuild is already running")
@@ -41,7 +40,7 @@ class GridRebuildService(
return GridRebuildResponseDto(
cellsCount = earthCellRepository.count(),
rebuiltRequestProjectionsCount = activeRequests.size.toLong(),
rebuiltAt = OffsetDateTime.now(ZoneOffset.UTC),
rebuiltAt = LocalDateTime.now(),
)
}
@@ -11,6 +11,7 @@ import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Service
import org.springframework.transaction.support.TransactionCallback
import org.springframework.transaction.support.TransactionOperations
import java.time.Duration
import java.time.LocalDateTime
import java.util.concurrent.ExecutionException
@@ -41,60 +42,91 @@ class OutboxPublisherService(
return try {
transactionOperations.execute(
TransactionCallback {
val events = outboxEventRepository.findRequestCompletedPublishableForUpdateSkipLocked(
eventType = RequestCompletedOutboxService.REQUEST_COMPLETED_EVENT_TYPE,
val now = LocalDateTime.now()
val events = outboxEventRepository.findPublishableForUpdateSkipLocked(
eventType = RequestStatusOutboxService.REQUEST_STATUS_EVENT_TYPE,
now = now,
maxAttempts = outboxProperties.maxAttempts,
batchSize = batchSize,
).distinctBy { it.id }
events.forEach { event ->
publishAndUpdateStatus(event)
publishAndUpdateStatus(event, now)
}
events.size
},
) ?: 0
} catch (exception: Exception) {
log.error("Failed to process REQUEST_COMPLETED outbox event batch", exception)
log.error("Failed to process REQUEST_TERMINAL_STATUS outbox event batch", exception)
0
}
}
private fun publishAndUpdateStatus(event: OutboxEventEntity) {
private fun publishAndUpdateStatus(event: OutboxEventEntity, now: LocalDateTime) {
try {
kafkaTemplate
.send(
outboxProperties.requestCompletedTopic,
outboxProperties.requestStatusTopic,
event.aggregateId.toString(),
event.payload.toString(),
)
.get()
event.status = OutboxEventStatus.PUBLISHED
event.publishedAt = LocalDateTime.now()
event.publishedAt = now
event.errorMessage = null
outboxEventRepository.save(event)
log.info(
"Published REQUEST_COMPLETED outbox event: eventId={}, requestId={}, topic={}",
"Published REQUEST_TERMINAL_STATUS outbox event: eventId={}, requestId={}, topic={}",
event.id,
event.aggregateId,
outboxProperties.requestCompletedTopic,
outboxProperties.requestStatusTopic,
)
} catch (exception: Exception) {
val publishException = exception.unwrapExecutionException()
event.attempts += 1
event.status = OutboxEventStatus.FAILED
event.errorMessage = publishException.shortMessage()
// attempts >= maxAttempts → терминальный FAILED (выборка публикации его уже не вернёт);
// иначе планируем повтор с backoff.
if (event.attempts < outboxProperties.maxAttempts) {
event.nextAttemptAt = now.plus(Duration.ofMillis(calculateBackoffMs(event.attempts)))
}
outboxEventRepository.save(event)
val exhausted = event.attempts >= outboxProperties.maxAttempts
log.warn(
"Failed to publish REQUEST_COMPLETED outbox event: eventId={}, requestId={}, topic={}",
"Failed to publish REQUEST_TERMINAL_STATUS outbox event: eventId={}, requestId={}, " +
"topic={}, attempts={}, maxAttempts={}, exhausted={}, nextAttemptAt={}",
event.id,
event.aggregateId,
outboxProperties.requestCompletedTopic,
outboxProperties.requestStatusTopic,
event.attempts,
outboxProperties.maxAttempts,
exhausted,
event.nextAttemptAt,
publishException,
)
}
}
/**
* Удваивает backoff от [OutboxProperties.initialBackoffMs] до [OutboxProperties.maxBackoffMs].
*/
private fun calculateBackoffMs(attempts: Int): Long {
var backoffMs = outboxProperties.initialBackoffMs.coerceAtMost(outboxProperties.maxBackoffMs)
repeat((attempts - 1).coerceAtLeast(0)) {
backoffMs = if (backoffMs >= outboxProperties.maxBackoffMs / 2) {
outboxProperties.maxBackoffMs
} else {
(backoffMs * 2).coerceAtMost(outboxProperties.maxBackoffMs)
}
}
return backoffMs
}
private fun Exception.unwrapExecutionException(): Throwable {
return if (this is ExecutionException && cause != null) {
cause!!
@@ -0,0 +1,118 @@
package org.nstart.dep265.requestservice.service
import org.nstart.dep265.requestservice.config.RequestExpiryProperties
import org.nstart.dep265.requestservice.entity.RequestStatus
import org.nstart.dep265.requestservice.repository.RequestRepository
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Service
import org.springframework.transaction.support.TransactionCallback
import org.springframework.transaction.support.TransactionOperations
import java.time.LocalDateTime
/**
* Планировщик истечения заявок.
*
* [RequestLifecycleService.refreshStatus] вызывается только при матчинге маршрута, поэтому заявка,
* к которой не пришло ни одного маршрута, никогда не уходила в EXPIRED после окончания окна.
* Этот воркер периодически переводит такие заявки в EXPIRED.
*
* Несколько инстансов безопасны: батч строк захватывается через FOR UPDATE SKIP LOCKED, так что
* параллельные инстансы разбирают непересекающиеся заявки.
*/
@Service
class RequestExpiryService(
private val requestRepository: RequestRepository,
private val requestLifecycleService: RequestLifecycleService,
private val requestStatusOutboxService: RequestStatusOutboxService,
private val requestExpiryProperties: RequestExpiryProperties,
@param:Qualifier("requestExpiryTransactionOperations")
private val transactionOperations: TransactionOperations,
) {
private val log = LoggerFactory.getLogger(this::class.java)
@Scheduled(fixedDelayString = "\${pcp.request.expiry.fixed-delay-ms:60000}")
fun expireOverdueScheduledBatch() {
if (!requestExpiryProperties.enabled) {
return
}
expireOverdue()
}
/**
* Переводит просроченные заявки (ACCEPTED/ACTIVE с истёкшим окном) в EXPIRED.
*
* @return число заявок, перешедших в EXPIRED за прогон.
*/
fun expireOverdue(): Int {
val batchSize = requestExpiryProperties.batchSize.coerceAtLeast(0)
if (batchSize == 0) {
return 0
}
val maxBatches = requestExpiryProperties.maxBatchesPerRun.coerceAtLeast(1)
val now = LocalDateTime.now()
var expiredTotal = 0
var batchIndex = 0
while (batchIndex < maxBatches) {
val result = try {
transactionOperations.execute(
TransactionCallback { expireBatch(now, batchSize) },
) ?: ExpireBatchResult.EMPTY
} catch (exception: Exception) {
log.error("Failed to process request expiry batch", exception)
break
}
expiredTotal += result.expiredCount
batchIndex += 1
if (result.claimedCount < batchSize) {
break
}
}
if (expiredTotal > 0) {
log.info("Request expiry sweep finished: expired={}", expiredTotal)
}
return expiredTotal
}
private fun expireBatch(now: LocalDateTime, batchSize: Int): ExpireBatchResult {
val candidates = requestRepository.findExpirableForUpdateSkipLocked(now, batchSize)
var expiredCount = 0
candidates.forEach { request ->
val refresh = requestLifecycleService.refreshStatus(request, now)
if (refresh.statusChanged) {
request.updatedAt = now
requestRepository.save(request)
if (request.status == RequestStatus.EXPIRED) {
expiredCount += 1
// Терминальный статус → уведомляем geoportal через тот же transactional outbox.
requestStatusOutboxService.createTerminalIfAbsent(request, now)
log.info(
"Request expired: requestId={}, endDateTime={}",
request.id,
request.endDateTime,
)
}
}
}
return ExpireBatchResult(claimedCount = candidates.size, expiredCount = expiredCount)
}
private data class ExpireBatchResult(
val claimedCount: Int,
val expiredCount: Int,
) {
companion object {
val EMPTY = ExpireBatchResult(claimedCount = 0, expiredCount = 0)
}
}
}

Some files were not shown because too many files have changed in this diff Show More