Merge remote-tracking branch 'origin/dev' into dev
This commit is contained in:
@@ -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/
|
||||
|
||||
Executable
+75
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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` в рантайме.
|
||||
|
||||
@@ -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` (см. разделы 2–3).
|
||||
@@ -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)
|
||||
@@ -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 изменений
|
||||
|
||||
@@ -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-слоя).
|
||||
@@ -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`** — пошагово: зависимости, монтирование как вкладки,
|
||||
контракты данных и где подключить реальные эфемериды/расписание.
|
||||
+203
@@ -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 />);
|
||||
+73
@@ -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 };
|
||||
})();
|
||||
+331
@@ -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;
|
||||
+180
@@ -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;
|
||||
+158
@@ -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 dφ = (lat2 - lat1) * D2R;
|
||||
const dλ = (lon2 - lon1) * D2R;
|
||||
const a = Math.sin(dφ / 2) ** 2 + Math.cos(φ1) * Math.cos(φ2) * Math.sin(dλ / 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,
|
||||
};
|
||||
})();
|
||||
+160
@@ -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;
|
||||
+34
@@ -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>
|
||||
+180
@@ -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;}
|
||||
+210
@@ -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 dφ = (lat2 - lat1) * D2R;
|
||||
const dλ = (lon2 - lon1) * D2R;
|
||||
const a = Math.sin(dφ / 2) ** 2 + Math.cos(φ1) * Math.cos(φ2) * Math.sin(dλ / 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 и тесты; покрытие — отношение площадей).
|
||||
@@ -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:50–12: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
@@ -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
|
||||
|
||||
|
||||
+46
@@ -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
|
||||
)
|
||||
+31
@@ -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
|
||||
}
|
||||
+17
-6
@@ -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)
|
||||
}
|
||||
|
||||
+7
-1
@@ -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)
|
||||
}
|
||||
|
||||
+82
@@ -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)
|
||||
}
|
||||
Executable → Regular
+22
-22
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
+23
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+26
-7
@@ -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),
|
||||
|
||||
+40
@@ -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}
|
||||
|
||||
+3
-4
@@ -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,
|
||||
|
||||
+3
-4
@@ -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,
|
||||
|
||||
+14
-1
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+6
-13
@@ -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)
|
||||
|
||||
}
|
||||
|
||||
+22
@@ -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}
|
||||
|
||||
+6
@@ -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;
|
||||
+3
-4
@@ -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,
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+6
-7
@@ -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}
|
||||
|
||||
-15
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+9
-8
@@ -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)
|
||||
|
||||
+1
-2
@@ -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:
|
||||
|
||||
+1
-2
@@ -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(),
|
||||
|
||||
+6
-4
@@ -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()
|
||||
|
||||
+6
@@ -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>) {
|
||||
|
||||
+31
@@ -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
|
||||
)
|
||||
+156
@@ -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)
|
||||
}
|
||||
|
||||
+55
@@ -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>
|
||||
)
|
||||
+16
-3
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+16
-3
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+3
@@ -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>
|
||||
}
|
||||
|
||||
+16
-2
@@ -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)
|
||||
|
||||
+3
-3
@@ -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>
|
||||
}
|
||||
|
||||
+574
-13
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-2
@@ -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}
|
||||
|
||||
+8
@@ -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;
|
||||
+297
@@ -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 }
|
||||
}
|
||||
+293
@@ -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 }
|
||||
}
|
||||
+8
-10
@@ -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))
|
||||
|
||||
|
||||
+131
@@ -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"
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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(
|
||||
|
||||
+8
-1
@@ -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,
|
||||
)
|
||||
|
||||
+11
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+19
@@ -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,
|
||||
)
|
||||
+28
@@ -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,
|
||||
)
|
||||
+9
-9
@@ -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,
|
||||
|
||||
+2
-2
@@ -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,
|
||||
)
|
||||
|
||||
+32
-23
@@ -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,
|
||||
|
||||
+8
@@ -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,
|
||||
|
||||
|
||||
+12
-3
@@ -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
|
||||
|
||||
+23
@@ -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>
|
||||
}
|
||||
|
||||
+2
-3
@@ -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(),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+43
-11
@@ -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!!
|
||||
|
||||
+118
@@ -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
Reference in New Issue
Block a user