Add PCP architecture docs and TGU ops updates

Capture current PCP architecture notes, service-map prototypes, TGU operations UI/map work, local configuration updates, database helper scripts, and request/sample JSON artifacts.
This commit is contained in:
Дмитрий Соловьев
2026-05-30 14:18:19 +03:00
parent 9fca4d4051
commit c3a1a8b4a1
86 changed files with 10426 additions and 74 deletions
+1
View File
@@ -55,3 +55,4 @@ out/
/deploy/local-bundle/build/
/deploy/local-bundle/pcp-local-bundle-*.tar.gz
/services/pcp-tgu-ops-ui/node_modules/
/services/pcp-tgu-ops-ui/dist/
+4 -4
View File
@@ -1,16 +1,16 @@
# TODO before merge request: вернуть старые адреса окружения.
# pcp.infra.postgres.host: 192.168.60.201
# pcp.infra.postgres.host: 192.168.1.8
pcp:
infra:
postgres:
host: 192.168.60.201
host: 192.168.1.8
port: 35400
kafka:
host: 192.168.60.201
host: 192.168.1.8
port: 19092
network:
host: 192.168.60.201
host: 192.168.1.8
ports:
config: 8888
+1 -1
View File
@@ -56,7 +56,7 @@ spring:
camunda:
client:
enabled: ${CAMUNDA_CLIENT_ENABLED:false}
grpc-address: http://192.168.60.201:26500
grpc-address: http://192.168.1.8:26500
auth:
method: none
prefer-rest-over-grpc: false
+3 -3
View File
@@ -3,16 +3,16 @@
pcp:
infra:
postgres:
host: ${PCP_POSTGRES_HOST:192.168.60.201}
host: ${PCP_POSTGRES_HOST:192.168.1.8}
port: ${PCP_POSTGRES_PORT:5432}
kafka:
host: ${PCP_KAFKA_HOST:192.168.60.201}
host: ${PCP_KAFKA_HOST:192.168.1.8}
port: ${PCP_KAFKA_PORT:29092}
network:
host: ${PCP_NETWORK_HOST:192.168.60.201}
host: ${PCP_NETWORK_HOST:192.168.1.8}
ports:
config: 8888
+2 -2
View File
@@ -75,7 +75,7 @@ spring:
camunda:
client:
mode: self-managed
grpc-address: http://192.168.60.201:26500
grpc-address: http://192.168.1.8:26500
auth:
method: none
prefer-rest-over-grpc: false
@@ -136,7 +136,7 @@ spring:
camunda:
client:
mode: self-managed
grpc-address: http://192.168.60.201:26500
grpc-address: http://192.168.1.8:26500
auth:
method: none
prefer-rest-over-grpc: false
+1 -1
View File
@@ -14,7 +14,7 @@ spring:
camunda:
client:
mode: self-managed
grpc-address: http://192.168.60.201:26500
grpc-address: http://192.168.1.8:26500
auth:
method: none
prefer-rest-over-grpc: false
+127
View File
@@ -0,0 +1,127 @@
cat > create-service-dbs.sh <<'BASH'
#!/usr/bin/env bash
set -euo pipefail
NAMESPACE="pcp-dev"
POSTGRES_POD="pcp-postgres-0"
validate_db_name() {
local db_name="$1"
if [[ ! "$db_name" =~ ^[a-z][a-z0-9_]*$ ]]; then
echo "ERROR: invalid database name: $db_name" >&2
echo "Allowed: lowercase letters, digits, underscore; must start with a letter." >&2
exit 1
fi
}
secret_name_for_db() {
local db_name="$1"
echo "${db_name//_/-}-db"
}
user_name_for_db() {
local db_name="$1"
echo "${db_name}_user"
}
get_or_create_password() {
local secret_name="$1"
if kubectl -n "$NAMESPACE" get secret "$secret_name" >/dev/null 2>&1; then
kubectl -n "$NAMESPACE" get secret "$secret_name" \
-o jsonpath='{.data.SPRING_DATASOURCE_PASSWORD}' | base64 -d
else
openssl rand -base64 32 | tr -d '/+=' | head -c 32
fi
}
create_db_user_and_secret() {
local db_name="$1"
validate_db_name "$db_name"
local db_user
local secret_name
local db_password
db_user="$(user_name_for_db "$db_name")"
secret_name="$(secret_name_for_db "$db_name")"
db_password="$(get_or_create_password "$secret_name")"
echo
echo "============================================================"
echo "Creating database package"
echo " database: $db_name"
echo " user: $db_user"
echo " secret: $secret_name"
echo "============================================================"
echo "1/4 Create or update PostgreSQL user and database"
kubectl -n "$NAMESPACE" exec -i "$POSTGRES_POD" -- psql \
-U postgres \
-d postgres <<SQL
DO \$\$
BEGIN
IF NOT EXISTS (
SELECT FROM pg_roles WHERE rolname = '$db_user'
) THEN
CREATE ROLE "$db_user" LOGIN PASSWORD '$db_password';
ELSE
ALTER ROLE "$db_user" WITH LOGIN PASSWORD '$db_password';
END IF;
END
\$\$;
SELECT 'CREATE DATABASE "$db_name" OWNER "$db_user"'
WHERE NOT EXISTS (
SELECT FROM pg_database WHERE datname = '$db_name'
) \gexec
ALTER DATABASE "$db_name" OWNER TO "$db_user";
GRANT CONNECT ON DATABASE "$db_name" TO "$db_user";
SQL
echo "2/4 Enable PostGIS"
kubectl -n "$NAMESPACE" exec -i "$POSTGRES_POD" -- psql \
-U postgres \
-d "$db_name" <<SQL
CREATE EXTENSION IF NOT EXISTS postgis;
CREATE EXTENSION IF NOT EXISTS postgis_topology;
ALTER SCHEMA public OWNER TO "$db_user";
GRANT USAGE, CREATE ON SCHEMA public TO "$db_user";
SQL
echo "3/4 Create or update Kubernetes Secret"
kubectl -n "$NAMESPACE" create secret generic "$secret_name" \
--from-literal=SPRING_DATASOURCE_USERNAME="$db_user" \
--from-literal=SPRING_DATASOURCE_PASSWORD="$db_password" \
--dry-run=client -o yaml | kubectl apply -f -
echo "4/4 Check connection and PostGIS"
kubectl -n "$NAMESPACE" exec -i "$POSTGRES_POD" -- env PGPASSWORD="$db_password" psql \
-h localhost \
-U "$db_user" \
-d "$db_name" \
-c 'select current_database(), current_user, postgis_full_version();'
echo "Done: $db_name"
}
if [[ "$#" -eq 0 ]]; then
echo "Usage: $0 <db_name> [db_name...]"
exit 1
fi
for db_name in "$@"; do
create_db_user_and_secret "$db_name"
done
echo
echo "All requested databases processed."
BASH
+285
View File
@@ -0,0 +1,285 @@
#!/usr/bin/env bash
set -euo pipefail
NAMESPACE="${NAMESPACE:-pcp-dev}"
POSTGRES_SERVICE="${POSTGRES_SERVICE:-pcp-postgres}"
CLUSTER_DOMAIN="${CLUSTER_DOMAIN:-altum.local}"
POSTGRES_IMAGE="${POSTGRES_IMAGE:-postgis/postgis:15-3.3}"
ADMIN_SECRET="${ADMIN_SECRET:-pcp-postgres-admin}"
ADMIN_PASSWORD_KEY="${ADMIN_PASSWORD_KEY:-POSTGRES_PASSWORD}"
POSTGRES_HOST="${POSTGRES_SERVICE}.${NAMESPACE}.svc.${CLUSTER_DOMAIN}"
usage() {
cat <<EOF
Usage:
$0 <db_name> [db_name...]
$0 -f databases.txt
Examples:
$0 pcp_satellite_catalog pcp_ballistics
cat > databases.txt <<LIST
pcp_satellite_catalog
pcp_ballistics
pcp_requests
LIST
$0 -f databases.txt
Environment overrides:
NAMESPACE=pcp-dev
POSTGRES_SERVICE=pcp-postgres
CLUSTER_DOMAIN=altum.local
POSTGRES_IMAGE=postgis/postgis:15-3.3
EOF
}
require_command() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "ERROR: required command not found: $1" >&2
exit 1
fi
}
validate_db_name() {
local db_name="$1"
if [[ ! "$db_name" =~ ^[a-z][a-z0-9_]*$ ]]; then
echo "ERROR: invalid database name: $db_name" >&2
echo "Allowed format: lowercase letters, digits, underscore; must start with a letter." >&2
exit 1
fi
}
secret_name_for_db() {
local db_name="$1"
local dashed="${db_name//_/-}"
echo "${dashed}-db"
}
user_name_for_db() {
local db_name="$1"
echo "${db_name}_user"
}
make_pod_name() {
local prefix="$1"
local db_name="$2"
local safe="${db_name//_/-}"
safe="${safe:0:35}"
echo "${prefix}-${safe}-${RANDOM}${RANDOM}"
}
get_admin_password() {
kubectl -n "$NAMESPACE" get secret "$ADMIN_SECRET" \
-o jsonpath="{.data.${ADMIN_PASSWORD_KEY}}" | base64 -d
}
get_or_create_service_password() {
local secret_name="$1"
if kubectl -n "$NAMESPACE" get secret "$secret_name" >/dev/null 2>&1; then
kubectl -n "$NAMESPACE" get secret "$secret_name" \
-o jsonpath='{.data.SPRING_DATASOURCE_PASSWORD}' | base64 -d
else
openssl rand -base64 32 | tr -d '/+=' | head -c 32
fi
}
create_database_and_user() {
local db_name="$1"
local db_user="$2"
local db_password="$3"
local admin_password="$4"
local pod_name
pod_name="$(make_pod_name "psql-create" "$db_name")"
echo "Creating/updating database and user:"
echo " database: $db_name"
echo " user: $db_user"
kubectl -n "$NAMESPACE" run --rm -i "$pod_name" \
--image="$POSTGRES_IMAGE" \
--restart=Never \
--env="PGPASSWORD=$admin_password" \
--command -- psql \
-h "$POSTGRES_HOST" \
-U postgres \
-d postgres <<SQL
DO \$\$
BEGIN
IF NOT EXISTS (
SELECT FROM pg_roles WHERE rolname = '$db_user'
) THEN
CREATE ROLE "$db_user" LOGIN PASSWORD '$db_password';
ELSE
ALTER ROLE "$db_user" WITH LOGIN PASSWORD '$db_password';
END IF;
END
\$\$;
SELECT 'CREATE DATABASE "$db_name" OWNER "$db_user"'
WHERE NOT EXISTS (
SELECT FROM pg_database WHERE datname = '$db_name'
) \gexec
ALTER DATABASE "$db_name" OWNER TO "$db_user";
GRANT CONNECT ON DATABASE "$db_name" TO "$db_user";
SQL
}
enable_postgis() {
local db_name="$1"
local db_user="$2"
local admin_password="$3"
local pod_name
pod_name="$(make_pod_name "psql-postgis" "$db_name")"
echo "Enabling PostGIS:"
echo " database: $db_name"
kubectl -n "$NAMESPACE" run --rm -i "$pod_name" \
--image="$POSTGRES_IMAGE" \
--restart=Never \
--env="PGPASSWORD=$admin_password" \
--command -- psql \
-h "$POSTGRES_HOST" \
-U postgres \
-d "$db_name" <<SQL
CREATE EXTENSION IF NOT EXISTS postgis;
CREATE EXTENSION IF NOT EXISTS postgis_topology;
ALTER SCHEMA public OWNER TO "$db_user";
GRANT USAGE, CREATE ON SCHEMA public TO "$db_user";
SQL
}
create_or_update_secret() {
local db_name="$1"
local db_user="$2"
local db_password="$3"
local secret_name="$4"
echo "Creating/updating Kubernetes Secret:"
echo " secret: $secret_name"
kubectl -n "$NAMESPACE" create secret generic "$secret_name" \
--from-literal=SPRING_DATASOURCE_USERNAME="$db_user" \
--from-literal=SPRING_DATASOURCE_PASSWORD="$db_password" \
--dry-run=client -o yaml | kubectl apply -f -
}
check_database() {
local db_name="$1"
local db_user="$2"
local db_password="$3"
local pod_name
pod_name="$(make_pod_name "psql-check" "$db_name")"
echo "Checking database:"
echo " database: $db_name"
echo " user: $db_user"
kubectl -n "$NAMESPACE" run --rm -i "$pod_name" \
--image="$POSTGRES_IMAGE" \
--restart=Never \
--env="PGPASSWORD=$db_password" \
--command -- psql \
-h "$POSTGRES_HOST" \
-U "$db_user" \
-d "$db_name" \
-c 'select current_database(), current_user, postgis_full_version();'
}
read_db_list() {
if [[ $# -eq 0 ]]; then
usage
exit 1
fi
if [[ "$1" == "-h" || "$1" == "--help" ]]; then
usage
exit 0
fi
if [[ "$1" == "-f" || "$1" == "--file" ]]; then
if [[ $# -ne 2 ]]; then
echo "ERROR: file path is required after $1" >&2
exit 1
fi
local file="$2"
if [[ ! -f "$file" ]]; then
echo "ERROR: file not found: $file" >&2
exit 1
fi
grep -vE '^\s*($|#)' "$file"
else
printf '%s\n' "$@"
fi
}
main() {
require_command kubectl
require_command openssl
require_command base64
echo "Namespace: $NAMESPACE"
echo "Postgres host: $POSTGRES_HOST"
echo "Postgres image: $POSTGRES_IMAGE"
echo "Admin secret: $ADMIN_SECRET"
echo
local admin_password
admin_password="$(get_admin_password)"
mapfile -t databases < <(read_db_list "$@")
if [[ "${#databases[@]}" -eq 0 ]]; then
echo "ERROR: database list is empty" >&2
exit 1
fi
for db_name in "${databases[@]}"; do
validate_db_name "$db_name"
local db_user
local secret_name
local db_password
local jdbc_url
db_user="$(user_name_for_db "$db_name")"
secret_name="$(secret_name_for_db "$db_name")"
db_password="$(get_or_create_service_password "$secret_name")"
jdbc_url="jdbc:postgresql://${POSTGRES_HOST}:5432/${db_name}"
echo
echo "============================================================"
echo "Processing database: $db_name"
echo "============================================================"
create_database_and_user "$db_name" "$db_user" "$db_password" "$admin_password"
enable_postgis "$db_name" "$db_user" "$admin_password"
create_or_update_secret "$db_name" "$db_user" "$db_password" "$secret_name"
check_database "$db_name" "$db_user" "$db_password"
echo
echo "Done:"
echo " database: $db_name"
echo " user: $db_user"
echo " secret: $secret_name"
echo " JDBC URL: $jdbc_url"
done
echo
echo "All databases processed successfully."
}
main "$@"
+26
View File
@@ -0,0 +1,26 @@
pcp_ballistics
pcp_satellites
pcp_complex_plan
pcp_missions
pcp_requests
pcp_route_processing
pcp_satellite_catalog +
pcp_slots
pcp_stations
pcp_tgu
pcp_tle
pcp_ballistics pcp_satellites pcp_complex_plan pcp_missions pcp_requests pcp_route_processing pcp_satellite_catalog pcp_slots pcp_stations pcp_tgu pcp_tle
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env bash
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}"
PGPORT="${PGPORT:-5432}"
PGUSER="${PGUSER:-postgres}"
PGPASSWORD="${PGPASSWORD:-password}"
export PGPASSWORD
DATABASES=(
pcp_ballistics
pcp_complex_plan
pcp_missions
pcp_requests
pcp_satellite_catalog
pcp_satellites
pcp_slots
pcp_stations
pcp_tgu
pcp_tle
)
# Services whose migrations use PostGIS geometry/extension.
POSTGIS_DATABASES=(
pcp_ballistics
pcp_missions
pcp_requests
pcp_slots
pcp_stations
)
psql_admin() {
psql -v ON_ERROR_STOP=1 \
--host="$PGHOST" \
--port="$PGPORT" \
--username="$PGUSER" \
--dbname=postgres \
"$@"
}
psql_db() {
local db="$1"
shift
psql -v ON_ERROR_STOP=1 \
--host="$PGHOST" \
--port="$PGPORT" \
--username="$PGUSER" \
--dbname="$db" \
"$@"
}
create_database_if_missing() {
local db="$1"
local exists
exists="$(psql_admin -tAc "SELECT 1 FROM pg_database WHERE datname = '$db'")"
if [[ "$exists" == "1" ]]; then
echo "Database already exists: $db"
else
echo "Creating database: $db"
psql_admin -c "CREATE DATABASE \"$db\" OWNER \"$PGUSER\" ENCODING 'UTF8';"
fi
}
enable_postgis_if_possible() {
local db="$1"
echo "Enabling PostGIS in database: $db"
psql_db "$db" -c "CREATE EXTENSION IF NOT EXISTS postgis;"
}
main() {
echo "Target PostgreSQL: ${PGHOST}:${PGPORT}, user=${PGUSER}"
for db in "${DATABASES[@]}"; do
create_database_if_missing "$db"
done
for db in "${POSTGIS_DATABASES[@]}"; do
enable_postgis_if_possible "$db"
done
echo "Done. Databases are ready. Services will create their schemas via Flyway on startup."
}
main "$@"
+266
View File
@@ -0,0 +1,266 @@
# Observatio Terrae (pcp) — карта сервисов, взаимодействие и контракты
> Базовый справочный документ для доработки системы планирования ДЗЗ.
> Составлен из исходного кода (контроллеры, Kafka listeners/publishers, HTTP-клиенты) и `config-repo`.
> Помечено явно, что **проверено по коду**, а что **требует уточнения** (`⚠`).
Стек: Kotlin + Spring Boot, Spring Cloud Config, Spring Boot Admin, Camunda 8 / Zeebe (оркестрация), Kafka (события), PostgreSQL (БД на сервис, Flyway), Helm + GitLab CI.
---
## 1. Карта сервисов — кто за что отвечает
### Доменные сервисы
| Сервис | Зона ответственности | 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-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` | 🟡 средняя |
| **tle-monitoring-service** | Подтяжка TLE (CelesTrak), мониторинг каталога | — | TLE-история | in: `pcp.satellites` · out: `pcp.tle` ⚠ | 🟡 средняя |
| **pcp-coverage-scheme-service** | Расчёт схемы покрытия (coverage scheme) | `/api/coverage-schemes` | результаты схем | in: `pcp.satellites` | 🟡 средняя |
| **slots-service** | Слоты сброса/ресурсы: расчёт, бронирование, статусы слотов | (см. контроллеры) | слоты, брони | in/out: `pcp.slots.status.v1` (`BookedSlotsStatusChangedEvent`); in: `pcp.satellites` | 🟢 высокая (лучшее покрытие тестами) |
| **pcp-complex-mission-service** | Комплексный план по тепловой карте методом слотов; снапшоты планов | `/api/satellites`, `/api/com-plan` | комплексные планы, снапшоты | in: `pcp.satellites` | 🟢 высокая |
| **pcp-mission-planing-service** | План по конкретному КА: съёмка / сброс / стирание; **хост Zeebe-воркеров** оркестрации | `/api/missions` | миссии, режимы | in: `pcp.slots.status.v1`, `pcp.route.in.v1`, `pcp.satellites` | 🔴 низкая (есть заглушки в `FlowService`) |
| **pcp-dynamic-plan-service** | Динамический/текущий план (greedy-математика, расчёт по интервалам) | `/v1/main/calcPlan` | прогоны (runs), результаты | in: `pcp.satellites` | 🟡 средняя |
| **pcp-tgu-service** | Операторская оркестрация (ТГУ/Mission Ops): окна видимости, выдача планов, решения по планам; **деплой и запуск BPMN** | `/api/plans`, `/api/platforms`, `/test/tgu` | планы КА, dedup-таблицы | in: `pcp.tgu.satellite-plan-decision.v1` · out: `pcp.tgu.visibility-windows-changed.v1` | 🟢 высокая |
| **pcp-stations-service** | Каталог наземных станций приёма | `/api/stations` | станции | — | 🟡 средняя |
| **pcp-ui-service** | BFF/агрегатор для веб-UI и 3D-глобуса (CZML/Cesium); проксирует во все сервисы | `/requests`, `/map`, `/api/*` | — (stateless) | — | 🟡 средняя |
\* Зрелость = оценка по соотношению тестов, наличию idempotency/outbox, чистоте слоёв и отсутствию заглушек (см. предыдущий разбор).
### Инфраструктурные модули
| Модуль | Роль |
|---|---|
| **spring-cloud-config-server** | Раздаёт конфигурацию из `config-repo` всем сервисам |
| **pcp-srpring-boot-admin-server** | Spring Boot Admin — мониторинг/health всех сервисов (опечатка в имени: `srpring`) |
| **pcp-tgu-ops-ui** | Плейсхолдер фронта Mission Ops (пустой, прототипы в `docs/ui-prototypes`) |
| **libs/pcp-types-lib** | Общие DTO и конверт `KafkaMessage<T>`, перечисление `PcpKafkaEvent` |
| **libs/ballistics-lib** | Общие баллистические расчёты |
---
## 2. Диаграммы взаимодействия
### 2.1. Синхронные зависимости (REST) — кто кого вызывает
Рёбра восстановлены из ключей `*-service*` base-url в `config-repo` и имён HTTP-клиентов в коде.
```mermaid
flowchart TD
UI[pcp-ui-service<br/>BFF / агрегатор]
REQ[pcp-request-service<br/>заявки + earth-grid]
CAT[pcp-satellite-catalog-service<br/>каталог КА]
BAL[pcp-ballistics-service]
CM[pcp-complex-mission-service]
MP[pcp-mission-planing-service]
SL[slots-service]
CS[pcp-coverage-scheme-service]
DP[pcp-dynamic-plan-service]
TGU[pcp-tgu-service]
ST[pcp-stations-service]
TLE[tle-monitoring-service]
UI --> REQ & CM & DP & TGU & CAT & BAL & ST & SL & MP & CS
CM --> BAL & REQ & CAT & ST & SL & CS
MP --> BAL & CM & CAT & ST
SL --> REQ & CAT
DP --> REQ & BAL & CAT
CS --> BAL & CM & CAT
TLE --> CM & CAT
TGU --> BAL
TLE -.внешний.-> CTRAK[(CelesTrak)]
TGU -.внешний.-> ORD[(ordinis.nstart.cloud<br/>классификатор платформ + external points)]
```
Наблюдения по графу:
- `pcp-satellite-catalog-service` и `pcp-ballistics-service` — самые востребованные (почти все зовут их). Кандидаты на повышенные требования к SLA/надёжности.
- `pcp-ui-service` — точка агрегации (BFF), завязан на всё.
- ⚠ Есть взаимный вызов `complex-mission ⇄ coverage-scheme` (CM зовёт CS на расчёт, CS в конфиге знает про CM) — проверить, нет ли циклической зависимости в рантайме.
### 2.2. Событийный слой (Kafka) — потоки данных
```mermaid
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{{?}}
CAT[satellite-catalog] -->|pcp.satellites / SatelliteDeletedEvent| BAL[ballistics]
CAT --> TLEM[tle-monitoring]
CAT --> SL[slots]
CAT --> MP
CAT --> CM[complex-mission]
CAT --> DP[dynamic-plan]
TLEM -->|pcp.tle| BAL
SL -->|pcp.slots.status.v1 / BookedSlotsStatusChanged| MP
SL -->|pcp.slots.status.v1| SL
MissionOps[Mission Ops / КА ⚠] -->|pcp.tgu.satellite-plan-decision.v1| TGU[tgu-service]
TGU -->|pcp.tgu.visibility-windows-changed.v1| TGU
```
### 2.3. Сквозной сценарий планирования (sequence)
Упрощённый happy-path от заявки до выдачи задания на КА.
```mermaid
sequenceDiagram
participant U as UI
participant RQ as request-service
participant RP as route-processing
participant CM as complex-mission
participant SL as slots-service
participant TGU as tgu-service (Camunda)
participant MP as mission-planing (Zeebe workers)
U->>RQ: POST заявка на съёмку
RP-->>RQ: pcp.route.georeference.v1 (геопривязанные маршруты)
RQ->>RQ: сопоставление маршрут↔заявка, RequestCompleted (outbox)
U->>CM: POST /api/com-plan/process (комплексный план)
CM->>SL: расчёт/бронирование слотов
CM->>CM: снапшот комплексного плана
U->>TGU: POST /test/tgu/plans/{id}/issue
TGU->>TGU: start BPMN createSatelliteMission
Note over TGU,MP: Zeebe job dispatch по jobType
TGU-->>MP: calculateSatelliteSurveyMissions
MP->>MP: план съёмки
TGU-->>MP: calculateSatelliteDropMissions
MP->>MP: план сброса
TGU-->>MP: sendSatellitePlan
MP-->>TGU: Message_SatellitePlanAccepted ⚠ (сейчас имитация)
MissionOps-->>TGU: pcp.tgu.satellite-plan-decision.v1 (ACCEPTED/REJECTED)
TGU->>TGU: updateAcceptedPlanSlots BPMN → updateSlotsStatus
```
---
## 3. Контракты между сервисами
### 3.1. Оркестрация Camunda 8 / Zeebe
**Деплой BPMN:** `pcp-tgu-service` через `@Deployment` при старте грузит:
`classpath:BPMN/createSatelliteMission.bpmn`, `classpath:BPMN/updateAcceptedPlanSlots.bpmn`.
**Zeebe job workers** (реализованы в `pcp-mission-planing-service/FlowService`):
| jobType | Назначение | Вход (process variables) | Выход | Примечание |
|---|---|---|---|---|
| `calculateSatelliteSurveyMissions` | План съёмки | `satellitePlanId`, `spacecraftId`, `planStartTime`, `planEndTime` | `modes: Int` | `autoComplete=false` |
| `calculateSatelliteDropMissions` | План сброса | `satellitePlanId` | `modes: Int` | — |
| `sendSatellitePlan` | Отправка плана на КА | `satellitePlanId` | — | ⚠ сейчас `Thread.sleep` + имитация подтверждения |
| `updateSlotsStatus` | Подтверждение, обновление слотов | `satellitePlanId` | `slots: List` | используется в `updateAcceptedPlanSlots` |
**Camunda message:** `Message_SatellitePlanAccepted`, `correlationKey = satellitePlanId`.
> ⚠ `schemes/pcp_v1.bpmn` — большой концептуальный процесс (шлюзы, параллельные ветки, подпроцессы), **не задеплоен** и без jobType. Реально работает только минимальный `createSatelliteMission.bpmn` (start → 3 service task → end, без обработки ошибок).
### 3.2. Kafka-топики (реестр)
Конверт по умолчанию — `KafkaMessage<T>` из `pcp-types-lib`: поля `type`, `data`, `traceId`, `correlationId`, `id`, `source`, `time`. Тип события — enum `PcpKafkaEvent`.
| Топик | Producer | Consumer(s) | Тип события / payload | Ключ |
|---|---|---|---|---|
| `pcp.satellites` | satellite-catalog | ballistics, tle-monitoring, slots, mission-planing, complex-mission, dynamic-plan | `SatelliteDeletedEvent` (`satelliteId`, `noradId`) | `satelliteId` |
| `pcp.tle` | tle-monitoring / catalog ⚠ | ballistics (фильтры IC/ICRV/TLE) | TLE / `ICPlacedEvent` / `ICRVPlacedEvent` / `ICUpdatedEvent` | ⚠ |
| `pcp.request.survey-georeference.v1` | внешний / UI ⚠ | route-processing | RoutePassport (сырой) | ⚠ |
| `pcp.request.survey-georeference-dlq.v1` | route-processing | (DLQ) | — | — |
| `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.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` | ⚠ |
> ⚠ Реализация консьюмеров неоднородна: одни читают типизированный `KafkaMessage<T>`, другие парсят сырое дерево и ждут поле `data`; используются обе версии Jackson (2 и 3). Это первый кандидат на унификацию контракта.
### 3.3. REST-эндпоинты (контракты API по сервисам)
**request-service** (`/v1`)
```
GET /v1/requests/{id}
GET /v1/requests/{requestId}/with-cells
DELETE /v1/requests/{id}
GET /v1/cells/with-requests
GET /v1/grid/settings PUT /v1/grid/settings POST /v1/grid/rebuild
```
**satellite-catalog-service** (`/api/satellites`, `/api/satellite-groups`)
```
GET/PUT/DELETE /api/satellites/{satellite_id}
GET /api/satellites/by-norad/{norad_id}
GET/POST/PUT/DELETE /api/satellites/{satellite_id}/observation-profile
GET/POST/PUT/DELETE /api/satellites/{satellite_id}/slot-profile
POST /api/satellites/batch
GET/PUT/DELETE /api/satellite-groups/{group_id}
```
**ballistics-service** (`/api`)
```
POST /api/satellites/{satellite_id}/extract-time POST /api/satellites/{satellite_id}/clear
GET /api/satellites/{norad_id}/orbit | /asc-node | /flight-line | /rva
GET /api/satellites/orbit/availability[/{id}]
POST /api/obj-view/mpl-square | /mpl-point GET /api/obj-view/cell-coverage/{number}
POST /api/tle/parse | /api/tle/rva
```
**complex-mission-service** (`/api`)
```
POST /api/com-plan/process | /clear GET/DELETE /api/com-plan/runs/{id}
GET /api/com-plan/runs | /runs/{id}/modes | /cell-with-mars/{cell_number}
GET /api/satellites/{satellite_id}[/mission|/modes|/snapshots|/mission/statistics|/mission-for-paint]
GET /api/satellites/snapshots[/{snapshot_id}[/modes]] | /modes POST /api/satellites/prepare
```
**mission-planing-service** (`/api/missions`)
```
GET /api/missions/{mission_id}[/modes] GET /api/missions/modes/satellite
POST /api/missions/{mission_id}/drops/calculate POST /api/missions/{mission_id}/surveys/calculate
POST /api/missions/{mission_id}/confirm PUT /api/missions/survey-modes/{mode_id}/status
PUT/DELETE /api/missions/{mission_id}
```
**slots-service / coverage-scheme / dynamic-plan / stations / tgu**
```
coverage-scheme : POST /api/coverage-schemes/calculate
dynamic-plan : POST /v1/main/calcPlan ; GET /v1/main/calcPlan/runs|/{runId}[/result|/routes|/intervals]
stations : GET /api/stations[/{station_id}] ; POST /api/stations ; DELETE /api/stations/{station_id}
tgu : GET /api/plans/{spacecraftId} ; /api/platforms ;
POST /test/tgu/plans/{planId}/issue|/decision ; /spacecraft/{id}/refresh-visibility|/rebuild-plans
```
**ui-service** — BFF, агрегирует вызовы: `/requests/*`, `/map`, `/api/requests`, `/api/slots/booking/request`,
`/api/com-plan/*`, `/api/tgu-planning/*`, `/api/current-plans/*`, `/api/dynamic-plan/*`, `/api/stations/*`.
### 3.4. Внешние интеграции
| Откуда | Куда | Назначение |
|---|---|---|
| tle-monitoring-service | CelesTrak | загрузка TLE |
| tgu-service | `https://ordinis.nstart.cloud/api/v1/spacecraft/records` | классификатор платформ (фильтр `OPERATIONAL`), кэш 15m |
| tgu-service | external points API (`pcp.services.ballistics`) | внешние точки (lookahead 3 дня) |
---
## 4. Открытые вопросы для уточнения перед доработкой
Эти пункты помечены `⚠` выше — их стоит зафиксировать до изменений:
1. Producer топиков `pcp.request.survey-georeference.v1` и `pcp.request.in.v1` (внешняя система или UI?).
2. Consumer `pcp.request.completed.v1` — кто слушает завершённые заявки (предположительно complex-mission/планирование).
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` в рантайме.
6. Расхождение задеплоенного `createSatelliteMission.bpmn` и концептуального `schemes/pcp_v1.bpmn` — какой считать целевым.
---
*Документ описывает текущее состояние «как есть». При доработке обновлять разделы 2–3 синхронно с изменениями контроллеров, listener'ов и `config-repo`.*
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

@@ -0,0 +1,83 @@
<!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" />
<style>
:root {
--bg-0: oklch(0.18 0.012 165);
--bg-1: oklch(0.215 0.013 165);
--bg-2: oklch(0.255 0.014 165);
--bg-3: oklch(0.30 0.015 165);
--line: oklch(0.34 0.014 165);
--line-soft: oklch(0.28 0.013 165);
--grid: oklch(0.26 0.012 165);
--ink: oklch(0.95 0.01 150);
--ink-1: oklch(0.80 0.012 160);
--ink-2: oklch(0.64 0.012 165);
--ink-3: oklch(0.50 0.012 165);
--t-optical: oklch(0.80 0.14 158);
--t-radar: oklch(0.82 0.13 82);
--t-combo: oklch(0.74 0.15 6);
--t-optical-dim: oklch(0.80 0.14 158 / 0.16);
--t-radar-dim: oklch(0.82 0.13 82 / 0.16);
--t-combo-dim: oklch(0.74 0.15 6 / 0.16);
--accent: oklch(0.82 0.13 200);
--warn: oklch(0.80 0.15 55);
--now: oklch(0.86 0.16 25);
--radius: 7px;
--mono: "IBM Plex Mono", ui-monospace, monospace;
--sans: "IBM Plex Sans", system-ui, sans-serif;
--shadow: 0 8px 30px oklch(0.12 0.01 165 / 0.55);
}
* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; }
body {
font-family: var(--sans);
background: var(--bg-0);
color: var(--ink);
font-size: 13px;
-webkit-font-smoothing: antialiased;
overflow: hidden;
}
#root { height: 100vh; }
::-webkit-scrollbar { width: 11px; height: 11px; }
::-webkit-scrollbar-track { background: var(--bg-0); }
::-webkit-scrollbar-thumb { background: var(--bg-3); border-radius: 6px; border: 2px solid var(--bg-0); }
::-webkit-scrollbar-thumb:hover { background: var(--line); }
::-webkit-scrollbar-corner { background: var(--bg-0); }
.mono { font-family: var(--mono); }
.tnum { font-variant-numeric: tabular-nums; }
button { font-family: inherit; color: inherit; cursor: pointer; }
a { -webkit-tap-highlight-color: transparent; }
</style>
</head>
<body>
<div id="root"></div>
<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>
<script src="data.jsx"></script>
<script src="world.js"></script>
<script src="basemap.js"></script>
<script type="text/babel" src="util.jsx"></script>
<script type="text/babel" src="orbit.jsx"></script>
<script type="text/babel" src="tabs.jsx"></script>
<script type="text/babel" src="mapview.jsx"></script>
<script type="text/babel" src="mapdetails.jsx"></script>
<script type="text/babel" src="mapapp.jsx"></script>
</body>
</html>
+227
View File
@@ -0,0 +1,227 @@
// Корневой компонент: состояние, фильтры, масштаб, тулбар, сборка строк.
const { useState, useMemo, useEffect, useRef } = React;
// Пресеты масштаба: имя → видимое окно в мс (центрируется на «сейчас» при выборе).
const HOUR = D.HOUR, DAY = D.DAY;
const SCALES = [
{ id: "week", label: "Неделя", span: 7 * DAY, minPx: 64 },
{ id: "2week", label: "2 недели", span: 14 * DAY, minPx: 70 },
{ id: "month", label: "Месяц", span: 30 * DAY, minPx: 80 },
{ id: "range", label: "Весь диапазон", span: D.RANGE_END - D.RANGE_START, minPx: 90 },
];
function App() {
// Видимость групп / скрытые КА.
const [groupShown, setGroupShown] = useState(() => {
const o = {}; D.GROUP_DEFS.forEach((g) => (o[g.id] = true)); return o;
});
const [scHidden, setScHidden] = useState(() => new Set());
const [typeFilter, setTypeFilter] = useState({ optical: true, radar: true, combo: true });
const [search, setSearch] = useState("");
const [scaleId, setScaleId] = useState("week");
const [selectedIv, setSelectedIv] = useState(() => {
const p = new URLSearchParams(location.search).get("plan");
return p ? p : null;
});
const [selectedSc, setSelectedSc] = useState(null);
const [timelineCollapsed, setTimelineCollapsed] = useState(() => new Set());
const [scrollSignal, setScrollSignal] = useState(0);
const [winCenter, setWinCenter] = useState(D.NOW);
const byId = useMemo(() => {
const m = {}; D.plans.forEach((iv) => (m[iv.id] = iv)); return m;
}, []);
const scById = useMemo(() => {
const m = {}; D.spacecraft.forEach((sc) => (m[sc.id] = sc)); return m;
}, []);
// Масштаб → px/ms (умещаем span в условные 1100px рабочей ширины).
const scale = SCALES.find((s) => s.id === scaleId);
const VIEW_W = 1180;
const pxPerMs = VIEW_W / scale.span;
const rangeStart = D.RANGE_START;
const rangeEnd = D.RANGE_END;
// Фильтрация КА.
const scByGroup = useMemo(() => {
const m = {};
D.spacecraft.forEach((sc) => { (m[sc.groupId] = m[sc.groupId] || []).push(sc); });
return m;
}, []);
const q = search.trim().toLowerCase();
const matchesSearch = (sc) => !q || sc.name.toLowerCase().includes(q) || sc.short.toLowerCase().includes(q);
// Счётчики для легенды.
const counts = useMemo(() => {
const byType = {};
D.spacecraft.forEach((sc) => { byType[sc.type] = (byType[sc.type] || 0) + 1; });
return { byType };
}, []);
// Интервалы по КА (с учётом типового фильтра не трогаем — фильтр по типу скрывает строки целиком).
const ivBySc = useMemo(() => {
const m = {};
D.plans.forEach((iv) => { (m[iv.scId] = m[iv.scId] || []).push(iv); });
return m;
}, []);
// Сборка строк таймлайна.
const rows = useMemo(() => {
const out = [];
for (const g of D.GROUP_DEFS) {
if (!groupShown[g.id]) continue;
const list = (scByGroup[g.id] || [])
.filter((sc) => typeFilter[sc.type])
.filter((sc) => !scHidden.has(sc.id))
.filter(matchesSearch);
if (list.length === 0) continue;
out.push({ kind: "group", group: g, count: list.length });
if (timelineCollapsed.has(g.id)) continue;
for (const sc of list) {
out.push({ kind: "sc", sc, ivs: ivBySc[sc.id] || [] });
}
}
return out;
}, [groupShown, scHidden, typeFilter, q, timelineCollapsed]);
// Цепочка родословной выбранного интервала.
const lineageSet = useMemo(() => {
if (!selectedIv) return null;
const lin = lineageOf(selectedIv, byId);
return new Set(lin.chain);
}, [selectedIv]);
const selIv = selectedIv ? byId[selectedIv] : null;
const selSc = selIv ? scById[selIv.scId] : null;
const selGroup = selSc ? D.GROUP_DEFS.find((g) => g.id === selSc.groupId) : null;
const toggleGroup = (id) => setGroupShown((s) => ({ ...s, [id]: !s[id] }));
const toggleSc = (id) => setScHidden((s) => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; });
const toggleCollapse = (id) => setTimelineCollapsed((s) => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; });
const onPickSc = (id) => {
setSelectedSc(id);
// прокрутка к первому интервалу этого КА
const ivs = ivBySc[id] || [];
if (ivs.length) { setSelectedIv(null); }
};
// Статистика по видимым.
const stats = useMemo(() => {
let sc = 0, iv = 0, ov = 0;
rows.forEach((r) => {
if (r.kind === "sc") {
sc++;
iv += r.ivs.length;
ov += overlapSegments(r.ivs).length;
}
});
return { sc, iv, ov };
}, [rows]);
const allOn = D.GROUP_DEFS.every((g) => groupShown[g.id]) && scHidden.size === 0;
const showAll = () => { const o = {}; D.GROUP_DEFS.forEach((g) => (o[g.id] = true)); setGroupShown(o); setScHidden(new Set()); };
return (
<div style={{ display: "flex", flexDirection: "column", height: "100vh" }}>
<TopTabs active="plans" mapHref="Map.html" />
<div style={{ flex: 1, display: "grid", gridTemplateColumns: `268px 1fr ${selIv ? "340px" : "0px"}`, minHeight: 0, transition: "grid-template-columns .18s" }}>
<Sidebar
groups={D.GROUP_DEFS} scByGroup={scByGroup}
typeFilter={typeFilter} setTypeFilter={setTypeFilter}
groupShown={groupShown} toggleGroup={toggleGroup}
scHidden={scHidden} toggleSc={toggleSc}
search={search} setSearch={setSearch}
counts={counts} selectedSc={selectedSc} onPickSc={onPickSc}
/>
<div style={{ display: "flex", flexDirection: "column", minWidth: 0 }}>
{/* ТУЛБАР */}
<div style={{ height: 50, flex: "none", display: "flex", alignItems: "center", gap: 14, padding: "0 16px", borderBottom: "1px solid var(--line)", background: "var(--bg-1)" }}>
{/* масштаб */}
<div style={{ display: "flex", gap: 2, background: "var(--bg-0)", borderRadius: 7, padding: 3, border: "1px solid var(--line)" }}>
{SCALES.map((s) => (
<button key={s.id} onClick={() => { setScaleId(s.id); setScrollSignal((x) => x + 1); }}
style={{ padding: "5px 11px", borderRadius: 5, border: "none", fontSize: 11.5, fontWeight: 600, background: scaleId === s.id ? "var(--bg-3)" : "transparent", color: scaleId === s.id ? "var(--ink)" : "var(--ink-3)" }}>{s.label}</button>
))}
</div>
<button onClick={() => setScrollSignal((x) => x + 1)} style={{ display: "flex", alignItems: "center", gap: 6, padding: "6px 11px", borderRadius: 6, border: "1px solid var(--line)", background: "var(--bg-2)", fontSize: 11.5, fontWeight: 600, color: "var(--now)" }}>
<span style={{ width: 6, height: 6, borderRadius: 9, background: "var(--now)", boxShadow: "0 0 6px var(--now)" }} />Сейчас
</button>
<div style={{ width: 1, height: 22, background: "var(--line)" }} />
<WorksLegend />
<div style={{ flex: 1 }} />
{/* статистика */}
<div style={{ display: "flex", gap: 16, alignItems: "center" }}>
<Stat label="АППАРАТОВ" v={stats.sc} />
<Stat label="ПЛАНОВ" v={stats.iv} />
<Stat label="ПЕРЕСЕЧЕНИЙ" v={stats.ov} accent={stats.ov > 0 ? "var(--now)" : null} />
</div>
{!allOn && (
<button onClick={showAll} style={{ padding: "6px 11px", borderRadius: 6, border: "1px solid var(--line)", background: "var(--bg-2)", fontSize: 11.5, color: "var(--ink-1)" }}>Показать все</button>
)}
</div>
{rows.length === 0 ? (
<div style={{ flex: 1, display: "grid", placeItems: "center", color: "var(--ink-3)", fontSize: 13 }}>
Нет аппаратов по текущим фильтрам.
</div>
) : (
<div style={{ flex: 1, minHeight: 0 }} onClick={() => {}}>
<Timeline
rows={rows} pxPerMs={pxPerMs} rangeStart={rangeStart} rangeEnd={rangeEnd}
now={D.NOW} selectedIv={selectedIv} onSelectIv={setSelectedIv}
lineageSet={lineageSet} byId={byId} scrollSignal={scrollSignal}
timelineCollapsed={timelineCollapsed} toggleCollapse={toggleCollapse}
/>
</div>
)}
</div>
<Details iv={selIv} sc={selSc} group={selGroup} byId={byId} onSelectIv={setSelectedIv} onClose={() => setSelectedIv(null)} />
</div>
</div>
);
}
function WorksLegend() {
const items = [
{ k: "shoot", label: "Съёмка", stripes: true },
{ k: "downlink", label: "Сброс", fill: "var(--accent)" },
{ k: "calib", label: "Калибровка", fill: "oklch(0.78 0.02 200)" },
{ k: "service", label: "Служебная", dashed: true },
];
return (
<div style={{ display: "flex", alignItems: "center", gap: 11 }}>
<span className="mono" style={{ fontSize: 8.5, color: "var(--ink-3)", letterSpacing: ".08em" }}>РАБОТЫ:</span>
{items.map((it) => (
<div key={it.k} style={{ display: "flex", alignItems: "center", gap: 5 }}>
<span style={{
width: 11, height: 9, borderRadius: 2, flex: "none",
background: it.stripes
? "linear-gradient(90deg, var(--t-optical) 0 33%, var(--t-radar) 33% 66%, var(--t-combo) 66% 100%)"
: it.dashed ? "transparent" : it.fill,
border: it.dashed ? "1px dashed var(--ink-3)" : "none",
}} />
<span style={{ fontSize: 10.5, color: "var(--ink-2)" }}>{it.label}</span>
</div>
))}
</div>
);
}
function Stat({ label, v, accent }) { return (
<div style={{ display: "flex", flexDirection: "column", alignItems: "flex-end", lineHeight: 1.1 }}>
<span className="mono tnum" style={{ fontSize: 15, fontWeight: 600, color: accent || "var(--ink)" }}>{v}</span>
<span className="mono" style={{ fontSize: 8, color: "var(--ink-3)", letterSpacing: ".08em" }}>{label}</span>
</div>
);
}
ReactDOM.createRoot(document.getElementById("root")).render(<App />);
@@ -0,0 +1,107 @@
// Web Mercator проекция + слой растровых тайлов (slippy-map) + кэш загрузки.
// Подложки: тёмная (CARTO, OSM-данные), классическая OSM, без тайлов (схема).
(function () {
const TILE = 256;
const DEG = Math.PI / 180, RAD = 180 / Math.PI;
const MAX_LAT = 85.0511;
// lon/lat -> мировые пиксели при размере мира ws (= TILE * 2^z).
function lonToWorldX(lon, ws) { return (lon + 180) / 360 * ws; }
function latToWorldY(lat, ws) {
const l = Math.max(-MAX_LAT, Math.min(MAX_LAT, lat));
const s = Math.sin(l * DEG);
const y = 0.5 - Math.log((1 + s) / (1 - s)) / (4 * Math.PI);
return y * ws;
}
function worldXToLon(wx, ws) { return wx / ws * 360 - 180; }
function worldYToLat(wy, ws) {
const n = Math.PI - 2 * Math.PI * (wy / ws);
return RAD * Math.atan(Math.sinh(n));
}
// Метры на пиксель в точке широты (для перевода км -> px).
function metersPerPixel(lat, ws) {
return Math.cos(lat * DEG) * 2 * Math.PI * 6378137 / ws;
}
const PROVIDERS = {
dark: {
id: "dark", name: "Тёмная",
url: (z, x, y) => `https://a.basemaps.cartocdn.com/dark_all/${z}/${x}/${y}.png`,
scrim: 0.0, label: "© OpenStreetMap · © CARTO", maxZoom: 18, dark: true,
},
osm: {
id: "osm", name: "Светлая",
url: (z, x, y) => `https://a.basemaps.cartocdn.com/rastertiles/voyager/${z}/${x}/${y}.png`,
scrim: 0.34, label: "© OpenStreetMap · © CARTO", maxZoom: 19, dark: false,
},
schema: {
id: "schema", name: "Схема", url: null, scrim: 0, label: "Схематичная основа (оффлайн)", dark: true,
},
};
// Кэш тайлов: key -> {img, loaded, error}.
const cache = new Map();
function getTile(provider, z, x, y, onLoad) {
const key = provider.id + "/" + z + "/" + x + "/" + y;
let rec = cache.get(key);
if (rec) return rec;
rec = { img: new Image(), loaded: false, error: false };
rec.img.crossOrigin = "anonymous";
rec.img.onload = () => { rec.loaded = true; onLoad && onLoad(); };
rec.img.onerror = () => { rec.error = true; };
rec.img.src = provider.url(z, x, y);
cache.set(key, rec);
return rec;
}
// Рисует слой тайлов на ctx для текущего вида.
// view: { centerLon, centerLat, z }, size: {w,h}. onLoad — колбэк перерисовки.
function drawTiles(ctx, provider, view, size, onLoad) {
if (!provider || !provider.url) return;
const { w, h } = size;
const z = view.z;
const tz = Math.max(1, Math.min(provider.maxZoom || 18, Math.round(z)));
const ws = TILE * Math.pow(2, z); // непрерывный размер мира
const k = Math.pow(2, z - tz); // масштаб тайла tz -> экран
const tileScreen = TILE * k;
const n = Math.pow(2, tz);
const cwx = lonToWorldX(view.centerLon, ws);
const cwy = latToWorldY(view.centerLat, ws);
const originX = cwx - w / 2; // мировой px (непрерывный) в левом верхнем углу
const originY = cwy - h / 2;
// Видимый диапазон тайлов в tz-пространстве.
const leftTz = (originX / k) / TILE;
const topTz = (originY / k) / TILE;
const rightTz = ((originX + w) / k) / TILE;
const botTz = ((originY + h) / k) / TILE;
const x0 = Math.floor(leftTz), x1 = Math.floor(rightTz);
const y0 = Math.max(0, Math.floor(topTz)), y1 = Math.min(n - 1, Math.floor(botTz));
ctx.imageSmoothingEnabled = true;
for (let tx = x0; tx <= x1; tx++) {
const wrapX = ((tx % n) + n) % n; // горизонтальный повтор мира
for (let ty = y0; ty <= y1; ty++) {
const rec = getTile(provider, tz, wrapX, ty, onLoad);
const sx = tx * tileScreen - originX;
const sy = ty * tileScreen - originY;
if (rec.loaded) {
ctx.drawImage(rec.img, Math.floor(sx), Math.floor(sy), Math.ceil(tileScreen) + 1, Math.ceil(tileScreen) + 1);
}
}
}
// затемняющий scrim (для светлой OSM, чтобы overlay читался)
if (provider.scrim > 0) {
ctx.fillStyle = `rgba(8,16,12,${provider.scrim})`;
ctx.fillRect(0, 0, w, h);
}
}
window.Basemap = {
TILE, MAX_LAT, PROVIDERS,
lonToWorldX, latToWorldY, worldXToLon, worldYToLat,
metersPerPixel, drawTiles, getTile,
};
})();
+195
View File
@@ -0,0 +1,195 @@
// Тестовые данные планов КА. Детерминированная генерация (seeded RNG),
// чтобы при перезагрузке данные были стабильны.
(function () {
function mulberry32(a) {
return function () {
a |= 0; a = (a + 0x6D2B79F5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const rnd = mulberry32(20260529);
const pick = (arr) => arr[Math.floor(rnd() * arr.length)];
const randint = (a, b) => a + Math.floor(rnd() * (b - a + 1));
const HOUR = 3600 * 1000;
const DAY = 24 * HOUR;
// "Сейчас" — фиксируем под текущую дату проекта.
const NOW = new Date("2026-05-29T11:20:00Z").getTime();
// Три типа по аппаратуре (все аппараты — ДЗЗ).
const TYPES = {
optical: { id: "optical", label: "Оптическая", short: "ОПТ" },
radar: { id: "radar", label: "Радиолокационная", short: "РЛ" },
combo: { id: "combo", label: "Комбинированная", short: "ОПТ+РЛ" },
};
// Программы/созвездия (название группы). Каждая группа тяготеет к своим типам.
const GROUP_DEFS = [
{ id: "resurs", name: "Ресурс-ОП", code: "RSO", weights: { optical: 0.8, radar: 0.05, combo: 0.15 } },
{ id: "kanopus", name: "Канопус-В", code: "KNP", weights: { optical: 0.7, radar: 0.1, combo: 0.2 } },
{ id: "kondor", name: "Кондор-ФКА", code: "KND", weights: { optical: 0.05, radar: 0.85, combo: 0.1 } },
{ id: "obzor", name: "Обзор-Р", code: "OBZ", weights: { optical: 0.05, radar: 0.8, combo: 0.15 } },
{ id: "meteor", name: "Метеор-М", code: "MTR", weights: { optical: 0.55, radar: 0.15, combo: 0.3 } },
{ id: "aist", name: "Аист-2", code: "AST", weights: { optical: 0.6, radar: 0.1, combo: 0.3 } },
{ id: "smotr", name: "Смотр-Р", code: "SMT", weights: { optical: 0.1, radar: 0.55, combo: 0.35 } },
];
function weightedType(weights) {
const r = rnd();
let acc = 0;
for (const k of Object.keys(weights)) {
acc += weights[k];
if (r <= acc) return k;
}
return "optical";
}
// Виды РАБОТ внутри плана. У каждой работы — своя категория (kind).
const WORK_KINDS = {
shoot: { id: "shoot", label: "Съёмка" },
downlink: { id: "downlink", label: "Сброс на НКПОР" },
calib: { id: "calib", label: "Калибровка" },
service: { id: "service", label: "Служебная операция" },
};
const WORK_KIND_ORDER = ["shoot", "downlink", "calib", "service"];
// Подпись работы-съёмки зависит от аппаратуры КА.
const SHOOT_LABEL = {
optical: ["Панхром. съёмка", "Мультиспектр. съёмка", "Стереосъёмка", "Площадная съёмка"],
radar: ["РСА съёмка", "Интерферометрия", "Прожекторный режим", "Сканирующий режим"],
combo: ["Совмещ. съёмка", "РСА + панхром.", "Площадная съёмка", "Мониторинг ЧС"],
};
// Названия самих ПЛАНОВ (объемлющих интервалов).
const PLAN_NAMES = ["Суточная программа", "Целевая программа", "Спецзадание", "Программа мониторинга", "Сеансовый план", "Программа ДЗЗ"];
function genWorks(planStart, planEnd, scType, makeId) {
const works = [];
const span = planEnd - planStart;
// Плотность работ ~ 1 на 4–9 часов плана.
let cursor = planStart + randint(0, 2) * HOUR;
let guard = 0;
while (cursor < planEnd - 1 * HOUR && guard < 40) {
guard++;
const kind = pick(WORK_KIND_ORDER);
let dur;
if (kind === "shoot") dur = randint(20, 140) * 60 * 1000; // 20м–2.3ч
else if (kind === "downlink") dur = randint(8, 35) * 60 * 1000; // сброс
else if (kind === "calib") dur = randint(30, 90) * 60 * 1000;
else dur = randint(10, 40) * 60 * 1000; // служебная
const wStart = cursor;
const wEnd = Math.min(planEnd, wStart + dur);
if (wEnd <= wStart) break;
const label = kind === "shoot" ? pick(SHOOT_LABEL[scType]) : WORK_KINDS[kind].label;
works.push({ id: makeId(), kind, label, start: wStart, end: wEnd });
// пауза между работами
cursor = wEnd + randint(20, 180) * 60 * 1000;
}
return works;
}
// Генерация КА.
const spacecraft = [];
let scCounter = 0;
for (const g of GROUP_DEFS) {
const count = randint(8, 16); // суммарно ~80
for (let i = 0; i < count; i++) {
const type = weightedType(g.weights);
scCounter++;
const num = i + 1;
spacecraft.push({
id: `${g.id}-${num}`,
name: `${g.name}${num}`,
short: `${g.code}-${String(num).padStart(2, "0")}`,
groupId: g.id,
type,
orbit: pick(["ССО 510 км", "ССО 575 км", "ССО 700 км", "ССО 480 км"]),
});
}
}
// Видимый горизонт планирования: ~ -16 сут .. +30 сут от "сейчас".
const RANGE_START = NOW - 16 * DAY;
const RANGE_END = NOW + 30 * DAY;
function statusFor(start, end) {
if (end < NOW) return "executed";
if (start <= NOW && NOW <= end) return "active";
return "planned";
}
// Генерация ПЛАНОВ (объемлющих интервалов) с родословной (ревизии плана = потомки).
// Каждый план содержит набор РАБОТ внутри своего интервала.
const plans = [];
let ivCounter = 0;
let workCounter = 0;
const makeWorkId = () => `W-${String(++workCounter).padStart(5, "0")}`;
for (const sc of spacecraft) {
// стартовая точка цепочки
let cursor = RANGE_START + randint(0, 3) * DAY + randint(0, 20) * HOUR;
let prevId = null;
const nChains = randint(2, 4);
for (let c = 0; c < nChains; c++) {
// базовый план в цепочке
const dur = randint(8, 60) * HOUR;
const start = cursor;
const end = start + dur;
ivCounter++;
const baseId = `PL-${String(ivCounter).padStart(4, "0")}`;
const planName = pick(PLAN_NAMES);
plans.push({
id: baseId,
scId: sc.id,
start, end,
title: planName,
rev: 1,
parentId: prevId,
status: statusFor(start, end),
author: pick(["ЦУП-1", "ЦУП-2", "ПЦ Москва", "ПЦ Дубна", "Авто-планировщик"]),
works: genWorks(start, end, sc.type, makeWorkId),
});
prevId = baseId;
// c вероятностью — ревизия, перекрывающая базовый план (создаёт пересечение)
if (rnd() < 0.45) {
const overlapStart = start + Math.floor(dur * (0.35 + rnd() * 0.4));
const revDur = randint(10, 50) * HOUR;
const revEnd = overlapStart + revDur;
ivCounter++;
const revId = `PL-${String(ivCounter).padStart(4, "0")}`;
plans.push({
id: revId,
scId: sc.id,
start: overlapStart,
end: revEnd,
title: planName,
rev: 2,
parentId: baseId,
status: statusFor(overlapStart, revEnd),
author: pick(["ЦУП-1", "ЦУП-2", "ПЦ Москва", "Авто-планировщик"]),
works: genWorks(overlapStart, revEnd, sc.type, makeWorkId),
});
prevId = revId;
cursor = revEnd + randint(4, 36) * HOUR;
} else {
cursor = end + randint(6, 48) * HOUR;
}
if (cursor > RANGE_END) break;
}
}
// Индексы родословной: дети по родителю.
const childrenOf = {};
for (const iv of plans) {
if (iv.parentId) {
(childrenOf[iv.parentId] = childrenOf[iv.parentId] || []).push(iv.id);
}
}
window.MissionData = {
HOUR, DAY, NOW, RANGE_START, RANGE_END,
TYPES, GROUP_DEFS, WORK_KINDS, WORK_KIND_ORDER,
spacecraft, plans, childrenOf,
typeOrder: ["optical", "radar", "combo"],
};
})();
@@ -0,0 +1,159 @@
// Правая панель деталей выбранного интервала + лента родословной (предки/потомки).
function StatusBadge({ status }) {
const map = {
executed: { t: "ВЫПОЛНЕН", c: "var(--ink-2)", bg: "var(--bg-3)" },
active: { t: "АКТИВЕН", c: "var(--now)", bg: "oklch(0.86 0.16 25 / 0.16)" },
planned: { t: "ЗАПЛАНИРОВАН", c: "var(--accent)", bg: "oklch(0.82 0.13 200 / 0.14)" },
};
const s = map[status] || map.planned;
return <span className="mono" style={{ fontSize: 9.5, fontWeight: 600, letterSpacing: ".06em", color: s.c, background: s.bg, padding: "2px 7px", borderRadius: 4 }}>{s.t}</span>;
}
function Field({ label, children, mono }) {
return (
<div style={{ display: "flex", flexDirection: "column", gap: 3 }}>
<span className="mono" style={{ fontSize: 9, color: "var(--ink-3)", letterSpacing: ".08em" }}>{label}</span>
<span className={mono ? "mono tnum" : ""} style={{ fontSize: 12.5, color: "var(--ink)" }}>{children}</span>
</div>
);
}
function Details({ iv, sc, group, byId, onSelectIv, onClose }) {
if (!iv) {
return (
<div style={{ height: "100%", background: "var(--bg-1)", borderLeft: "1px solid var(--line)", display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 10, padding: 24, textAlign: "center" }}>
<svg width="34" height="34" viewBox="0 0 24 24" fill="none" style={{ opacity: .3 }}><rect x="3" y="6" width="18" height="3.4" rx="1.5" stroke="var(--ink-2)" strokeWidth="1.6" /><rect x="3" y="13" width="11" height="3.4" rx="1.5" stroke="var(--ink-2)" strokeWidth="1.6" /></svg>
<div style={{ fontSize: 12, color: "var(--ink-3)", maxWidth: 180 }}>Выберите план на таймлайне, чтобы увидеть его работы и цепочку ревизий.</div>
</div>
);
}
const lin = lineageOf(iv.id, byId);
const dur = iv.end - iv.start;
const col = TYPE_COLOR[sc.type];
const chainRow = (id) => {
const it = byId[id];
const isSel = id === iv.id;
return (
<button key={id} onClick={() => onSelectIv(id)} style={{
display: "flex", alignItems: "center", gap: 8, width: "100%", textAlign: "left",
padding: "7px 9px", borderRadius: 6, border: `1px solid ${isSel ? "var(--accent)" : "var(--line-soft)"}`,
background: isSel ? "oklch(0.82 0.13 200 / 0.12)" : "var(--bg-2)",
}}>
<span className="mono tnum" style={{ fontSize: 9.5, color: "var(--ink-3)", flex: "none" }}>{it.id}</span>
<span style={{ flex: 1, fontSize: 11.5, color: isSel ? "var(--ink)" : "var(--ink-1)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{it.title}</span>
<span className="mono" style={{ fontSize: 8.5, color: "var(--ink-3)", border: "1px solid var(--line)", borderRadius: 3, padding: "0 4px", flex: "none" }}>r{it.rev}</span>
</button>
);
};
return (
<div style={{ height: "100%", background: "var(--bg-1)", borderLeft: "1px solid var(--line)", display: "flex", flexDirection: "column" }}>
<div style={{ padding: "14px 16px 12px", borderBottom: "1px solid var(--line-soft)" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 8 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8, minWidth: 0 }}>
<span style={{ width: 10, height: 10, borderRadius: 3, background: col, boxShadow: `0 0 8px ${col}`, flex: "none" }} />
<span className="mono" style={{ fontSize: 10, color: "var(--ink-3)" }}>{iv.id}</span>
</div>
<button onClick={onClose} style={{ background: "none", border: "none", color: "var(--ink-3)", fontSize: 18, lineHeight: 1, padding: 0 }}>×</button>
</div>
<div style={{ fontSize: 17, fontWeight: 700, marginTop: 8, lineHeight: 1.2 }}>{iv.title}</div>
<div style={{ display: "flex", alignItems: "center", gap: 8, marginTop: 9 }}>
<StatusBadge status={iv.status} />
<span className="mono" style={{ fontSize: 10.5, color: "var(--ink-2)" }}>{sc.short} · {group.name}</span>
</div>
</div>
<div style={{ flex: 1, overflowY: "auto", padding: "14px 16px 20px", display: "flex", flexDirection: "column", gap: 16 }}>
{/* Время */}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
<Field label="НАЧАЛО" mono>{fmtDateTime(iv.start)}</Field>
<Field label="КОНЕЦ" mono>{fmtDateTime(iv.end)}</Field>
<Field label="ДЛИТЕЛЬНОСТЬ" mono>{fmtDur(dur)}</Field>
<Field label="РАБОТ В ПЛАНЕ" mono>{iv.works.length}</Field>
</div>
<div style={{ height: 1, background: "var(--line-soft)" }} />
{/* Аппарат */}
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<span className="mono" style={{ fontSize: 9, color: "var(--ink-3)", letterSpacing: ".08em" }}>АППАРАТ</span>
<div style={{ display: "flex", alignItems: "center", gap: 9 }}>
<TypeDot type={sc.type} size={9} />
<div>
<div style={{ fontSize: 13, fontWeight: 600 }}>{sc.name}</div>
<div style={{ fontSize: 10.5, color: "var(--ink-3)" }}>{D.TYPES[sc.type].label} · {sc.orbit}</div>
</div>
</div>
<Field label="ИСТОЧНИК ПЛАНА">{iv.author}</Field>
</div>
<div style={{ height: 1, background: "var(--line-soft)" }} />
{/* Работы внутри плана */}
<div style={{ display: "flex", flexDirection: "column", gap: 9 }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<span className="mono" style={{ fontSize: 9, color: "var(--ink-3)", letterSpacing: ".08em" }}>РАБОТЫ В ПЛАНЕ</span>
<span className="mono" style={{ fontSize: 9, color: "var(--ink-3)" }}>{iv.works.length}</span>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 3 }}>
{iv.works.length === 0 && <div style={{ fontSize: 11, color: "var(--ink-3)" }}>Работы не заданы.</div>}
{iv.works.map((wk) => {
const fill = workFill(wk.kind, sc.type);
const isService = wk.kind === "service";
return (
<div key={wk.id} style={{ display: "flex", alignItems: "center", gap: 8, padding: "5px 8px", borderRadius: 6, background: "var(--bg-2)" }}>
<span style={{ width: 9, height: 9, borderRadius: 2, flex: "none", background: isService ? "transparent" : fill, border: isService ? `1px dashed ${fill}` : "none" }} />
<span style={{ flex: 1, fontSize: 11.5, color: "var(--ink-1)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{wk.label}</span>
<span className="mono tnum" style={{ fontSize: 10, color: "var(--ink-3)", flex: "none" }}>{fmtTime(wk.start)}{fmtTime(wk.end)}</span>
</div>
);
})}
</div>
</div>
<div style={{ height: 1, background: "var(--line-soft)" }} />
{/* Родословная */}
<div style={{ display: "flex", flexDirection: "column", gap: 9 }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<span className="mono" style={{ fontSize: 9, color: "var(--ink-3)", letterSpacing: ".08em" }}>РОДОСЛОВНАЯ ПЛАНА</span>
<span className="mono" style={{ fontSize: 9, color: "var(--ink-3)" }}>{lin.chain.length} ревизий</span>
</div>
{lin.ancestors.length > 0 && (
<div style={{ display: "flex", flexDirection: "column", gap: 5 }}>
<span style={{ fontSize: 9.5, color: "var(--ink-3)" }}> Предки</span>
{lin.ancestors.map(chainRow)}
</div>
)}
<div style={{ display: "flex", flexDirection: "column", gap: 5 }}>
<span style={{ fontSize: 9.5, color: "var(--accent)" }}> Текущий</span>
{chainRow(iv.id)}
</div>
{lin.descendants.length > 0 && (
<div style={{ display: "flex", flexDirection: "column", gap: 5 }}>
<span style={{ fontSize: 9.5, color: "var(--ink-3)" }}> Потомки</span>
{lin.descendants.map(chainRow)}
</div>
)}
{lin.ancestors.length === 0 && lin.descendants.length === 0 && (
<div style={{ fontSize: 11, color: "var(--ink-3)", padding: "4px 0" }}>Это единственная ревизия плана без предков и потомков.</div>
)}
</div>
</div>
<div style={{ padding: "12px 16px", borderTop: "1px solid var(--line-soft)" }}>
<a href={`Map.html?plan=${iv.id}`} style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 7, padding: "9px 0", borderRadius: 7, border: "1px solid var(--line)", background: "var(--bg-2)", color: "var(--ink-1)", fontSize: 12, fontWeight: 600, textDecoration: "none" }}>
Показать на карте
<svg width="13" height="13" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="2" /><path d="M3 12h18M12 3c3 3 3 15 0 18M12 3c-3 3-3 15 0 18" stroke="currentColor" strokeWidth="1.4" fill="none" /></svg>
</a>
</div>
</div>
);
}
Object.assign(window, { Details });
@@ -0,0 +1,162 @@
// Страница «Карта»: вкладки, левые контролы (охват/слои/выбор КА), детали, ?plan=.
const { useState: useStateMA, useMemo: useMemoMA, useEffect: useEffectMA } = React;
const HOUR_MS = 3600000, DAY_MS = 86400000;
const PLAN_PAGE = "План КА — Mission Ops.html";
function MapApp() {
const byId = useMemoMA(() => { const m = {}; D.plans.forEach(p => m[p.id] = p); return m; }, []);
const scById = useMemoMA(() => { const m = {}; D.spacecraft.forEach(s => m[s.id] = s); return m; }, []);
const ivBySc = useMemoMA(() => { const m = {}; D.plans.forEach(p => (m[p.scId] = m[p.scId] || []).push(p)); return m; }, []);
const paramsBy = useMemoMA(() => { const m = {}; D.spacecraft.forEach(s => m[s.id] = orbitParams(s)); return m; }, []);
// начальное состояние из URL
const urlParams = new URLSearchParams(location.search);
const urlPlan = urlParams.get("plan");
const urlSc = urlParams.get("sc");
const initPlan = urlPlan && byId[urlPlan] ? urlPlan : null;
const initSc = initPlan ? byId[initPlan].scId : (urlSc && scById[urlSc] ? urlSc : D.spacecraft[0].id);
const [scope, setScope] = useStateMA(initPlan || urlSc ? "one" : "one");
const [selSc, setSelSc] = useStateMA(initSc);
const [selPlan, setSelPlan] = useStateMA(initPlan);
const [search, setSearch] = useStateMA("");
const [layers, setLayers] = useStateMA({ tracks: true, swath: true, shoot: true, downlink: true, stations: true, terminator: true });
const [basemap, setBasemap] = useStateMA("dark");
const sc = scById[selSc];
const plan = selPlan ? byId[selPlan] : null;
// окно времени
const win = useMemoMA(() => {
if (plan) return { t0: plan.start, t1: Math.min(plan.end, plan.start + 3 * DAY_MS) };
return { t0: D.NOW - 3 * HOUR_MS, t1: D.NOW + 9 * HOUR_MS };
}, [plan]);
// список КА в охвате
const scList = useMemoMA(() => {
if (scope === "one") return [sc];
if (scope === "group") return D.spacecraft.filter(s => s.groupId === sc.groupId);
return D.spacecraft;
}, [scope, sc]);
const toggleLayer = (k) => setLayers(l => ({ ...l, [k]: !l[k] }));
const onSelectSc = (id) => { setSelSc(id); setSelPlan(null); if (scope === "all") setScope("group"); };
const onSelectPlan = (id) => { const p = byId[id]; setSelSc(p.scId); setSelPlan(id); setScope("one"); };
// фильтр выбора КА
const q = search.trim().toLowerCase();
const scResults = useMemoMA(() => D.spacecraft.filter(s => !q || s.name.toLowerCase().includes(q) || s.short.toLowerCase().includes(q)), [q]);
return (
<div style={{ display: "flex", flexDirection: "column", height: "100vh" }}>
<TopTabs active="map" planHref={PLAN_PAGE} />
<div style={{ flex: 1, display: "grid", gridTemplateColumns: `288px 1fr ${plan ? "330px" : "0px"}`, minHeight: 0, transition: "grid-template-columns .18s" }}>
{/* ЛЕВЫЕ КОНТРОЛЫ */}
<div style={{ background: "var(--bg-1)", borderRight: "1px solid var(--line)", display: "flex", flexDirection: "column", minHeight: 0 }}>
{/* подложка */}
<div style={{ padding: "12px 14px", borderBottom: "1px solid var(--line-soft)" }}>
<div className="mono" style={{ fontSize: 9.5, color: "var(--ink-3)", letterSpacing: ".1em", marginBottom: 7 }}>ПОДЛОЖКА</div>
<div style={{ display: "flex", gap: 3, background: "var(--bg-0)", borderRadius: 7, padding: 3, border: "1px solid var(--line)" }}>
{[["dark", "Тёмная"], ["osm", "Светлая"], ["schema", "Схема"]].map(([id, lab]) => (
<button key={id} onClick={() => setBasemap(id)} style={{ flex: 1, padding: "6px 0", borderRadius: 5, border: "none", fontSize: 11.5, fontWeight: 600, background: basemap === id ? "var(--bg-3)" : "transparent", color: basemap === id ? "var(--ink)" : "var(--ink-3)" }}>{lab}</button>
))}
</div>
</div>
{/* охват */}
<div style={{ padding: "12px 14px", borderBottom: "1px solid var(--line-soft)" }}>
<div className="mono" style={{ fontSize: 9.5, color: "var(--ink-3)", letterSpacing: ".1em", marginBottom: 7 }}>ОХВАТ</div>
<div style={{ display: "flex", gap: 3, background: "var(--bg-0)", borderRadius: 7, padding: 3, border: "1px solid var(--line)" }}>
{[["one", "1 КА"], ["group", "Группа"], ["all", "Все"]].map(([id, lab]) => (
<button key={id} onClick={() => setScope(id)} style={{ flex: 1, padding: "6px 0", borderRadius: 5, border: "none", fontSize: 11.5, fontWeight: 600, background: scope === id ? "var(--bg-3)" : "transparent", color: scope === id ? "var(--ink)" : "var(--ink-3)" }}>{lab}</button>
))}
</div>
<div style={{ marginTop: 8, fontSize: 11, color: "var(--ink-2)", display: "flex", alignItems: "center", gap: 6 }}>
<TypeDot type={sc.type} size={8} />
<span className="mono">{sc.short}</span>
<span style={{ color: "var(--ink-3)" }}>· {D.GROUP_DEFS.find(g => g.id === sc.groupId).name}</span>
</div>
</div>
{/* окно времени */}
<div style={{ padding: "10px 14px", borderBottom: "1px solid var(--line-soft)" }}>
<div className="mono" style={{ fontSize: 9.5, color: "var(--ink-3)", letterSpacing: ".1em", marginBottom: 5 }}>ОКНО ВРЕМЕНИ</div>
<div className="mono" style={{ fontSize: 11, color: "var(--ink-1)" }}>{fmtDateTime(win.t0)} {fmtDateTime(win.t1)}</div>
<div style={{ fontSize: 10, color: "var(--ink-3)", marginTop: 3 }}>{plan ? `план ${plan.id}` : "сутки вокруг «сейчас»"}</div>
</div>
{/* слои */}
<div style={{ padding: "10px 14px", borderBottom: "1px solid var(--line-soft)" }}>
<div className="mono" style={{ fontSize: 9.5, color: "var(--ink-3)", letterSpacing: ".1em", marginBottom: 7 }}>СЛОИ</div>
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
<LayerRow on={layers.tracks} onClick={() => toggleLayer("tracks")} swatch={<LineSwatch />} label="Трассы КА" />
<LayerRow on={layers.swath} onClick={() => toggleLayer("swath")} swatch={<BandSwatch />} label="Полосы обзора (swath)" />
<LayerRow on={layers.shoot} onClick={() => toggleLayer("shoot")} swatch={<PolySwatch />} label="Контуры маршрутов съёмки" />
<LayerRow on={layers.downlink} onClick={() => toggleLayer("downlink")} swatch={<DownSwatch />} label="Участки сброса" />
<LayerRow on={layers.stations} onClick={() => toggleLayer("stations")} swatch={<StationSwatch />} label="Станции приёма (НКПОР)" />
<LayerRow on={layers.terminator} onClick={() => toggleLayer("terminator")} swatch={<NightSwatch />} label="Терминатор день/ночь" />
</div>
</div>
{/* выбор КА */}
<div style={{ padding: "10px 14px 6px" }}>
<div className="mono" style={{ fontSize: 9.5, color: "var(--ink-3)", letterSpacing: ".1em", marginBottom: 6 }}>АППАРАТ В ФОКУСЕ</div>
<div style={{ position: "relative" }}>
<input value={search} onChange={(e) => setSearch(e.target.value)} placeholder="Поиск КА…"
style={{ width: "100%", padding: "7px 8px", background: "var(--bg-0)", border: "1px solid var(--line)", borderRadius: 6, color: "var(--ink)", fontFamily: "var(--mono)", fontSize: 12, outline: "none" }} />
</div>
</div>
<div style={{ flex: 1, overflowY: "auto", padding: "4px 8px 12px" }}>
{scResults.map((s) => (
<div key={s.id} onClick={() => onSelectSc(s.id)}
style={{ display: "flex", alignItems: "center", gap: 8, padding: "5px 8px", borderRadius: 6, cursor: "pointer", background: s.id === selSc ? "var(--bg-3)" : "transparent" }}>
<TypeDot type={s.type} size={7} />
<span className="mono" style={{ flex: 1, fontSize: 11, color: s.id === selSc ? "var(--ink)" : "var(--ink-1)" }}>{s.short}</span>
<span className="mono" style={{ fontSize: 9.5, color: "var(--ink-3)" }}>{(ivBySc[s.id] || []).length} пл.</span>
</div>
))}
</div>
</div>
{/* КАРТА */}
<div style={{ position: "relative", minWidth: 0 }}>
<MapView scList={scList} selectedPlan={plan} window={win} now={D.NOW} layers={layers}
onSelectSc={onSelectSc} paramsBy={paramsBy} scById={scById} basemap={basemap} />
{/* плашка масштаба/охвата */}
<div style={{ position: "absolute", left: 12, top: 12, display: "flex", gap: 8, alignItems: "center", background: "var(--bg-1)", border: "1px solid var(--line)", borderRadius: 7, padding: "6px 10px" }}>
<span className="mono" style={{ fontSize: 10, color: "var(--ink-3)" }}>ОХВАТ:</span>
<span style={{ fontSize: 11.5, fontWeight: 600 }}>{scope === "one" ? "1 КА" : scope === "group" ? "Группа" : "Все"}</span>
<span style={{ width: 1, height: 14, background: "var(--line)" }} />
<span className="mono tnum" style={{ fontSize: 11.5, color: "var(--ink-1)" }}>{scList.length} КА</span>
</div>
</div>
{/* ДЕТАЛИ ПЛАНА */}
{plan && <MapDetails plan={plan} sc={scById[plan.scId]} params={paramsBy[plan.scId]} byId={byId} onSelectPlan={onSelectPlan} onClose={() => setSelPlan(null)} planPage={PLAN_PAGE} />}
</div>
</div>
);
}
function LayerRow({ on, onClick, swatch, label }) {
return (
<button onClick={onClick} style={{ display: "flex", alignItems: "center", gap: 8, padding: "5px 6px", borderRadius: 6, border: "1px solid transparent", background: on ? "var(--bg-2)" : "transparent", opacity: on ? 1 : 0.4, textAlign: "left" }}>
<span style={{ width: 18, flex: "none", display: "grid", placeItems: "center" }}>{swatch}</span>
<span style={{ flex: 1, fontSize: 11.5, color: "var(--ink-1)" }}>{label}</span>
<span style={{ width: 14, height: 14, borderRadius: 4, flex: "none", border: `1.5px solid ${on ? "var(--accent)" : "var(--line)"}`, background: on ? "var(--accent)" : "transparent", display: "grid", placeItems: "center" }}>
{on && <svg width="9" height="9" viewBox="0 0 10 10"><path d="M1.5 5.2 4 7.6 8.6 2.4" stroke="var(--bg-0)" strokeWidth="1.8" fill="none" strokeLinecap="round" strokeLinejoin="round" /></svg>}
</span>
</button>
);
}
const LineSwatch = () => <svg width="18" height="10"><path d="M0 8 Q6 0 12 5 T18 3" stroke="var(--t-optical)" strokeWidth="1.4" fill="none" /></svg>;
const BandSwatch = () => <svg width="18" height="10"><path d="M0 8 Q6 0 12 5 T18 3" stroke="var(--t-optical)" strokeWidth="5" fill="none" opacity="0.25" /></svg>;
const PolySwatch = () => <svg width="18" height="10"><rect x="2" y="2" width="14" height="6" fill="color-mix(in oklch, var(--t-optical) 30%, transparent)" stroke="var(--t-optical)" strokeWidth="1" /></svg>;
const DownSwatch = () => <svg width="18" height="10"><path d="M1 5 H17" stroke="var(--accent)" strokeWidth="3" strokeLinecap="round" /></svg>;
const StationSwatch = () => <svg width="18" height="10"><path d="M9 1 L13 8 L5 8 Z" fill="rgba(180,200,210,0.9)" /></svg>;
const NightSwatch = () => <svg width="18" height="10"><rect x="0" y="0" width="9" height="10" fill="rgba(4,8,12,0.6)" /><rect x="9" y="0" width="9" height="10" fill="#15291f" /></svg>;
ReactDOM.createRoot(document.getElementById("root")).render(<MapApp />);
@@ -0,0 +1,125 @@
// Панель деталей плана на странице «Карта»: сводка, наземные параметры,
// работы (со «съёмка/сброс»), ссылка обратно на таймлайн.
function MapStatusBadge({ status }) {
const map = {
executed: { t: "ВЫПОЛНЕН", c: "var(--ink-2)", bg: "var(--bg-3)" },
active: { t: "АКТИВЕН", c: "var(--now)", bg: "oklch(0.86 0.16 25 / 0.16)" },
planned: { t: "ЗАПЛАНИРОВАН", c: "var(--accent)", bg: "oklch(0.82 0.13 200 / 0.14)" },
};
const s = map[status] || map.planned;
return <span className="mono" style={{ fontSize: 9.5, fontWeight: 600, letterSpacing: ".06em", color: s.c, background: s.bg, padding: "2px 7px", borderRadius: 4 }}>{s.t}</span>;
}
function MapDetails({ plan, sc, params, byId, onSelectPlan, onClose, planPage }) {
const col = TYPE_COLOR[sc.type];
const shoots = plan.works.filter(w => w.kind === "shoot");
const downs = plan.works.filter(w => w.kind === "downlink");
const STATIONS = window.WorldGeo.GROUND_STATIONS;
// станции, задействованные в сбросах
const usedStations = [];
for (const d of downs) {
const pts = trackPoints(params, d.start, d.end, 10);
const mid = pts[Math.floor(pts.length / 2)];
const ns = nearestStation(mid.lat, mid.lon, STATIONS);
if (ns.st && !usedStations.find(x => x.id === ns.st.id)) usedStations.push(ns.st);
}
return (
<div style={{ background: "var(--bg-1)", borderLeft: "1px solid var(--line)", display: "flex", flexDirection: "column", minHeight: 0 }}>
<div style={{ padding: "14px 16px 12px", borderBottom: "1px solid var(--line-soft)" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 8 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8, minWidth: 0 }}>
<span style={{ width: 10, height: 10, borderRadius: 3, background: col, boxShadow: `0 0 8px ${col}`, flex: "none" }} />
<span className="mono" style={{ fontSize: 10, color: "var(--ink-3)" }}>{plan.id}</span>
</div>
<button onClick={onClose} style={{ background: "none", border: "none", color: "var(--ink-3)", fontSize: 18, lineHeight: 1, padding: 0 }}>×</button>
</div>
<div style={{ fontSize: 16, fontWeight: 700, marginTop: 8, lineHeight: 1.2 }}>{plan.title}</div>
<div style={{ display: "flex", alignItems: "center", gap: 8, marginTop: 9 }}>
<MapStatusBadge status={plan.status} />
<span className="mono" style={{ fontSize: 10.5, color: "var(--ink-2)" }}>{sc.short} · {sc.orbit}</span>
</div>
</div>
<div style={{ flex: 1, overflowY: "auto", padding: "14px 16px 20px", display: "flex", flexDirection: "column", gap: 15 }}>
{/* наземные параметры */}
<div>
<div className="mono" style={{ fontSize: 9, color: "var(--ink-3)", letterSpacing: ".08em", marginBottom: 8 }}>НАЗЕМНЫЕ ПАРАМЕТРЫ</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
<KV label="ВЫСОТА ОРБИТЫ">{params.h} км</KV>
<KV label="ПЕРИОД ВИТКА">{(params.T / 60).toFixed(1)} мин</KV>
<KV label="ПОЛОСА ОБЗОРА">{Math.round(params.swathKm)} км</KV>
<KV label="НАКЛОНЕНИЕ">{(params.incl * RAD).toFixed(1)}°</KV>
</div>
</div>
<div style={{ height: 1, background: "var(--line-soft)" }} />
{/* съёмка */}
<div>
<div style={{ display: "flex", alignItems: "center", gap: 7, marginBottom: 8 }}>
<span style={{ width: 10, height: 8, background: col, borderRadius: 2, flex: "none" }} />
<span className="mono" style={{ fontSize: 9, color: "var(--ink-3)", letterSpacing: ".08em" }}>КОНТУРЫ СЪЁМКИ</span>
<span className="mono" style={{ fontSize: 9, color: "var(--ink-3)", marginLeft: "auto" }}>{shoots.length}</span>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 3 }}>
{shoots.length === 0 && <div style={{ fontSize: 11, color: "var(--ink-3)" }}>Съёмочных маршрутов нет.</div>}
{shoots.map(w => (
<div key={w.id} style={{ display: "flex", alignItems: "center", gap: 8, padding: "5px 8px", borderRadius: 6, background: "var(--bg-2)" }}>
<span style={{ flex: 1, fontSize: 11.5, color: "var(--ink-1)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{w.label}</span>
<span className="mono tnum" style={{ fontSize: 10, color: "var(--ink-3)" }}>{fmtTime(w.start)}{fmtTime(w.end)}</span>
</div>
))}
</div>
</div>
{/* сброс */}
<div>
<div style={{ display: "flex", alignItems: "center", gap: 7, marginBottom: 8 }}>
<span style={{ width: 12, height: 3, background: "var(--accent)", borderRadius: 2, flex: "none", boxShadow: "0 0 5px var(--accent)" }} />
<span className="mono" style={{ fontSize: 9, color: "var(--ink-3)", letterSpacing: ".08em" }}>УЧАСТКИ СБРОСА</span>
<span className="mono" style={{ fontSize: 9, color: "var(--ink-3)", marginLeft: "auto" }}>{downs.length}</span>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 3 }}>
{downs.length === 0 && <div style={{ fontSize: 11, color: "var(--ink-3)" }}>Сбросов нет.</div>}
{downs.map(w => {
const pts = trackPoints(params, w.start, w.end, 10);
const mid = pts[Math.floor(pts.length / 2)];
const ns = nearestStation(mid.lat, mid.lon, STATIONS);
return (
<div key={w.id} style={{ display: "flex", alignItems: "center", gap: 8, padding: "5px 8px", borderRadius: 6, background: "var(--bg-2)" }}>
<span style={{ flex: 1, fontSize: 11.5, color: "var(--ink-1)" }}> {ns.st.name}</span>
<span className="mono tnum" style={{ fontSize: 10, color: "var(--ink-3)" }}>{fmtTime(w.start)}{fmtTime(w.end)}</span>
</div>
);
})}
</div>
</div>
{usedStations.length > 0 && (
<div style={{ fontSize: 10.5, color: "var(--ink-3)", lineHeight: 1.5 }}>
Задействованы станции: <span style={{ color: "var(--ink-1)" }}>{usedStations.map(s => s.name).join(", ")}</span>
</div>
)}
</div>
<div style={{ padding: "12px 16px", borderTop: "1px solid var(--line-soft)" }}>
<a href={`${planPage}?plan=${plan.id}`} style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 7, padding: "9px 0", borderRadius: 7, border: "1px solid var(--line)", background: "var(--bg-2)", color: "var(--ink-1)", fontSize: 12, fontWeight: 600, textDecoration: "none" }}>
Открыть в таймлайне планов
</a>
</div>
</div>
);
}
function KV({ label, children }) {
return (
<div style={{ display: "flex", flexDirection: "column", gap: 3 }}>
<span className="mono" style={{ fontSize: 9, color: "var(--ink-3)", letterSpacing: ".06em" }}>{label}</span>
<span className="mono tnum" style={{ fontSize: 12.5, color: "var(--ink)" }}>{children}</span>
</div>
);
}
Object.assign(window, { MapDetails });
@@ -0,0 +1,300 @@
// Карта: Web Mercator. Canvas-основа (тайлы OSM/CARTO или векторная схема,
// сетка, трассы, swath, терминатор) + SVG-overlay (станции, «сейчас»,
// контуры съёмки, сброс, hover). Зум/панорама как у slippy-карты.
const { useRef: useRefMV, useState: useStateMV, useEffect: useEffectMV, useMemo: useMemoMV, useCallback } = React;
const STATIONS = window.WorldGeo.GROUND_STATIONS;
const BM = window.Basemap;
const TILE = BM.TILE;
function MapView(props) {
const { scList, selectedPlan, window: win, now, layers, onSelectSc, paramsBy, scById, basemap } = props;
const wrapRef = useRefMV(null);
const canvasRef = useRefMV(null);
const [dims, setDims] = useStateMV({ w: 1000, h: 560 });
const [view, setView] = useStateMV({ centerLon: 45, centerLat: 30, z: 2.2, init: false });
const [hover, setHover] = useStateMV(null);
const [tileTick, setTileTick] = useStateMV(0);
const drag = useRefMV(null);
const provider = BM.PROVIDERS[basemap] || BM.PROVIDERS.dark;
const ws = useMemoMV(() => TILE * Math.pow(2, view.z), [view.z]);
// Инициализация: вписать мир по ширине.
useEffectMV(() => {
if (view.init || !dims.w) return;
const z = Math.max(0.7, Math.log2(dims.w / TILE) - 0.05);
setView((v) => ({ ...v, z, centerLon: 45, centerLat: 28, init: true }));
}, [dims, view.init]);
const project = useCallback((lon, lat) => {
const cwx = BM.lonToWorldX(view.centerLon, ws);
const cwy = BM.latToWorldY(view.centerLat, ws);
let wx = BM.lonToWorldX(lon, ws);
// выбрать ближайшую копию мира по X (горизонтальный повтор)
const half = ws / 2;
while (wx - cwx > half) wx -= ws;
while (wx - cwx < -half) wx += ws;
return {
x: dims.w / 2 + (wx - cwx),
y: dims.h / 2 + (BM.latToWorldY(lat, ws) - cwy),
};
}, [view.centerLon, view.centerLat, ws, dims]);
const unproject = useCallback((sx, sy) => {
const cwx = BM.lonToWorldX(view.centerLon, ws);
const cwy = BM.latToWorldY(view.centerLat, ws);
return {
lon: BM.worldXToLon(cwx + (sx - dims.w / 2), ws),
lat: BM.worldYToLat(cwy + (sy - dims.h / 2), ws),
};
}, [view.centerLon, view.centerLat, ws, dims]);
// ResizeObserver
useEffectMV(() => {
const el = wrapRef.current;
if (!el) return;
const ro = new ResizeObserver(() => setDims({ w: el.clientWidth, h: el.clientHeight }));
ro.observe(el);
setDims({ w: el.clientWidth, h: el.clientHeight });
return () => ro.disconnect();
}, []);
// Отрисовка canvas.
useEffectMV(() => {
const cv = canvasRef.current;
if (!cv) return;
const dpr = Math.min(2, window.devicePixelRatio || 1);
cv.width = dims.w * dpr; cv.height = dims.h * dpr;
cv.style.width = dims.w + "px"; cv.style.height = dims.h + "px";
const ctx = cv.getContext("2d");
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, dims.w, dims.h);
// фон
ctx.fillStyle = provider.dark ? "#0a1410" : "#0a1410";
ctx.fillRect(0, 0, dims.w, dims.h);
const P = (lon, lat) => project(lon, lat);
if (provider.url) {
// растровая подложка (тайлы)
BM.drawTiles(ctx, provider, view, dims, () => setTileTick((t) => t + 1));
} else {
// векторная схема (оффлайн)
drawVectorWorld(ctx, P, dims);
}
// сетка координат поверх (тонкая)
ctx.strokeStyle = provider.dark ? "rgba(120,180,150,0.10)" : "rgba(255,255,255,0.14)";
ctx.lineWidth = 1;
ctx.beginPath();
for (let lon = -180; lon <= 180; lon += 30) { const a = P(lon, 84), b = P(lon, -84); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); }
for (let lat = -60; lat <= 60; lat += 30) { const a = P(-180, lat), b = P(180, lat); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); }
ctx.stroke();
// терминатор / ночь
if (layers.terminator) {
const tc = terminatorCurve(now);
ctx.beginPath();
tc.curve.forEach((pt, i) => { const q = P(pt[0], pt[1]); i ? ctx.lineTo(q.x, q.y) : ctx.moveTo(q.x, q.y); });
const topLat = tc.nightInNorth ? 84 : -84;
const c1 = P(180, topLat), c2 = P(-180, topLat);
ctx.lineTo(c1.x, c1.y); ctx.lineTo(c2.x, c2.y);
ctx.closePath();
ctx.fillStyle = "rgba(4,8,12,0.42)";
ctx.fill();
ctx.strokeStyle = "rgba(255,210,120,0.30)";
ctx.lineWidth = 1;
ctx.beginPath();
tc.curve.forEach((pt, i) => { const q = P(pt[0], pt[1]); i ? ctx.lineTo(q.x, q.y) : ctx.moveTo(q.x, q.y); });
ctx.stroke();
}
// трассы + swath
const TYPE_RGB = { optical: [126,222,180], radar: [232,200,120], combo: [240,150,165] };
const many = scList.length;
const showSwath = layers.swath && many <= 8;
const trackAlpha = many > 40 ? 0.14 : many > 14 ? 0.22 : many > 4 ? 0.4 : 0.72;
for (const sc of scList) {
const p = paramsBy[sc.id];
const rgb = TYPE_RGB[sc.type];
const segs = groundTrack(p, win.t0, win.t1, 900);
const isSelSc = selectedPlan && selectedPlan.scId === sc.id;
if (showSwath || isSelSc) {
for (const seg of segs) {
if (!seg.length) continue;
const midLat = seg[Math.floor(seg.length / 2)].lat;
const mpp = BM.metersPerPixel(midLat, ws);
const wpx = Math.max(1.5, (p.swathKm * 1000) / mpp);
ctx.strokeStyle = `rgba(${rgb[0]},${rgb[1]},${rgb[2]},${isSelSc ? 0.16 : 0.09})`;
ctx.lineWidth = wpx; ctx.lineCap = "round"; ctx.lineJoin = "round";
ctx.beginPath();
seg.forEach((pt, i) => { const q = P(pt.lon, pt.lat); i ? ctx.lineTo(q.x, q.y) : ctx.moveTo(q.x, q.y); });
ctx.stroke();
}
}
if (layers.tracks) {
ctx.strokeStyle = `rgba(${rgb[0]},${rgb[1]},${rgb[2]},${isSelSc ? 0.98 : trackAlpha})`;
ctx.lineWidth = isSelSc ? 2 : many > 14 ? 0.6 : 0.9;
for (const seg of segs) {
ctx.beginPath();
seg.forEach((pt, i) => { const q = P(pt.lon, pt.lat); i ? ctx.lineTo(q.x, q.y) : ctx.moveTo(q.x, q.y); });
ctx.stroke();
}
}
}
}, [dims, view, scList, selectedPlan, win, now, layers, project, ws, provider, tileTick]);
// ---- overlay (SVG) ----
const planOverlay = useMemoMV(() => {
if (!selectedPlan) return null;
const p = paramsBy[selectedPlan.scId];
const sc = scById[selectedPlan.scId];
const shoots = [], downs = [];
for (const wk of selectedPlan.works) {
if (wk.kind === "shoot" && layers.shoot) {
const pts = trackPoints(p, wk.start, wk.end, 18);
const poly = ribbon(pts, p.swathKm / 2);
shoots.push({ id: wk.id, label: wk.label, poly: poly.map(c => project(c[0], c[1])) });
}
if (wk.kind === "downlink" && layers.downlink) {
const pts = trackPoints(p, wk.start, wk.end, 14);
const mid = pts[Math.floor(pts.length / 2)];
const ns = nearestStation(mid.lat, mid.lon, STATIONS);
downs.push({
id: wk.id, label: wk.label,
line: pts.map(pt => project(pt.lon, pt.lat)),
st: ns.st, stPt: project(ns.st.lon, ns.st.lat), midPt: project(mid.lon, mid.lat),
});
}
}
return { shoots, downs, scType: sc.type };
}, [selectedPlan, layers, project, paramsBy, scById]);
const nowMarkers = useMemoMV(() => {
if (now < win.t0 || now > win.t1) return [];
return scList.map((sc) => {
const s = subPoint(paramsBy[sc.id], now);
return { id: sc.id, short: sc.short, type: sc.type, ...project(s.lon, s.lat) };
});
}, [scList, now, win, project, paramsBy]);
const TYPE_HEX = { optical: "var(--t-optical)", radar: "var(--t-radar)", combo: "var(--t-combo)" };
// ---- pan / zoom ----
const onWheel = (e) => {
e.preventDefault();
const rect = wrapRef.current.getBoundingClientRect();
const mx = e.clientX - rect.left, my = e.clientY - rect.top;
const before = unproject(mx, my);
setView((v) => {
const nz = Math.max(0.7, Math.min(9, v.z + (e.deltaY < 0 ? 0.4 : -0.4)));
const nws = TILE * Math.pow(2, nz);
const cwx = BM.lonToWorldX(before.lon, nws) - (mx - dims.w / 2);
const cwy = BM.latToWorldY(before.lat, nws) - (my - dims.h / 2);
return { ...v, z: nz, centerLon: BM.worldXToLon(cwx, nws), centerLat: BM.worldYToLat(cwy, nws) };
});
};
const onDown = (e) => {
drag.current = { x: e.clientX, y: e.clientY, lon: view.centerLon, lat: view.centerLat, moved: false };
};
const onMove = (e) => {
if (!drag.current) return;
const dx = e.clientX - drag.current.x, dy = e.clientY - drag.current.y;
if (Math.abs(dx) + Math.abs(dy) > 3) drag.current.moved = true;
const cwx = BM.lonToWorldX(drag.current.lon, ws) - dx;
const cwy = BM.latToWorldY(drag.current.lat, ws) - dy;
setView((v) => ({ ...v, centerLon: BM.worldXToLon(cwx, ws), centerLat: Math.max(-82, Math.min(82, BM.worldYToLat(cwy, ws))) }));
};
const onUp = () => { drag.current = null; };
const zoomBy = (f) => setView((v) => ({ ...v, z: Math.max(0.7, Math.min(9, v.z + f)) }));
const resetView = () => setView((v) => ({ ...v, init: false }));
return (
<div ref={wrapRef} style={{ position: "absolute", inset: 0, overflow: "hidden", cursor: drag.current ? "grabbing" : "grab", background: "#0a1410" }}
onWheel={onWheel} onMouseDown={onDown} onMouseMove={onMove} onMouseUp={onUp} onMouseLeave={onUp}>
<canvas ref={canvasRef} style={{ position: "absolute", inset: 0 }} />
<svg style={{ position: "absolute", inset: 0, width: "100%", height: "100%", pointerEvents: "none", overflow: "visible" }}>
{planOverlay && planOverlay.shoots.map((s) => (
<polygon key={s.id} points={s.poly.map(p => `${p.x},${p.y}`).join(" ")}
fill={`color-mix(in oklch, ${TYPE_HEX[planOverlay.scType]} 32%, transparent)`}
stroke={TYPE_HEX[planOverlay.scType]} strokeWidth="1.4" style={{ pointerEvents: "all", cursor: "pointer" }}
onMouseEnter={(e) => setHover({ x: e.clientX, y: e.clientY, title: s.label, kind: "Съёмка / контур маршрута" })}
onMouseLeave={() => setHover(null)} />
))}
{planOverlay && planOverlay.downs.map((d) => (
<g key={d.id}>
<line x1={d.midPt.x} y1={d.midPt.y} x2={d.stPt.x} y2={d.stPt.y} stroke="var(--accent)" strokeWidth="1" strokeDasharray="2 3" opacity="0.6" />
<polyline points={d.line.map(p => `${p.x},${p.y}`).join(" ")} fill="none"
stroke="var(--accent)" strokeWidth="3.4" strokeLinecap="round"
style={{ pointerEvents: "all", cursor: "pointer", filter: "drop-shadow(0 0 4px var(--accent))" }}
onMouseEnter={(e) => setHover({ x: e.clientX, y: e.clientY, title: `${d.label}${d.st.name}`, kind: "Сброс на НКПОР" })}
onMouseLeave={() => setHover(null)} />
</g>
))}
{layers.stations && STATIONS.map((st) => {
const q = project(st.lon, st.lat);
const active = planOverlay && planOverlay.downs.some(d => d.st.id === st.id);
return (
<g key={st.id} transform={`translate(${q.x},${q.y})`} style={{ pointerEvents: "all", cursor: "default" }}
onMouseEnter={(e) => setHover({ x: e.clientX, y: e.clientY, title: st.name, kind: "Станция приёма (НКПОР)" })}
onMouseLeave={() => setHover(null)}>
<path d="M0,-6 L5,4 L-5,4 Z" fill={active ? "var(--accent)" : "rgba(190,210,220,0.92)"} stroke="#0a1410" strokeWidth="1" />
{view.z > 3.6 && <text x="7" y="3" fontSize="9" fill={provider.dark ? "var(--ink-2)" : "#dfeaf0"} style={{ fontFamily: "var(--mono)", paintOrder: "stroke", stroke: "#0a1410", strokeWidth: 2 }}>{st.name}</text>}
</g>
);
})}
{nowMarkers.map((m) => (
<g key={m.id} transform={`translate(${m.x},${m.y})`} style={{ pointerEvents: "all", cursor: "pointer" }}
onClick={() => onSelectSc(m.id)}
onMouseEnter={(e) => setHover({ x: e.clientX, y: e.clientY, title: m.short, kind: "Подспутниковая точка · сейчас" })}
onMouseLeave={() => setHover(null)}>
<circle r="4.5" fill={TYPE_HEX[m.type]} stroke="#0a1410" strokeWidth="1.5" />
<circle r="8" fill="none" stroke={TYPE_HEX[m.type]} strokeWidth="1" opacity="0.5" />
</g>
))}
</svg>
{hover && (
<div style={{ position: "fixed", left: hover.x + 12, top: hover.y + 12, zIndex: 50, pointerEvents: "none",
background: "var(--bg-0)", border: "1px solid var(--line)", borderRadius: 6, padding: "6px 9px", boxShadow: "var(--shadow)", maxWidth: 220 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: "var(--ink)" }}>{hover.title}</div>
<div className="mono" style={{ fontSize: 9.5, color: "var(--ink-3)", marginTop: 2 }}>{hover.kind}</div>
</div>
)}
{/* атрибуция подложки */}
<div className="mono" style={{ position: "absolute", left: 8, bottom: 6, fontSize: 9, color: provider.dark ? "var(--ink-3)" : "rgba(255,255,255,0.8)", background: "rgba(10,20,16,0.55)", padding: "2px 6px", borderRadius: 4, pointerEvents: "none" }}>{provider.label}</div>
<div style={{ position: "absolute", right: 12, bottom: 12, display: "flex", flexDirection: "column", gap: 4 }}>
<ZoomBtn onClick={() => zoomBy(0.5)}>+</ZoomBtn>
<ZoomBtn onClick={() => zoomBy(-0.5)}></ZoomBtn>
<ZoomBtn onClick={resetView} title="Сбросить вид"></ZoomBtn>
</div>
</div>
);
}
// Векторная схема континентов (режим «Схема», оффлайн).
function drawVectorWorld(ctx, P, dims) {
for (const name in window.WorldGeo.LAND) {
const poly = window.WorldGeo.LAND[name];
ctx.beginPath();
poly.forEach((pt, i) => { const q = P(pt[0], pt[1]); i ? ctx.lineTo(q.x, q.y) : ctx.moveTo(q.x, q.y); });
ctx.closePath();
ctx.fillStyle = "#173228"; ctx.fill();
ctx.strokeStyle = "rgba(150,215,180,0.5)"; ctx.lineWidth = 0.9; ctx.stroke();
}
}
function ZoomBtn({ children, onClick, title }) {
return (
<button onClick={onClick} title={title} style={{ width: 30, height: 30, borderRadius: 6, border: "1px solid var(--line)", background: "var(--bg-1)", color: "var(--ink-1)", fontSize: 16, lineHeight: 1, display: "grid", placeItems: "center" }}>{children}</button>
);
}
Object.assign(window, { MapView });
@@ -0,0 +1,142 @@
// Орбитальная геометрия: трассы КА (подспутниковая точка), полосы обзора (swath),
// контуры маршрутов съёмки, участки сброса, терминатор день/ночь.
const DEG = Math.PI / 180;
const RAD = 180 / Math.PI;
const RE_KM = 6371;
const MU = 398600.4418; // км^3/с^2
const SIDEREAL = 86164; // период вращения Земли, с
// Детерминированный хэш строки → [0,1).
function hash01(str, salt) {
let h = 2166136261 ^ (salt || 0);
for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 16777619); }
return ((h >>> 0) % 100000) / 100000;
}
// Параметры орбиты КА (ССО). Высоту берём из sc.orbit, наклонение ~98°.
function orbitParams(sc) {
const m = /(\d+)\s*км/.exec(sc.orbit);
const h = m ? +m[1] : 550;
const a = RE_KM + h;
const T = 2 * Math.PI * Math.sqrt((a * a * a) / MU); // период, с
const incl = (97.4 + hash01(sc.id, 7) * 1.6) * DEG; // 97.499.0°
const node0 = hash01(sc.id, 3) * 360 * DEG; // долгота восх. узла @ эпоха
const u0 = hash01(sc.id, 11) * 2 * Math.PI; // фаза
// Ширина полосы обзора (км) по типу аппаратуры.
const swathKm = sc.type === "radar" ? 90 + hash01(sc.id, 5) * 110
: sc.type === "combo" ? 50 + hash01(sc.id, 5) * 70
: 24 + hash01(sc.id, 5) * 36;
return { h, a, T, incl, node0, u0, swathKm };
}
const EPOCH = (window.MissionData ? window.MissionData.RANGE_START : 0);
// Подспутниковая точка в момент t (мс).
function subPoint(p, tMs) {
const dt = (tMs - EPOCH) / 1000; // с
const u = p.u0 + 2 * Math.PI * (dt / p.T);
const lat = Math.asin(Math.sin(p.incl) * Math.sin(u)) * RAD;
let lonRel = Math.atan2(Math.cos(p.incl) * Math.sin(u), Math.cos(u));
let lon = (p.node0 + lonRel) * RAD - (360 / SIDEREAL) * dt;
lon = ((((lon + 180) % 360) + 360) % 360) - 180;
// курс (для перпендикуляра swath) — численно
return { lat, lon, u };
}
// Трасса за [t0,t1] с адаптивным шагом. Возвращает сегменты (разрыв на ±180°).
function groundTrack(p, t0, t1, maxPts) {
const span = t1 - t0;
const idealStep = p.T * 1000 / 36; // ~36 точек на виток
let step = idealStep;
const cap = maxPts || 900;
if (span / step > cap) step = span / cap;
const segs = [];
let cur = [];
let prevLon = null;
for (let t = t0; t <= t1; t += step) {
const s = subPoint(p, t);
if (prevLon !== null && Math.abs(s.lon - prevLon) > 180) {
if (cur.length) segs.push(cur);
cur = [];
}
cur.push({ t, lat: s.lat, lon: s.lon });
prevLon = s.lon;
}
if (cur.length) segs.push(cur);
return segs;
}
// Точки трассы строго внутри [t0,t1] (для footprint работы), без разрыва.
function trackPoints(p, t0, t1, n) {
const pts = [];
const k = Math.max(2, n || 24);
for (let i = 0; i <= k; i++) {
const t = t0 + (t1 - t0) * (i / k);
const s = subPoint(p, t);
pts.push({ t, lat: s.lat, lon: s.lon });
}
return pts;
}
// Лента (ribbon) шириной halfKm по обе стороны трассы → замкнутый полигон [lon,lat].
function ribbon(points, halfKm) {
const half = halfKm / 111; // градусы широты
const left = [], right = [];
for (let i = 0; i < points.length; i++) {
const a = points[Math.max(0, i - 1)];
const b = points[Math.min(points.length - 1, i + 1)];
const cosLat = Math.cos(points[i].lat * DEG) || 0.01;
let dx = (b.lon - a.lon) * cosLat;
let dy = (b.lat - a.lat);
const len = Math.hypot(dx, dy) || 1;
dx /= len; dy /= len;
// перпендикуляр
const px = -dy, py = dx;
const p = points[i];
left.push([p.lon + (px * half) / cosLat, p.lat + py * half]);
right.push([p.lon - (px * half) / cosLat, p.lat - py * half]);
}
return left.concat(right.reverse());
}
// Видимость станции из подспутниковой точки (грубо: по угловому расстоянию).
function nearestStation(lat, lon, stations) {
let best = null, bd = 1e9;
for (const st of stations) {
const d = Math.hypot((st.lon - lon) * Math.cos(lat * DEG), st.lat - lat);
if (d < bd) { bd = d; best = st; }
}
return { st: best, distDeg: bd };
}
// Подсолнечная точка (примитивно) для терминатора в момент tMs.
function subsolar(tMs) {
const d = new Date(tMs);
const dayMs = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate());
const secs = (tMs - dayMs) / 1000;
const lon = 180 - (secs / 86400) * 360; // движется на запад
const N = Math.floor((tMs - Date.UTC(d.getUTCFullYear(), 0, 0)) / 86400000);
const decl = 23.44 * Math.sin(DEG * (360 / 365) * (N - 81)); // склонение
return { lon: ((lon + 180) % 360 + 360) % 360 - 180, lat: decl };
}
// Кривая терминатора: для каждого lon — широта раздела день/ночь.
function terminatorCurve(tMs) {
const ss = subsolar(tMs);
const decl = ss.lat * DEG;
const out = [];
for (let lon = -180; lon <= 180; lon += 4) {
const H = (lon - ss.lon) * DEG;
// граница: tan(lat) = -cos(H)/tan(decl)
let lat;
if (Math.abs(decl) < 0.01) lat = 0;
else lat = Math.atan(-Math.cos(H) / Math.tan(decl)) * RAD;
out.push([lon, lat]);
}
return { curve: out, nightInNorth: ss.lat < 0, subsolar: ss };
}
Object.assign(window, {
orbitParams, subPoint, groundTrack, trackPoints, ribbon,
nearestStation, subsolar, terminatorCurve, DEG, RAD,
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

@@ -0,0 +1,122 @@
// Левая панель: поиск, фильтр по типам (легенда), дерево групп → КА с чекбоксами.
const { useState: useStateSB } = React;
function TypeDot({ type, size = 9 }) {
return (
<span style={{
width: size, height: size, borderRadius: 2, flex: "none",
background: TYPE_COLOR[type], display: "inline-block",
boxShadow: `0 0 6px ${TYPE_COLOR[type]}`,
}} />
);
}
function Check({ on, onClick, dim }) {
return (
<button onClick={onClick} aria-label="toggle" style={{
width: 16, height: 16, flex: "none", borderRadius: 4,
border: `1.5px solid ${on ? "var(--accent)" : "var(--line)"}`,
background: on ? "var(--accent)" : "transparent",
opacity: dim ? 0.4 : 1,
display: "grid", placeItems: "center", padding: 0, transition: "all .12s",
}}>
{on && (
<svg width="10" height="10" viewBox="0 0 10 10"><path d="M1.5 5.2 4 7.6 8.6 2.4" stroke="var(--bg-0)" strokeWidth="1.8" fill="none" strokeLinecap="round" strokeLinejoin="round" /></svg>
)}
</button>
);
}
function Sidebar(props) {
const { groups, scByGroup, typeFilter, setTypeFilter, groupShown, toggleGroup,
scHidden, toggleSc, search, setSearch, counts, selectedSc, onPickSc } = props;
const [open, setOpen] = useStateSB(() => {
const o = {}; D.GROUP_DEFS.forEach((g) => (o[g.id] = true)); return o;
});
return (
<div style={{ display: "flex", flexDirection: "column", height: "100%", background: "var(--bg-1)", borderRight: "1px solid var(--line)" }}>
{/* Заголовок */}
<div style={{ padding: "14px 14px 10px", borderBottom: "1px solid var(--line-soft)" }}>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<div style={{ width: 22, height: 22, borderRadius: 5, background: "var(--accent)", display: "grid", placeItems: "center", boxShadow: "0 0 12px var(--accent)" }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="3" fill="var(--bg-0)" /><ellipse cx="12" cy="12" rx="10" ry="4.3" stroke="var(--bg-0)" strokeWidth="1.7" transform="rotate(28 12 12)" /></svg>
</div>
<div>
<div style={{ fontWeight: 700, letterSpacing: ".02em", fontSize: 13 }}>ПЛАН КА</div>
<div className="mono" style={{ fontSize: 9.5, color: "var(--ink-3)", letterSpacing: ".08em" }}>MISSION OPS · ДЗЗ</div>
</div>
</div>
</div>
{/* Поиск */}
<div style={{ padding: "10px 12px 8px" }}>
<div style={{ position: "relative" }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" style={{ position: "absolute", left: 9, top: 9, opacity: .5 }}><circle cx="11" cy="11" r="7" stroke="var(--ink-2)" strokeWidth="2" /><path d="m20 20-3.5-3.5" stroke="var(--ink-2)" strokeWidth="2" strokeLinecap="round" /></svg>
<input value={search} onChange={(e) => setSearch(e.target.value)} placeholder="Поиск КА…"
style={{ width: "100%", padding: "7px 8px 7px 28px", background: "var(--bg-0)", border: "1px solid var(--line)", borderRadius: 6, color: "var(--ink)", fontFamily: "var(--mono)", fontSize: 12, outline: "none" }} />
{search && <button onClick={() => setSearch("")} style={{ position: "absolute", right: 6, top: 6, background: "none", border: "none", color: "var(--ink-3)", fontSize: 14, padding: 2 }}>×</button>}
</div>
</div>
{/* Легенда / фильтр по типам */}
<div style={{ padding: "4px 12px 10px", borderBottom: "1px solid var(--line-soft)" }}>
<div className="mono" style={{ fontSize: 9.5, color: "var(--ink-3)", letterSpacing: ".1em", marginBottom: 6 }}>АППАРАТУРА</div>
<div style={{ display: "flex", flexDirection: "column", gap: 3 }}>
{D.typeOrder.map((t) => {
const on = typeFilter[t];
return (
<button key={t} onClick={() => setTypeFilter({ ...typeFilter, [t]: !on })}
style={{ display: "flex", alignItems: "center", gap: 8, padding: "5px 7px", borderRadius: 6, border: "1px solid transparent", background: on ? "var(--bg-2)" : "transparent", opacity: on ? 1 : 0.45, textAlign: "left" }}>
<TypeDot type={t} />
<span style={{ flex: 1, fontSize: 12 }}>{D.TYPES[t].label}</span>
<span className="mono tnum" style={{ fontSize: 10.5, color: "var(--ink-3)" }}>{counts.byType[t] || 0}</span>
</button>
);
})}
</div>
</div>
{/* Дерево групп → КА */}
<div style={{ flex: 1, overflowY: "auto", padding: "8px 8px 16px" }}>
<div className="mono" style={{ fontSize: 9.5, color: "var(--ink-3)", letterSpacing: ".1em", margin: "2px 6px 6px" }}>ГРУППЫ / КА</div>
{groups.map((g) => {
const list = scByGroup[g.id] || [];
const isOpen = open[g.id];
const shownCount = list.filter((sc) => !scHidden.has(sc.id)).length;
return (
<div key={g.id} style={{ marginBottom: 2 }}>
<div style={{ display: "flex", alignItems: "center", gap: 6, padding: "5px 6px", borderRadius: 6, background: "var(--bg-2)" }}>
<button onClick={() => setOpen({ ...open, [g.id]: !isOpen })} style={{ background: "none", border: "none", padding: 0, width: 14, height: 14, display: "grid", placeItems: "center", color: "var(--ink-2)" }}>
<svg width="9" height="9" viewBox="0 0 10 10" style={{ transform: isOpen ? "rotate(90deg)" : "none", transition: "transform .12s" }}><path d="M3 1.5 7 5 3 8.5" stroke="currentColor" strokeWidth="1.6" fill="none" strokeLinecap="round" strokeLinejoin="round" /></svg>
</button>
<Check on={groupShown[g.id]} onClick={() => toggleGroup(g.id)} />
<span className="mono" style={{ fontSize: 10, color: "var(--ink-3)" }}>{g.code}</span>
<span style={{ flex: 1, fontSize: 12, fontWeight: 600, color: groupShown[g.id] ? "var(--ink)" : "var(--ink-3)" }}>{g.name}</span>
<span className="mono tnum" style={{ fontSize: 10, color: "var(--ink-3)" }}>{shownCount}/{list.length}</span>
</div>
{isOpen && (
<div style={{ marginLeft: 14, borderLeft: "1px solid var(--line-soft)", paddingLeft: 4, marginTop: 2 }}>
{list.map((sc) => {
const vis = !scHidden.has(sc.id);
const sel = selectedSc === sc.id;
return (
<div key={sc.id} onClick={() => onPickSc(sc.id)}
style={{ display: "flex", alignItems: "center", gap: 7, padding: "4px 7px", borderRadius: 6, cursor: "pointer", background: sel ? "var(--bg-3)" : "transparent" }}>
<Check on={vis} dim={!groupShown[g.id]} onClick={(e) => { e.stopPropagation(); toggleSc(sc.id); }} />
<TypeDot type={sc.type} size={7} />
<span className="mono" style={{ flex: 1, fontSize: 11, color: vis && groupShown[g.id] ? "var(--ink-1)" : "var(--ink-3)" }}>{sc.short}</span>
</div>
);
})}
</div>
)}
</div>
);
})}
</div>
</div>
);
}
Object.assign(window, { Sidebar, TypeDot });
@@ -0,0 +1,45 @@
// Верхние вкладки навигации «Планы / Карта», общие для обеих страниц.
function TopTabs({ active, planHref, mapHref }) {
const PH = planHref || "План КА — Mission Ops.html";
const MH = mapHref || "Map.html";
const tab = (id, label, href, icon) => {
const on = active === id;
return (
<a href={href} style={{
display: "flex", alignItems: "center", gap: 7, padding: "0 16px", height: "100%",
textDecoration: "none", fontSize: 13, fontWeight: 600,
color: on ? "var(--ink)" : "var(--ink-3)",
borderBottom: `2px solid ${on ? "var(--accent)" : "transparent"}`,
background: on ? "var(--bg-1)" : "transparent",
}}>
<span style={{ display: "grid", placeItems: "center", opacity: on ? 1 : 0.7 }}>{icon}</span>
{label}
</a>
);
};
return (
<div style={{ height: 46, flex: "none", display: "flex", alignItems: "stretch", background: "var(--bg-0)", borderBottom: "1px solid var(--line)" }}>
<div style={{ display: "flex", alignItems: "center", gap: 8, padding: "0 16px", borderRight: "1px solid var(--line)" }}>
<div style={{ width: 20, height: 20, borderRadius: 5, background: "var(--accent)", display: "grid", placeItems: "center", boxShadow: "0 0 12px var(--accent)" }}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="3" fill="var(--bg-0)" /><ellipse cx="12" cy="12" rx="10" ry="4.3" stroke="var(--bg-0)" strokeWidth="1.7" transform="rotate(28 12 12)" /></svg>
</div>
<span className="mono" style={{ fontSize: 11, fontWeight: 600, letterSpacing: ".05em", color: "var(--ink-1)" }}>MISSION&nbsp;OPS</span>
</div>
{tab("plans", "Планы", PH, <svg width="14" height="14" viewBox="0 0 24 24" fill="none"><rect x="3" y="6" width="14" height="3" rx="1.5" fill="currentColor" /><rect x="6" y="12" width="14" height="3" rx="1.5" fill="currentColor" /><rect x="3" y="18" width="9" height="3" rx="1.5" fill="currentColor" /></svg>)}
{tab("map", "Карта", MH, <svg width="14" height="14" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="2" /><path d="M3 12h18M12 3c3 3 3 15 0 18M12 3c-3 3-3 15 0 18" stroke="currentColor" strokeWidth="1.5" fill="none" /></svg>)}
</div>
);
}
// Цветная метка типа аппаратуры (общая для обеих страниц).
function TypeDot({ type, size = 9 }) {
return (
<span style={{
width: size, height: size, borderRadius: 2, flex: "none",
background: TYPE_COLOR[type], display: "inline-block",
boxShadow: `0 0 6px ${TYPE_COLOR[type]}`,
}} />
);
}
Object.assign(window, { TopTabs, TypeDot });
@@ -0,0 +1,273 @@
// Таймлайн (Gantt): ось времени, сетка, маркер «сейчас», секции групп,
// раскладка интервалов по лейнам, пометка пересечений, стрелки родословной.
const { useRef: useRefTL, useMemo: useMemoTL, useEffect: useEffectTL, useLayoutEffect } = React;
const LABEL_W = 234;
const LANE_H = 38;
const ROW_PAD = 8;
const GROUP_H = 30;
const AXIS_H = 46;
function Timeline(props) {
const { rows, pxPerMs, rangeStart, rangeEnd, now, selectedIv, onSelectIv,
lineageSet, byId, scrollSignal, timelineCollapsed, toggleCollapse } = props;
const bodyRef = useRefTL(null);
const axisRef = useRefTL(null);
const leftRef = useRefTL(null);
const x = (ms) => (ms - rangeStart) * pxPerMs;
const contentW = (rangeEnd - rangeStart) * pxPerMs;
// Раскладка строк по вертикали + позиции интервалов.
const layout = useMemoTL(() => {
let y = 0;
const placed = [];
const ivPos = {};
for (const r of rows) {
if (r.kind === "group") {
placed.push({ ...r, y, h: GROUP_H });
y += GROUP_H;
} else {
const { laneOf, laneCount } = assignLanes(r.ivs);
const h = laneCount * LANE_H + ROW_PAD * 2;
const overlaps = overlapSegments(r.ivs);
for (const iv of r.ivs) {
const lane = laneOf[iv.id];
ivPos[iv.id] = {
x: x(iv.start), x2: x(iv.end),
cy: y + ROW_PAD + lane * LANE_H + LANE_H / 2,
};
}
placed.push({ ...r, y, h, laneOf, laneCount, overlaps });
y += h;
}
}
return { placed, total: y, ivPos };
}, [rows, pxPerMs, rangeStart]);
// Сетка по дням + засечки оси.
const days = useMemoTL(() => {
const out = [];
const startD = new Date(rangeStart);
let t = Date.UTC(startD.getUTCFullYear(), startD.getUTCMonth(), startD.getUTCDate());
while (t <= rangeEnd) {
const d = new Date(t);
out.push({ t, weekend: d.getUTCDay() === 0 || d.getUTCDay() === 6, dow: d.getUTCDay() });
t += 86400000;
}
return out;
}, [rangeStart, rangeEnd]);
const sub = useMemoTL(() => niceTicks(rangeStart, rangeEnd, pxPerMs, 64), [rangeStart, rangeEnd, pxPerMs]);
const showHourTicks = sub.step < 86400000;
// Синхронизация скролла.
const onBodyScroll = () => {
const b = bodyRef.current;
if (axisRef.current) axisRef.current.scrollLeft = b.scrollLeft;
if (leftRef.current) leftRef.current.scrollTop = b.scrollTop;
};
// Прокрутка к «сейчас».
useEffectTL(() => {
const b = bodyRef.current;
if (!b) return;
const target = x(now) - b.clientWidth * 0.32;
b.scrollTo({ left: Math.max(0, target), behavior: scrollSignal === 0 ? "auto" : "smooth" });
onBodyScroll();
}, [scrollSignal]);
// Стрелки родословной для выделенной цепочки.
const arrows = useMemoTL(() => {
if (!selectedIv) return [];
const lin = lineageOf(selectedIv, byId);
const segs = [];
for (const id of lin.chain) {
(D.childrenOf[id] || []).forEach((c) => {
if (lin.chain.includes(c) && layout.ivPos[id] && layout.ivPos[c]) {
segs.push({ from: layout.ivPos[id], to: layout.ivPos[c] });
}
});
}
return segs;
}, [selectedIv, layout, lineageSet]);
const anySelected = !!selectedIv;
return (
<div style={{ display: "flex", flexDirection: "column", height: "100%", background: "var(--bg-0)", minWidth: 0 }}>
{/* ОСЬ ВРЕМЕНИ */}
<div style={{ display: "flex", height: AXIS_H, flex: "none", borderBottom: "1px solid var(--line)", background: "var(--bg-1)" }}>
<div style={{ width: LABEL_W, flex: "none", borderRight: "1px solid var(--line)", display: "flex", alignItems: "flex-end", padding: "0 12px 7px" }}>
<span className="mono" style={{ fontSize: 9.5, color: "var(--ink-3)", letterSpacing: ".1em" }}>КОСМИЧЕСКИЙ АППАРАТ</span>
</div>
<div ref={axisRef} style={{ flex: 1, overflow: "hidden", position: "relative" }}>
<div style={{ width: contentW, height: "100%", position: "relative" }}>
{/* верхний ярус — дни */}
{days.map((d, i) => {
const w = 86400000 * pxPerMs;
return (
<div key={i} style={{ position: "absolute", left: x(d.t), top: 0, width: w, height: 24, borderLeft: "1px solid var(--line-soft)", display: "flex", alignItems: "center", gap: 5, padding: "0 7px", color: d.weekend ? "var(--ink-3)" : "var(--ink-1)" }}>
<span style={{ fontSize: 11, fontWeight: 600 }}>{pad(new Date(d.t).getUTCDate())}</span>
<span style={{ fontSize: 9.5, color: "var(--ink-3)" }}>{MONTHS[new Date(d.t).getUTCMonth()]}</span>
<span className="mono" style={{ fontSize: 8.5, color: "var(--ink-3)", letterSpacing: ".05em" }}>{WDAYS[d.dow]}</span>
</div>
);
})}
{/* нижний ярус — засечки */}
{sub.ticks.map((tk, i) => (
<div key={i} style={{ position: "absolute", left: x(tk.t), top: 24, height: 22, borderLeft: "1px solid var(--grid)", paddingLeft: 4, display: "flex", alignItems: "center" }}>
{showHourTicks && <span className="mono" style={{ fontSize: 9, color: "var(--ink-3)" }}>{fmtTime(tk.t)}</span>}
</div>
))}
{/* метка «сейчас» */}
<div style={{ position: "absolute", left: x(now), top: 0, height: "100%", transform: "translateX(-50%)", display: "flex", alignItems: "flex-start" }}>
<span className="mono" style={{ marginTop: 3, fontSize: 9, fontWeight: 600, color: "var(--bg-0)", background: "var(--now)", padding: "1px 5px", borderRadius: 4, whiteSpace: "nowrap", boxShadow: "0 0 8px var(--now)" }}>СЕЙЧАС {fmtTime(now)}</span>
</div>
</div>
</div>
</div>
{/* ТЕЛО */}
<div style={{ flex: 1, display: "flex", minHeight: 0 }}>
{/* Левая колонка подписей */}
<div ref={leftRef} style={{ width: LABEL_W, flex: "none", overflow: "hidden", borderRight: "1px solid var(--line)", background: "var(--bg-1)" }}>
<div style={{ height: layout.total, position: "relative" }}>
{layout.placed.map((r) => r.kind === "group" ? (
<div key={"g" + r.group.id} onClick={() => toggleCollapse(r.group.id)}
style={{ position: "absolute", top: r.y, left: 0, right: 0, height: r.h, display: "flex", alignItems: "center", gap: 7, padding: "0 12px", background: "var(--bg-2)", borderBottom: "1px solid var(--line)", borderTop: "1px solid var(--line)", cursor: "pointer" }}>
<svg width="9" height="9" viewBox="0 0 10 10" style={{ transform: timelineCollapsed.has(r.group.id) ? "none" : "rotate(90deg)", transition: "transform .12s", color: "var(--ink-2)", flex: "none" }}><path d="M3 1.5 7 5 3 8.5" stroke="currentColor" strokeWidth="1.6" fill="none" strokeLinecap="round" strokeLinejoin="round" /></svg>
<span className="mono" style={{ fontSize: 9.5, color: "var(--ink-3)" }}>{r.group.code}</span>
<span style={{ flex: 1, fontSize: 12, fontWeight: 700 }}>{r.group.name}</span>
<span className="mono tnum" style={{ fontSize: 10, color: "var(--ink-3)" }}>{r.count}</span>
</div>
) : (
<div key={r.sc.id} style={{ position: "absolute", top: r.y, left: 0, right: 0, height: r.h, display: "flex", alignItems: "center", gap: 8, padding: "0 12px", borderBottom: "1px solid var(--line-soft)" }}>
<TypeDot type={r.sc.type} size={8} />
<div style={{ minWidth: 0, flex: 1 }}>
<div className="mono" style={{ fontSize: 11.5, color: "var(--ink-1)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{r.sc.short}</div>
<div style={{ fontSize: 9.5, color: "var(--ink-3)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{r.sc.orbit}</div>
</div>
<span className="mono tnum" style={{ fontSize: 9.5, color: "var(--ink-3)" }}>{r.ivs.length}</span>
</div>
))}
</div>
</div>
{/* Правый холст */}
<div ref={bodyRef} onScroll={onBodyScroll} style={{ flex: 1, overflow: "auto", position: "relative" }}>
<div style={{ width: contentW, height: layout.total, position: "relative" }}>
{/* фон выходных + сетка дней */}
{days.map((d, i) => (
<div key={i} style={{ position: "absolute", left: x(d.t), top: 0, width: 86400000 * pxPerMs, height: "100%", borderLeft: "1px solid var(--grid)", background: d.weekend ? "oklch(0.22 0.012 165 / 0.5)" : "transparent" }} />
))}
{/* линия «сейчас» */}
<div style={{ position: "absolute", left: x(now), top: 0, height: "100%", width: 0, borderLeft: "1.5px dashed var(--now)", zIndex: 6 }} />
{/* строки */}
{layout.placed.map((r) => r.kind === "group" ? (
<div key={"g" + r.group.id} style={{ position: "absolute", top: r.y, left: 0, width: contentW, height: r.h, background: "var(--bg-2)", borderTop: "1px solid var(--line)", borderBottom: "1px solid var(--line)", opacity: .95 }} />
) : (
<Row key={r.sc.id} r={r} x={x} contentW={contentW} selectedIv={selectedIv}
onSelectIv={onSelectIv} lineageSet={lineageSet} anySelected={anySelected} />
))}
{/* стрелки родословной */}
{arrows.length > 0 && (
<svg style={{ position: "absolute", left: 0, top: 0, width: contentW, height: layout.total, pointerEvents: "none", zIndex: 7 }}>
<defs>
<marker id="ah" markerWidth="7" markerHeight="7" refX="5.5" refY="3" orient="auto">
<path d="M0 0 L6 3 L0 6 z" fill="var(--accent)" />
</marker>
</defs>
{arrows.map((a, i) => {
const x1 = a.from.x2, y1 = a.from.cy, x2 = a.to.x, y2 = a.to.cy;
const mid = x1 + Math.max(14, (x2 - x1) / 2);
const d = `M ${x1} ${y1} C ${mid} ${y1}, ${mid} ${y2}, ${x2 - 3} ${y2}`;
return <path key={i} d={d} fill="none" stroke="var(--accent)" strokeWidth="1.6" markerEnd="url(#ah)" opacity="0.9" />;
})}
</svg>
)}
</div>
</div>
</div>
</div>
);
}
// Одна строка КА: ПЛАНЫ (объемлющие интервалы) по лейнам, внутри — РАБОТЫ.
function Row({ r, x, contentW, selectedIv, onSelectIv, lineageSet, anySelected }) {
return (
<div style={{ position: "absolute", top: r.y, left: 0, width: contentW, height: r.h, borderBottom: "1px solid var(--line-soft)" }}>
{/* пересечения планов — штриховка */}
{r.overlaps.map((ov, i) => (
<div key={"ov" + i} className="hatch" style={{ position: "absolute", left: x(ov.start), width: Math.max(2, x(ov.end) - x(ov.start)), top: 2, height: r.h - 4, borderLeft: "1px solid var(--now)", borderRight: "1px solid var(--now)", borderRadius: 2, zIndex: 1, pointerEvents: "none" }} />
))}
{r.ivs.map((plan) => {
const lane = r.laneOf[plan.id];
const left = x(plan.start);
const w = Math.max(4, x(plan.end) - x(plan.start));
const inChain = lineageSet && lineageSet.has(plan.id);
const isSel = selectedIv === plan.id;
const dim = anySelected && !inChain;
const scType = r.sc.type;
const col = TYPE_COLOR[scType];
const hasParent = !!plan.parentId;
const hasChild = (D.childrenOf[plan.id] || []).length > 0;
const bandTop = ROW_PAD + lane * LANE_H;
const bandH = LANE_H - 8;
const wide = w > 78;
const showWorks = w > 14;
const executed = plan.status === "executed";
return (
<div key={plan.id} onClick={(e) => { e.stopPropagation(); onSelectIv(plan.id); }}
title={`План ${plan.id} · ${plan.title} · ${fmtDateTime(plan.start)}${fmtDateTime(plan.end)} · работ: ${plan.works.length}`}
style={{
position: "absolute", left, width: w, top: bandTop, height: bandH,
background: `color-mix(in oklch, ${col} 13%, var(--bg-1))`,
border: `1px solid color-mix(in oklch, ${col} 45%, var(--bg-1))`,
borderLeft: `3px solid ${col}`,
borderRadius: 5, cursor: "pointer", zIndex: isSel ? 5 : 3,
opacity: dim ? 0.26 : executed ? 0.7 : 1,
outline: isSel ? `1.5px solid var(--accent)` : inChain ? `1px solid color-mix(in oklch, var(--accent) 55%, transparent)` : "none",
boxShadow: isSel ? "0 0 0 3px oklch(0.82 0.13 200 / 0.18), var(--shadow)" : "none",
overflow: "hidden", transition: "opacity .12s",
}}>
{/* заголовок плана */}
<div style={{ display: "flex", alignItems: "center", gap: 4, height: 14, padding: "0 4px 0 5px" }}>
{hasParent && <span style={{ flex: "none", color: "var(--accent)", fontSize: 8, lineHeight: 1 }} title="есть предок"></span>}
{wide && <span className="mono" style={{ flex: 1, fontSize: 9, color: "var(--ink-1)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", letterSpacing: ".01em" }}>{plan.title}</span>}
{wide && plan.status === "active" && <span style={{ width: 4, height: 4, borderRadius: 9, background: "var(--now)", boxShadow: "0 0 5px var(--now)", flex: "none" }} />}
{wide && plan.rev > 1 && <span className="mono" style={{ flex: "none", fontSize: 7.5, color: "var(--ink-3)", border: "1px solid var(--line)", borderRadius: 3, padding: "0 2px" }}>r{plan.rev}</span>}
{!wide && <span style={{ flex: 1 }} />}
{hasChild && <span style={{ flex: "none", color: "var(--accent)", fontSize: 8, lineHeight: 1 }} title="есть потомок"></span>}
</div>
{/* трек работ внутри плана */}
{showWorks && (
<div style={{ position: "absolute", left: 0, right: 0, bottom: 2, height: 11 }}>
{plan.works.map((wk) => {
const wl = x(wk.start) - left;
const ww = Math.max(1.5, x(wk.end) - x(wk.start));
const fill = workFill(wk.kind, scType);
const isService = wk.kind === "service";
return (
<div key={wk.id} title={`${wk.label} · ${fmtTime(wk.start)}${fmtTime(wk.end)}`}
style={{
position: "absolute", left: wl, width: ww, top: 0, height: 11,
background: isService ? "transparent" : fill,
border: isService ? `1px dashed ${fill}` : "none",
borderRadius: 2, boxSizing: "border-box",
}} />
);
})}
</div>
)}
</div>
);
})}
</div>
);
}
Object.assign(window, { Timeline });
+123
View File
@@ -0,0 +1,123 @@
// Общие утилиты, формат времени, раскладка интервалов по лейнам.
const D = window.MissionData;
const TYPE_COLOR = {
optical: "var(--t-optical)",
radar: "var(--t-radar)",
combo: "var(--t-combo)",
};
const TYPE_DIM = {
optical: "var(--t-optical-dim)",
radar: "var(--t-radar-dim)",
combo: "var(--t-combo-dim)",
};
// Стиль работы внутри плана по её категории. shoot тонируется типом аппаратуры,
// остальные — служебной палитрой, отличной от трёх цветов аппаратуры.
const WORK_KIND_COLOR = {
shoot: null, // = цвет типа КА
downlink: "var(--accent)",
calib: "oklch(0.78 0.02 200)",
service: "var(--ink-3)",
};
function workFill(kind, scType) {
if (kind === "shoot") return TYPE_COLOR[scType];
return WORK_KIND_COLOR[kind];
}
const MONTHS = ["янв", "фев", "мар", "апр", "май", "июн", "июл", "авг", "сен", "окт", "ноя", "дек"];
const WDAYS = ["вс", "пн", "вт", "ср", "чт", "пт", "сб"];
function pad(n) { return String(n).padStart(2, "0"); }
function fmtTime(ms) {
const d = new Date(ms);
return `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`;
}
function fmtDate(ms) {
const d = new Date(ms);
return `${pad(d.getUTCDate())} ${MONTHS[d.getUTCMonth()]}`;
}
function fmtDateTime(ms) {
const d = new Date(ms);
return `${pad(d.getUTCDate())} ${MONTHS[d.getUTCMonth()]} ${fmtTime(ms)}`;
}
function fmtDur(ms) {
const h = ms / 3600000;
if (h < 24) return `${h % 1 === 0 ? h : h.toFixed(1)} ч`;
const d = Math.floor(h / 24);
const rh = Math.round(h - d * 24);
return rh ? `${d} сут ${rh} ч` : `${d} сут`;
}
// Раскладка интервалов одного КА по лейнам (greedy interval graph).
function assignLanes(ivs) {
const sorted = [...ivs].sort((a, b) => a.start - b.start || a.end - b.end);
const laneEnds = []; // конец последнего интервала в каждом лейне
const out = {};
for (const iv of sorted) {
let placed = false;
for (let l = 0; l < laneEnds.length; l++) {
if (iv.start >= laneEnds[l]) {
out[iv.id] = l;
laneEnds[l] = iv.end;
placed = true;
break;
}
}
if (!placed) {
out[iv.id] = laneEnds.length;
laneEnds.push(iv.end);
}
}
return { laneOf: out, laneCount: Math.max(1, laneEnds.length) };
}
// Пересечения по времени между интервалами одного КА.
function overlapSegments(ivs) {
const segs = [];
for (let i = 0; i < ivs.length; i++) {
for (let j = i + 1; j < ivs.length; j++) {
const a = ivs[i], b = ivs[j];
const s = Math.max(a.start, b.start);
const e = Math.min(a.end, b.end);
if (e > s) segs.push({ start: s, end: e, a: a.id, b: b.id });
}
}
return segs;
}
// Цепочка родословной (предки + потомки) для интервала.
function lineageOf(id, byId) {
const anc = [];
let cur = byId[id];
while (cur && cur.parentId) {
anc.unshift(cur.parentId);
cur = byId[cur.parentId];
}
const desc = [];
const stack = [...(D.childrenOf[id] || [])];
while (stack.length) {
const c = stack.shift();
desc.push(c);
(D.childrenOf[c] || []).forEach((x) => stack.push(x));
}
return { ancestors: anc, descendants: desc, chain: [...anc, id, ...desc] };
}
// "Хорошие" шаги сетки времени для оси.
function niceTicks(rangeStart, rangeEnd, pxPerMs, minPx) {
const HOUR = 3600000, DAY = 86400000;
const steps = [1 * HOUR, 2 * HOUR, 3 * HOUR, 6 * HOUR, 12 * HOUR, 1 * DAY, 2 * DAY, 7 * DAY];
let step = steps[steps.length - 1];
for (const s of steps) { if (s * pxPerMs >= minPx) { step = s; break; } }
const ticks = [];
const startTick = Math.ceil(rangeStart / step) * step;
for (let t = startTick; t <= rangeEnd; t += step) ticks.push({ t, step });
return { ticks, step };
}
Object.assign(window, {
TYPE_COLOR, TYPE_DIM, MONTHS, WDAYS, WORK_KIND_COLOR, workFill,
pad, fmtTime, fmtDate, fmtDateTime, fmtDur,
assignLanes, overlapSegments, lineageOf, niceTicks,
});
@@ -0,0 +1,41 @@
// Схематичные контуры континентов (равнопрямоугольная проекция, [lon, lat])
// и наземные станции приёма (НКПОР). Низкополигональный OPS-стиль, оффлайн.
(function () {
const LAND = {
"Сев. Америка": [[-166,66],[-160,70],[-150,70],[-140,70],[-125,70],[-110,71],[-95,72],[-83,73],[-70,67],[-64,60],[-56,53],[-52,47],[-60,46],[-67,44],[-70,41],[-74,39],[-76,35],[-81,31],[-80,25],[-84,30],[-90,29],[-94,29],[-97,26],[-97,22],[-105,22],[-110,29],[-114,31],[-120,34],[-124,40],[-124,48],[-130,53],[-136,58],[-145,60],[-152,59],[-160,58],[-164,60]],
"Гренландия": [[-46,60],[-32,66],[-22,70],[-20,74],[-28,79],[-40,83],[-55,82],[-60,76],[-53,70],[-50,64]],
"Юж. Америка": [[-78,9],[-70,11],[-60,10],[-51,4],[-50,-1],[-43,-3],[-35,-6],[-37,-13],[-48,-25],[-55,-34],[-62,-40],[-66,-45],[-70,-52],[-73,-54],[-74,-49],[-72,-42],[-71,-33],[-70,-24],[-71,-18],[-76,-14],[-80,-6],[-81,-3],[-79,3]],
"Африка": [[-16,15],[-13,21],[-6,27],[2,34],[10,37],[19,33],[26,32],[32,31],[35,24],[39,16],[43,12],[51,12],[44,3],[41,-3],[40,-11],[35,-18],[30,-26],[22,-34],[18,-35],[14,-24],[11,-13],[9,-1],[6,4],[-2,5],[-9,5],[-14,9]],
"Европа": [[-10,37],[-9,43],[-2,44],[-2,48],[2,51],[0,55],[5,58],[6,62],[11,64],[15,68],[21,70],[26,71],[30,67],[39,68],[42,63],[38,58],[30,59],[28,56],[24,56],[20,54],[14,54],[9,54],[4,51],[-1,49],[-4,48],[-1,46],[-2,43],[-9,40]],
"Азия": [[42,63],[55,68],[68,72],[80,74],[95,77],[110,76],[125,73],[140,72],[160,70],[172,67],[180,66],[178,62],[165,60],[160,54],[155,52],[143,53],[140,48],[135,44],[130,43],[127,38],[122,40],[121,31],[120,23],[110,21],[108,15],[105,9],[100,7],[98,11],[93,18],[88,22],[82,17],[77,8],[73,17],[68,24],[62,25],[57,25],[50,30],[47,38],[50,45],[48,50],[58,55],[50,58],[45,60]],
"Австралия": [[114,-22],[114,-32],[118,-35],[129,-32],[138,-35],[147,-38],[150,-37],[153,-31],[153,-25],[146,-18],[142,-11],[136,-12],[130,-12],[125,-14],[122,-18]],
"Н. Гвинея": [[131,-1],[141,-3],[147,-7],[143,-9],[134,-9],[131,-5]],
"Суматра": [[95,5],[100,1],[104,-5],[100,-3],[96,2]],
"Калимантан": [[109,2],[115,2],[118,-2],[114,-4],[109,-2]],
"Ява": [[105,-6],[114,-7],[110,-8]],
"Япония": [[130,32],[135,34],[140,36],[142,40],[141,45],[138,42],[135,36]],
"Британия": [[-5,50],[-2,51],[0,52],[-1,54],[-3,55],[-5,58],[-7,57],[-6,54],[-10,52]],
"Ирландия": [[-10,52],[-6,52],[-6,55],[-10,54]],
"Мадагаскар": [[44,-12],[50,-15],[49,-22],[45,-25],[44,-19],[43,-15]],
"Н. Зеландия С": [[173,-35],[178,-38],[174,-41],[172,-40],[173,-37]],
"Н. Зеландия Ю": [[170,-41],[172,-43],[170,-46],[167,-46],[168,-44]],
"Исландия": [[-24,65],[-18,66],[-14,65],[-18,64],[-23,64]],
"Антарктида": [[-180,-72],[-150,-75],[-120,-73],[-90,-72],[-60,-78],[-30,-72],[0,-70],[30,-69],[60,-67],[90,-66],[120,-66],[150,-70],[180,-72],[180,-85],[-180,-85]],
};
// Наземные станции приёма (НКПОР) — пункты приёма ДЗЗ.
const GROUND_STATIONS = [
{ id: "msk", name: "Москва", lon: 37.6, lat: 55.75 },
{ id: "dubna", name: "Дубна", lon: 37.17, lat: 56.73 },
{ id: "zhel", name: "Железногорск", lon: 93.5, lat: 56.25 },
{ id: "khab", name: "Хабаровск", lon: 135.07, lat: 48.48 },
{ id: "murm", name: "Мурманск", lon: 33.08, lat: 68.97 },
{ id: "anad", name: "Анадырь", lon: 177.5, lat: 64.73 },
{ id: "novo", name: "Новосибирск", lon: 82.9, lat: 55.03 },
{ id: "svalb", name: "Шпицберген", lon: 15.6, lat: 78.2 },
{ id: "magad", name: "Магадан", lon: 150.8, lat: 59.56 },
{ id: "progress", name: "Прогресс (Антарктида)", lon: 76.4, lat: -69.4 },
];
window.WorldGeo = { LAND, GROUND_STATIONS };
})();
@@ -0,0 +1,94 @@
<!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" />
<style>
:root {
/* Фон — очень тёмный десатурированный тёмно-зелёный (OPS) */
--bg-0: oklch(0.18 0.012 165);
--bg-1: oklch(0.215 0.013 165);
--bg-2: oklch(0.255 0.014 165);
--bg-3: oklch(0.30 0.015 165);
--line: oklch(0.34 0.014 165);
--line-soft: oklch(0.28 0.013 165);
--grid: oklch(0.26 0.012 165);
--ink: oklch(0.95 0.01 150);
--ink-1: oklch(0.80 0.012 160);
--ink-2: oklch(0.64 0.012 165);
--ink-3: oklch(0.50 0.012 165);
/* Типы аппаратуры — одинаковая C/L, разный hue */
--t-optical: oklch(0.80 0.14 158);
--t-radar: oklch(0.82 0.13 82);
--t-combo: oklch(0.74 0.15 6);
--t-optical-dim: oklch(0.80 0.14 158 / 0.16);
--t-radar-dim: oklch(0.82 0.13 82 / 0.16);
--t-combo-dim: oklch(0.74 0.15 6 / 0.16);
--accent: oklch(0.82 0.13 200);
--warn: oklch(0.80 0.15 55);
--now: oklch(0.86 0.16 25);
--radius: 7px;
--mono: "IBM Plex Mono", ui-monospace, monospace;
--sans: "IBM Plex Sans", system-ui, sans-serif;
--shadow: 0 8px 30px oklch(0.12 0.01 165 / 0.55);
}
* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; }
body {
font-family: var(--sans);
background: var(--bg-0);
color: var(--ink);
font-size: 13px;
-webkit-font-smoothing: antialiased;
overflow: hidden;
}
#root { height: 100vh; }
::-webkit-scrollbar { width: 11px; height: 11px; }
::-webkit-scrollbar-track { background: var(--bg-0); }
::-webkit-scrollbar-thumb { background: var(--bg-3); border-radius: 6px; border: 2px solid var(--bg-0); }
::-webkit-scrollbar-thumb:hover { background: var(--line); }
::-webkit-scrollbar-corner { background: var(--bg-0); }
.mono { font-family: var(--mono); }
.tnum { font-variant-numeric: tabular-nums; }
button { font-family: inherit; color: inherit; cursor: pointer; }
/* диагональная штриховка для пометки пересечений */
.hatch {
background-image: repeating-linear-gradient(
-45deg,
oklch(0.86 0.16 25 / 0.55) 0px,
oklch(0.86 0.16 25 / 0.55) 2px,
transparent 2px,
transparent 6px
);
}
</style>
</head>
<body>
<div id="root"></div>
<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>
<script src="data.jsx"></script>
<script type="text/babel" src="util.jsx"></script>
<script type="text/babel" src="tabs.jsx"></script>
<script type="text/babel" src="sidebar.jsx"></script>
<script type="text/babel" src="timeline.jsx"></script>
<script type="text/babel" src="details.jsx"></script>
<script type="text/babel" src="app.jsx"></script>
</body>
</html>
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+204
View File
@@ -0,0 +1,204 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'USAGE'
Usage:
./script-gpt-archive.sh [archive.zip]
./script-gpt-archive.sh --list
Creates a zip archive with project files useful for code analysis.
Environment:
MAX_FILE_SIZE_BYTES Per-file size limit. Default: 1048576
The script includes source, configuration, build metadata, CI, Helm and docs.
It excludes build outputs, logs, local caches, IDE files, archives, binaries and env files.
USAGE
}
mode="create"
if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then
usage
exit 0
fi
if [[ "${1:-}" == "--list" ]]; then
mode="list"
shift
fi
if (( $# > 1 )); then
usage >&2
exit 2
fi
project_root="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
archive_arg="${1:-pcp-chatgpt-context.zip}"
max_file_size_bytes="${MAX_FILE_SIZE_BYTES:-1048576}"
if [[ "$archive_arg" = /* ]]; then
archive_path="$archive_arg"
else
archive_path="$project_root/$archive_arg"
fi
include_paths=(
"AGENTS.md"
"README.md"
"HELP.md"
".dockerignore"
".gitattributes"
".gitignore"
".gitlab-ci.yml"
"build.gradle.kts"
"settings.gradle.kts"
"gradle.properties"
"gradlew"
"gradlew.bat"
"script-gpt-archive.sh"
"changeLogsDynamicSlots.md"
".gitlab"
"config-repo"
"docs"
"gradle"
"helm"
"libs"
"schemes"
"services"
)
should_skip_path() {
local path="$1"
case "$path" in
gradle/wrapper/gradle-wrapper.jar)
return 1
;;
.git|.git/*|.gradle|.gradle/*|.gradle-local|.gradle-local/*|.idea|.idea/*|.kotlin|.kotlin/*|.vscode|.vscode/*)
return 0
;;
build|build/*|logs|logs/*|out|out/*|bin|bin/*|target|target/*|node_modules|node_modules/*)
return 0
;;
*/build|*/build/*|*/logs|*/logs/*|*/out|*/out/*|*/bin|*/bin/*|*/target|*/target/*|*/node_modules|*/node_modules/*)
return 0
;;
esac
case "$path" in
.env|.env.*|*/.env|*/.env.*)
return 0
;;
*.log|*.tmp|*.bak|*.orig|*.class|*.jar|*.war|*.ear|*.zip|*.tar|*.tgz|*.tar.gz|*.gz|*.7z|*.rar)
return 0
;;
*.png|*.jpg|*.jpeg|*.gif|*.webp|*.ico|*.pdf|*.doc|*.docx|*.xls|*.xlsx|*.ppt|*.pptx)
return 0
;;
*.keystore|*.jks|*.p12|*.pem|*.key|*.crt)
return 0
;;
esac
return 1
}
is_allowed_file() {
local path="$1"
case "$path" in
AGENTS.md|README.md|HELP.md|.dockerignore|.gitattributes|.gitignore|.gitlab-ci.yml)
return 0
;;
build.gradle.kts|settings.gradle.kts|gradle.properties|gradlew|gradlew.bat|script-gpt-archive.sh|changeLogsDynamicSlots.md)
return 0
;;
gradle/wrapper/gradle-wrapper.jar)
return 0
;;
Dockerfile|Makefile|Jenkinsfile)
return 0
;;
*.kt|*.kts|*.java|*.groovy|*.xml|*.yaml|*.yml|*.properties|*.json|*.toml|*.md|*.txt)
return 0
;;
*.sql|*.graphql|*.graphqls|*.proto|*.bpmn|*.dmn|*.tpl|*.sh)
return 0
;;
*.html|*.css|*.scss|*.js|*.jsx|*.ts|*.tsx|*.vue)
return 0
;;
esac
return 1
}
manifest_file="$(mktemp)"
trap 'rm -f "$manifest_file"' EXIT
collect_files() {
local root_path
local relative_path
local file_size
for root_path in "${include_paths[@]}"; do
if [[ ! -e "$project_root/$root_path" ]]; then
continue
fi
if [[ -f "$project_root/$root_path" ]]; then
printf '%s\n' "$root_path"
else
(cd "$project_root" && find "$root_path" -type f -print)
fi
done | while IFS= read -r relative_path; do
relative_path="${relative_path#./}"
if should_skip_path "$relative_path"; then
continue
fi
if ! is_allowed_file "$relative_path"; then
continue
fi
file_size="$(stat -c '%s' "$project_root/$relative_path")"
if (( file_size > max_file_size_bytes )); then
continue
fi
printf '%s\n' "$relative_path"
done | sort -u
}
collect_files > "$manifest_file"
file_count="$(wc -l < "$manifest_file" | tr -d ' ')"
if (( file_count == 0 )); then
echo "No files selected for archive." >&2
exit 1
fi
if [[ "$mode" == "list" ]]; then
cat "$manifest_file"
echo
echo "Selected files: $file_count" >&2
exit 0
fi
if ! command -v zip >/dev/null 2>&1; then
echo "zip command is required but was not found." >&2
exit 1
fi
mkdir -p "$(dirname "$archive_path")"
rm -f "$archive_path"
(cd "$project_root" && zip -q -X "$archive_path" -@ < "$manifest_file")
archive_size="$(du -h "$archive_path" | awk '{print $1}')"
echo "Archive created: $archive_path"
echo "Selected files: $file_count"
echo "Archive size: $archive_size"
@@ -7,7 +7,7 @@ spring:
import: "configserver:"
cloud:
config:
uri: ${CONFIG_SERVER_URI:http://192.168.60.201:8888}
uri: ${CONFIG_SERVER_URI:http://192.168.1.8: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}
@@ -7,7 +7,7 @@ spring:
import: "configserver:"
cloud:
config:
uri: ${CONFIG_SERVER_URI:http://192.168.60.201:8888}
uri: ${CONFIG_SERVER_URI:http://192.168.1.8: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}
@@ -20,7 +20,7 @@ spring:
on-profile: local
datasource:
driver-class-name: org.postgresql.Driver
url: ${PCP_COMPLEX_MISSION_DATASOURCE_URL:jdbc:postgresql://${PCP_POSTGRES_HOST:192.168.60.201}:${PCP_POSTGRES_PORT:5432}/pcp_satellites}
url: ${PCP_COMPLEX_MISSION_DATASOURCE_URL:jdbc:postgresql://${PCP_POSTGRES_HOST:192.168.1.8}:${PCP_POSTGRES_PORT:5432}/pcp_satellites}
username: ${PCP_COMPLEX_MISSION_DATASOURCE_USERNAME:postgres}
password: ${PCP_COMPLEX_MISSION_DATASOURCE_PASSWORD:password}
jpa:
@@ -46,7 +46,7 @@ spring:
on-profile: dev
datasource:
driver-class-name: org.postgresql.Driver
url: ${PCP_COMPLEX_MISSION_DATASOURCE_URL:jdbc:postgresql://192.168.60.201:35400/pcp_satellites}
url: ${PCP_COMPLEX_MISSION_DATASOURCE_URL:jdbc:postgresql://192.168.1.8:35400/pcp_satellites}
username: ${PCP_COMPLEX_MISSION_DATASOURCE_USERNAME:postgres}
password: ${PCP_COMPLEX_MISSION_DATASOURCE_PASSWORD:password}
jpa:
@@ -7,7 +7,7 @@ spring:
import: "configserver:"
cloud:
config:
uri: ${CONFIG_SERVER_URI:http://192.168.60.201:8888}
uri: ${CONFIG_SERVER_URI:http://192.168.1.8: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}
@@ -7,7 +7,7 @@ spring:
import: "configserver:"
cloud:
config:
uri: ${CONFIG_SERVER_URI:http://192.168.60.201:8888}
uri: ${CONFIG_SERVER_URI:http://192.168.1.8: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}
@@ -7,7 +7,7 @@ spring:
import: "configserver:"
cloud:
config:
uri: ${CONFIG_SERVER_URI:http://192.168.60.201:8888}
uri: ${CONFIG_SERVER_URI:http://192.168.1.8: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}
@@ -7,7 +7,7 @@ spring:
import: "configserver:"
cloud:
config:
uri: ${CONFIG_SERVER_URI:http://192.168.60.201:8888}
uri: ${CONFIG_SERVER_URI:http://192.168.1.8: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}
@@ -7,7 +7,7 @@ spring:
import: "configserver:"
cloud:
config:
uri: ${CONFIG_SERVER_URI:http://192.168.60.201:8888}
uri: ${CONFIG_SERVER_URI:http://192.168.1.8: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}
@@ -7,7 +7,7 @@ spring:
import: "configserver:"
cloud:
config:
uri: ${CONFIG_SERVER_URI:http://192.168.60.201:8888}
uri: ${CONFIG_SERVER_URI:http://192.168.1.8: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}
@@ -7,7 +7,7 @@ spring:
import: "configserver:"
cloud:
config:
uri: ${CONFIG_SERVER_URI:http://192.168.60.201:8888}
uri: ${CONFIG_SERVER_URI:http://192.168.1.8: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}
@@ -7,7 +7,7 @@ spring:
import: "configserver:"
cloud:
config:
uri: ${CONFIG_SERVER_URI:http://192.168.60.201:8888}
uri: ${CONFIG_SERVER_URI:http://192.168.1.8: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}
+4
View File
@@ -0,0 +1,4 @@
node_modules
dist
.vite
npm-debug.log*
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
{
"name": "pcp-tgu-ops-ui",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"test": "vitest run",
"preview": "vite preview"
},
"dependencies": {
"@vitejs/plugin-react": "^5.1.1",
"vite": "^7.2.4",
"typescript": "^5.9.3",
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
"devDependencies": {
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"jsdom": "^27.2.0",
"vitest": "^4.0.14"
}
}
@@ -0,0 +1,48 @@
import type { TguPlanUi } from "../../model/timelineTypes";
import { platformLabel } from "../tgu-planning/tguTimelineMapper";
import { statusStyle } from "../tgu-planning/tguStatus";
import type { Tgu2DMapSelection } from "./model/mapSelectionTypes";
type Tgu2DMapDetailsProps = {
selectedPlan?: TguPlanUi;
selectedObject?: Tgu2DMapSelection;
};
export function Tgu2DMapDetails({ selectedPlan, selectedObject }: Tgu2DMapDetailsProps) {
const status = selectedPlan ? statusStyle(selectedPlan.status) : undefined;
return (
<section className="tgu-map-details">
<div>
<div className="tgu-map-details__label">План</div>
<div className="tgu-map-details__value mono">{selectedPlan?.planId || "-"}</div>
</div>
<div>
<div className="tgu-map-details__label">КА</div>
<div className="tgu-map-details__value">
{selectedPlan ? platformLabel(selectedPlan.platform, selectedPlan.spacecraftId) : "-"}
</div>
</div>
<div>
<div className="tgu-map-details__label">Статус</div>
<div className="tgu-map-details__value">
{status ? (
<span className="tgu-map-status-pill">
<i style={{ backgroundColor: status.color }} />
{status.label}
</span>
) : "-"}
</div>
</div>
<div>
<div className="tgu-map-details__label">Объект карты</div>
<div className="tgu-map-details__value mono">{selectedObject?.name || selectedObject?.id || "-"}</div>
</div>
<div>
<div className="tgu-map-details__label">Работы</div>
<div className="tgu-map-details__value">Работы плана</div>
</div>
</section>
);
}
@@ -0,0 +1,43 @@
import type { TguPlanUi, TguPlatformUi, TimelineRange } from "../../model/timelineTypes";
import type { Tgu2DMapLayersState } from "./model/mapLayerTypes";
import type { Tgu2DMapScene } from "./model/mapTypes";
export type Tgu2DMapLayerAvailability = Partial<Record<keyof Tgu2DMapLayersState, string>>;
export type Tgu2DMapSceneInput = {
range: TimelineRange;
selectedPlan?: TguPlanUi;
selectedSpacecraftId?: string;
platforms: TguPlatformUi[];
};
const UNAVAILABLE = "Данные слоя недоступны в текущем API.";
export function buildTgu2DMapScene(_input: Tgu2DMapSceneInput): Tgu2DMapScene {
return {
polygons: [],
lines: [],
markers: []
};
}
export function getLayerAvailability(layers: Tgu2DMapLayersState, scene: Tgu2DMapScene): Tgu2DMapLayerAvailability {
const availability: Tgu2DMapLayerAvailability = {};
if (layers.tracks && scene.lines.filter((line) => line.layer === "tracks").length === 0) {
availability.tracks = UNAVAILABLE;
}
if (layers.swath && scene.polygons.filter((polygon) => polygon.layer === "swath").length === 0) {
availability.swath = UNAVAILABLE;
}
if (layers.planWorks && scene.polygons.filter((polygon) => polygon.layer === "planWorks").length === 0) {
availability.planWorks = UNAVAILABLE;
}
if (layers.stations && scene.markers.filter((marker) => marker.layer === "stations").length === 0) {
availability.stations = UNAVAILABLE;
}
if (layers.spacecraftMarkers && scene.markers.filter((marker) => marker.layer === "spacecraftMarkers").length === 0) {
availability.spacecraftMarkers = UNAVAILABLE;
}
return availability;
}
@@ -0,0 +1,110 @@
import type { TguPlatformUi } from "../../model/timelineTypes";
import { filterPlatforms, platformLabel } from "../tgu-planning/tguTimelineMapper";
import { TGU_2D_MAP_LAYERS, type Tgu2DMapLayerKey, type Tgu2DMapLayersState } from "./model/mapLayerTypes";
import type { Tgu2DMapLayerAvailability } from "./Tgu2DMapLayers";
type Tgu2DMapSidebarProps = {
platforms: TguPlatformUi[];
selectedSpacecraftId?: string;
selectedPlanId?: string;
layers: Tgu2DMapLayersState;
search: string;
layerAvailability: Tgu2DMapLayerAvailability;
onSearchChange: (value: string) => void;
onSelectSpacecraft: (spacecraftId?: string) => void;
onToggleLayer: (key: Tgu2DMapLayerKey) => void;
};
export function Tgu2DMapSidebar({
platforms,
selectedSpacecraftId,
selectedPlanId,
layers,
search,
layerAvailability,
onSearchChange,
onSelectSpacecraft,
onToggleLayer
}: Tgu2DMapSidebarProps) {
const filteredPlatforms = filterPlatforms(platforms, search);
return (
<aside className="tgu-map-panel">
<div className="tgu-panel-heading">
<span>Карта 2D</span>
<button className="tgu-link-button" type="button" onClick={() => onSelectSpacecraft(undefined)}>
Все
</button>
</div>
<div className="tgu-map-panel__section">
<div className="tgu-map-panel__label">Выбранный план</div>
<div className="tgu-map-panel__value mono">{selectedPlanId || "-"}</div>
</div>
<div className="tgu-map-panel__section">
<div className="tgu-map-panel__label">Слои</div>
<div className="tgu-map-layer-list">
{TGU_2D_MAP_LAYERS.map((layer) => (
<label className="tgu-map-layer" key={layer.key}>
<input type="checkbox" checked={layers[layer.key]} onChange={() => onToggleLayer(layer.key)} />
<span>
<span className="tgu-map-layer__title">{layer.label}</span>
<span className="tgu-map-layer__description">{layer.description}</span>
{layers[layer.key] && layerAvailability[layer.key] && (
<span className="tgu-map-layer__warning">{layerAvailability[layer.key]}</span>
)}
</span>
</label>
))}
</div>
</div>
<div className="tgu-map-panel__section">
<div className="tgu-map-panel__label">Космические аппараты</div>
<div className="tgu-search">
<input
type="search"
placeholder="Поиск name / NORAD / mission"
value={search}
onChange={(event) => onSearchChange(event.target.value)}
/>
</div>
<div className="tgu-map-spacecraft-list">
{filteredPlatforms.map((platform) => {
const selected = selectedSpacecraftId === platform.spacecraftId;
return (
<button
className={`tgu-map-spacecraft ${selected ? "is-selected" : ""}`}
type="button"
key={platform.spacecraftId}
onClick={() => onSelectSpacecraft(platform.spacecraftId)}
>
<span>
<span className="tgu-map-spacecraft__name">{platformLabel(platform, platform.spacecraftId)}</span>
<span className="tgu-map-spacecraft__meta">
NORAD {platform.noradId || platform.spacecraftId}
{platform.mission ? ` · ${platform.mission}` : ""}
</span>
</span>
<span className="tgu-spacecraft__count">{platform.planCount}</span>
</button>
);
})}
{filteredPlatforms.length === 0 && <div className="tgu-empty tgu-empty--compact">Нет КА по текущему поиску.</div>}
</div>
</div>
<div className="tgu-map-panel__section">
<div className="tgu-map-panel__label">Статусы планов</div>
<div className="tgu-map-status-legend">
<span><i className="status-accepted" />ACCEPTED</span>
<span><i className="status-rejected" />REJECTED</span>
<span><i className="status-waiting" />WAITING_DECISION</span>
<span><i className="status-planned" />PLANNED</span>
<span><i className="status-issuing" />ISSUING</span>
<span><i className="status-expired" />EXPIRED</span>
<span><i className="status-superseded" />SUPERSEDED</span>
<span><i className="status-ambiguous" />START_AMBIGUOUS</span>
</div>
</div>
</aside>
);
}
@@ -0,0 +1,131 @@
import { useMemo, useState } from "react";
import type { TguPlanUi, TguPlatformUi, TimelineRange } from "../../model/timelineTypes";
import { platformLabel } from "../tgu-planning/tguTimelineMapper";
import { DEFAULT_TGU_2D_MAP_LAYERS, type Tgu2DMapLayerKey, type Tgu2DMapLayersState } from "./model/mapLayerTypes";
import type { Tgu2DMapSelection } from "./model/mapSelectionTypes";
import { buildTgu2DMapScene, getLayerAvailability } from "./Tgu2DMapLayers";
import { Tgu2DMapDetails } from "./Tgu2DMapDetails";
import { Tgu2DMapSidebar } from "./Tgu2DMapSidebar";
import { Tgu2DMapToolbar } from "./Tgu2DMapToolbar";
import { Tgu2DMapView } from "./Tgu2DMapView";
import { getTgu2DMapIntervalState, TGU_2D_MAP_INTERVAL_WARNING } from "./tgu2DMapInterval";
type Tgu2DMapTabProps = {
range: TimelineRange;
invalidRange: boolean;
selectedSpacecraftId?: string;
selectedPlan?: TguPlanUi;
platforms: TguPlatformUi[];
onSelectSpacecraft: (spacecraftId?: string) => void;
};
export function Tgu2DMapTab({
range,
invalidRange,
selectedSpacecraftId,
selectedPlan,
platforms,
onSelectSpacecraft
}: Tgu2DMapTabProps) {
const [layers, setLayers] = useState<Tgu2DMapLayersState>(DEFAULT_TGU_2D_MAP_LAYERS);
const [search, setSearch] = useState("");
const [redrawToken, setRedrawToken] = useState(0);
const [selectedObject, setSelectedObject] = useState<Tgu2DMapSelection>();
const satelliteId = selectedPlan?.spacecraftId ?? selectedSpacecraftId;
const mapInterval = getTgu2DMapIntervalState(range, selectedPlan);
const mapInvalidRange = invalidRange || mapInterval.tooLarge;
const platform = useMemo(
() => platforms.find((item) => item.spacecraftId === satelliteId),
[platforms, satelliteId]
);
const satelliteLabel = satelliteId ? platformLabel(platform, satelliteId) : "КА не выбран";
const intervalLabel = `${formatDate(mapInterval.range.fromMs)} - ${formatDate(mapInterval.range.toMs)}`;
const scene = useMemo(
() => buildTgu2DMapScene({ range: mapInterval.range, selectedPlan, selectedSpacecraftId: satelliteId, platforms }),
[mapInterval.range, platforms, satelliteId, selectedPlan]
);
const layerAvailability = useMemo(() => getLayerAvailability(layers, scene), [layers, scene]);
const overlayMessage = !satelliteId
? "Выберите КА в sidebar или план на timeline."
: invalidRange
? "Некорректный интервал: from должен быть меньше to."
: mapInterval.tooLarge
? TGU_2D_MAP_INTERVAL_WARNING
: undefined;
const toggleLayer = (key: Tgu2DMapLayerKey) => {
setLayers((current) => ({ ...current, [key]: !current[key] }));
};
return (
<main className="tgu-map-workspace">
<Tgu2DMapToolbar
satelliteLabel={satelliteLabel}
intervalLabel={intervalLabel}
layers={layers}
onRefresh={() => setRedrawToken((value) => value + 1)}
onToggleLayer={toggleLayer}
/>
<div className="tgu-map-content">
<Tgu2DMapSidebar
platforms={platforms}
selectedSpacecraftId={satelliteId}
selectedPlanId={selectedPlan?.planId}
layers={layers}
search={search}
layerAvailability={layerAvailability}
onSearchChange={setSearch}
onSelectSpacecraft={onSelectSpacecraft}
onToggleLayer={toggleLayer}
/>
<section className="tgu-map-stage">
{overlayMessage && <div className="tgu-map-overlay">{overlayMessage}</div>}
<Tgu2DMapView
scene={mapInvalidRange ? { polygons: [], lines: [], markers: [] } : scene}
layers={layers}
redrawToken={redrawToken}
onObjectSelect={setSelectedObject}
/>
</section>
</div>
<div className="tgu-map-errors">
{Object.entries(layerAvailability).map(([layer, message]) =>
message ? (
<div className="tgu-alert tgu-alert--warning" key={layer}>
{layerLabel(layer as Tgu2DMapLayerKey)}: {message}
</div>
) : null
)}
</div>
<Tgu2DMapDetails selectedPlan={selectedPlan} selectedObject={selectedObject} />
</main>
);
}
function formatDate(ms: number): string {
return new Date(ms).toLocaleString("ru-RU", {
day: "2-digit",
month: "2-digit",
hour: "2-digit",
minute: "2-digit"
});
}
function layerLabel(layer: Tgu2DMapLayerKey): string {
switch (layer) {
case "tracks":
return "Трасса КА";
case "swath":
return "Полоса обзора";
case "planWorks":
return "Работы плана";
case "stations":
return "Станции";
case "spacecraftMarkers":
return "Маркеры КА";
}
}
@@ -0,0 +1,44 @@
import { TGU_2D_MAP_LAYERS, type Tgu2DMapLayersState } from "./model/mapLayerTypes";
type Tgu2DMapToolbarProps = {
satelliteLabel: string;
intervalLabel: string;
layers: Tgu2DMapLayersState;
onRefresh: () => void;
onToggleLayer: (key: keyof Tgu2DMapLayersState) => void;
};
export function Tgu2DMapToolbar({
satelliteLabel,
intervalLabel,
layers,
onRefresh,
onToggleLayer
}: Tgu2DMapToolbarProps) {
return (
<div className="tgu-map-toolbar">
<div>
<div className="tgu-map-toolbar__title">Карта 2D</div>
<div className="tgu-map-toolbar__subtitle">
{satelliteLabel} · {intervalLabel}
</div>
</div>
<div className="tgu-map-toolbar__toggles">
{TGU_2D_MAP_LAYERS.map((layer) => (
<button
className={layers[layer.key] ? "is-active" : ""}
type="button"
key={layer.key}
onClick={() => onToggleLayer(layer.key)}
>
{layer.label}
</button>
))}
</div>
<button className="tgu-button" type="button" onClick={onRefresh}>
Перерисовать
</button>
</div>
);
}
@@ -0,0 +1,305 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { drawBaseMap } from "./canvas/drawBaseMap";
import { drawPlanWorks } from "./canvas/drawPlanWorks";
import { drawStations } from "./canvas/drawStations";
import { drawSwath } from "./canvas/drawSwath";
import { drawTracks } from "./canvas/drawTracks";
import { createMapProjection } from "./geometry/mapProjection";
import { createPolygonSpatialIndex, hitTestPolygons, queryPolygonsByBBox } from "./geometry/spatialIndex";
import type { Tgu2DMapLayersState } from "./model/mapLayerTypes";
import type { MapPolygon, MapSize, MapViewState, ScreenPoint, Tgu2DMapScene } from "./model/mapTypes";
import type { Tgu2DMapSelection } from "./model/mapSelectionTypes";
type Tgu2DMapViewProps = {
scene: Tgu2DMapScene;
layers: Tgu2DMapLayersState;
redrawToken: number;
onObjectSelect: (selection?: Tgu2DMapSelection) => void;
};
type DragState = {
startX: number;
startY: number;
centerAtStart: {
lon: number;
lat: number;
};
};
type TooltipState = {
x: number;
y: number;
title: string;
subtitle: string;
};
const INITIAL_VIEW: MapViewState = {
centerLon: 45,
centerLat: 30,
zoom: 2
};
export function Tgu2DMapView({ scene, layers, redrawToken, onObjectSelect }: Tgu2DMapViewProps) {
const wrapperRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const dragRef = useRef<DragState | undefined>(undefined);
const hoverRef = useRef<MapPolygon | undefined>(undefined);
const [size, setSize] = useState<MapSize>({ width: 1000, height: 560 });
const [view, setView] = useState<MapViewState>(INITIAL_VIEW);
const [tooltip, setTooltip] = useState<TooltipState>();
const selectedPolygonRef = useRef<MapPolygon | undefined>(undefined);
const activePolygons = useMemo(
() => scene.polygons.filter((polygon) => layers[polygon.layer]),
[scene.polygons, layers]
);
const polygonIndex = useMemo(() => createPolygonSpatialIndex(activePolygons), [activePolygons]);
const projection = useMemo(() => createMapProjection(view, size), [view, size]);
const visiblePolygons = useMemo(() => {
const topLeft = projection.unproject({ x: 0, y: 0 });
const bottomRight = projection.unproject({ x: size.width, y: size.height });
const minLon = Math.min(topLeft.lon, bottomRight.lon);
const maxLon = Math.max(topLeft.lon, bottomRight.lon);
if (maxLon - minLon > 300) {
return activePolygons;
}
return queryPolygonsByBBox(polygonIndex, {
minLon,
maxLon,
minLat: Math.min(topLeft.lat, bottomRight.lat),
maxLat: Math.max(topLeft.lat, bottomRight.lat)
});
}, [activePolygons, polygonIndex, projection, size.height, size.width]);
useEffect(() => {
const element = wrapperRef.current;
if (!element) return;
const resizeObserver = new ResizeObserver(() => {
setSize({
width: Math.max(320, element.clientWidth),
height: Math.max(320, element.clientHeight)
});
});
resizeObserver.observe(element);
setSize({
width: Math.max(320, element.clientWidth),
height: Math.max(320, element.clientHeight)
});
return () => resizeObserver.disconnect();
}, []);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const frameId = window.requestAnimationFrame(() => {
const dpr = Math.min(2, window.devicePixelRatio || 1);
canvas.width = Math.floor(size.width * dpr);
canvas.height = Math.floor(size.height * dpr);
canvas.style.width = `${size.width}px`;
canvas.style.height = `${size.height}px`;
const context = canvas.getContext("2d");
if (!context) return;
context.setTransform(dpr, 0, 0, dpr, 0, 0);
context.clearRect(0, 0, size.width, size.height);
drawBaseMap(context, projection, size);
if (layers.swath) {
drawSwath(context, visiblePolygons.filter((polygon) => polygon.layer === "swath"), projection, size);
}
if (layers.planWorks) {
drawPlanWorks(context, visiblePolygons.filter((polygon) => polygon.layer === "planWorks"), projection, size);
}
if (layers.tracks) {
drawTracks(context, scene.lines.filter((line) => layers[line.layer]), projection);
}
if (layers.stations) {
drawStations(context, scene.markers.filter((marker) => marker.layer === "stations"), projection);
}
});
return () => window.cancelAnimationFrame(frameId);
}, [layers, projection, redrawToken, scene.lines, scene.markers, size, visiblePolygons]);
const screenPointFromEvent = useCallback((event: React.PointerEvent | React.MouseEvent): ScreenPoint => {
const bounds = wrapperRef.current?.getBoundingClientRect();
return {
x: bounds ? event.clientX - bounds.left : 0,
y: bounds ? event.clientY - bounds.top : 0
};
}, []);
const updateHover = useCallback(
(event: React.PointerEvent) => {
const screenPoint = screenPointFromEvent(event);
const polygon = hitTestPolygons(polygonIndex, projection.unproject(screenPoint));
if (polygon?.id === hoverRef.current?.id) return;
hoverRef.current = polygon;
if (!polygon) {
setTooltip(undefined);
return;
}
setTooltip({
x: screenPoint.x + 12,
y: screenPoint.y + 12,
title: polygon.name,
subtitle: layerTitle(polygon.layer)
});
},
[polygonIndex, projection, screenPointFromEvent]
);
const onPointerDown = (event: React.PointerEvent) => {
const screenPoint = screenPointFromEvent(event);
dragRef.current = {
startX: screenPoint.x,
startY: screenPoint.y,
centerAtStart: { lon: view.centerLon, lat: view.centerLat }
};
wrapperRef.current?.setPointerCapture(event.pointerId);
};
const onPointerMove = (event: React.PointerEvent) => {
const drag = dragRef.current;
if (!drag) {
updateHover(event);
return;
}
const screenPoint = screenPointFromEvent(event);
const dx = screenPoint.x - drag.startX;
const dy = screenPoint.y - drag.startY;
const dragProjection = createMapProjection(
{
centerLon: drag.centerAtStart.lon,
centerLat: drag.centerAtStart.lat,
zoom: view.zoom
},
size
);
const nextCenter = dragProjection.unproject({ x: size.width / 2 - dx, y: size.height / 2 - dy });
setView((current) => ({
...current,
centerLon: nextCenter.lon,
centerLat: Math.max(-82, Math.min(82, nextCenter.lat))
}));
};
const onPointerUp = (event: React.PointerEvent) => {
dragRef.current = undefined;
wrapperRef.current?.releasePointerCapture(event.pointerId);
};
const onClick = (event: React.MouseEvent) => {
const screenPoint = screenPointFromEvent(event);
const polygon = hitTestPolygons(polygonIndex, projection.unproject(screenPoint));
selectedPolygonRef.current = polygon;
if (!polygon) {
onObjectSelect(undefined);
return;
}
onObjectSelect({
type: "polygon",
id: polygon.id,
name: polygon.name,
layer: polygon.layer,
planId: polygon.planId,
spacecraftId: polygon.spacecraftId,
status: polygon.status
});
};
const onWheel = (event: React.WheelEvent) => {
event.preventDefault();
const screenPoint = screenPointFromEvent(event);
const before = projection.unproject(screenPoint);
const nextZoom = Math.max(0.8, Math.min(8, view.zoom + (event.deltaY < 0 ? 0.35 : -0.35)));
const nextProjection = createMapProjection({ ...view, zoom: nextZoom }, size);
const after = nextProjection.unproject(screenPoint);
setView((current) => ({
...current,
zoom: nextZoom,
centerLon: current.centerLon + before.lon - after.lon,
centerLat: Math.max(-82, Math.min(82, current.centerLat + before.lat - after.lat))
}));
};
const selectedPolygon = selectedPolygonRef.current;
const overlayPolygons = uniquePolygons([selectedPolygon, hoverRef.current]);
return (
<div
className="tgu-2d-map"
ref={wrapperRef}
onClick={onClick}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
onPointerLeave={() => {
dragRef.current = undefined;
hoverRef.current = undefined;
setTooltip(undefined);
}}
onWheel={onWheel}
>
<canvas className="tgu-2d-map__canvas" ref={canvasRef} />
<svg className="tgu-2d-map__overlay" width={size.width} height={size.height} aria-hidden="true">
{overlayPolygons.map((polygon) => (
<polygon
key={polygon.id}
points={polygon.points.map((point) => {
const screen = projection.project(point);
return `${screen.x},${screen.y}`;
}).join(" ")}
className={polygon.id === selectedPolygon?.id ? "is-selected" : "is-hovered"}
/>
))}
</svg>
{tooltip && (
<div className="tgu-2d-map__tooltip" style={{ left: tooltip.x, top: tooltip.y }}>
<div>{tooltip.title}</div>
<span>{tooltip.subtitle}</span>
</div>
)}
<div className="tgu-2d-map__attribution">Схематичная основа · Web Mercator</div>
<div className="tgu-2d-map__zoom">
<button type="button" onClick={() => setView((current) => ({ ...current, zoom: Math.min(8, current.zoom + 0.5) }))}>+</button>
<button type="button" onClick={() => setView((current) => ({ ...current, zoom: Math.max(0.8, current.zoom - 0.5) }))}>-</button>
<button type="button" onClick={() => setView(INITIAL_VIEW)}></button>
</div>
</div>
);
}
function layerTitle(layer: MapPolygon["layer"]): string {
switch (layer) {
case "planWorks":
return "Работы плана";
case "swath":
return "Полоса обзора";
case "tracks":
return "Трасса КА";
}
}
function uniquePolygons(polygons: Array<MapPolygon | undefined>): MapPolygon[] {
const result: MapPolygon[] = [];
const ids = new Set<string>();
for (const polygon of polygons) {
if (!polygon || ids.has(polygon.id)) continue;
ids.add(polygon.id);
result.push(polygon);
}
return result;
}
@@ -0,0 +1,70 @@
import type { MapProjection } from "../geometry/mapProjection";
import type { MapSize } from "../model/mapTypes";
const LAND: Record<string, [number, number][]> = {
"Сев. Америка": [[-166, 66], [-150, 70], [-125, 70], [-95, 72], [-70, 67], [-56, 53], [-52, 47], [-70, 41], [-81, 31], [-80, 25], [-97, 22], [-114, 31], [-124, 48], [-145, 60], [-164, 60]],
"Юж. Америка": [[-78, 9], [-60, 10], [-51, 4], [-43, -3], [-37, -13], [-55, -34], [-70, -52], [-74, -49], [-71, -18], [-80, -6], [-79, 3]],
"Африка": [[-16, 15], [-6, 27], [10, 37], [32, 31], [43, 12], [51, 12], [41, -3], [35, -18], [22, -34], [14, -24], [9, -1], [-9, 5], [-14, 9]],
"Европа": [[-10, 37], [-9, 43], [2, 51], [6, 62], [21, 70], [30, 67], [42, 63], [38, 58], [24, 56], [14, 54], [-4, 48], [-9, 40]],
"Азия": [[42, 63], [68, 72], [95, 77], [125, 73], [160, 70], [180, 66], [165, 60], [143, 53], [127, 38], [121, 31], [110, 21], [100, 7], [88, 22], [73, 17], [62, 25], [47, 38], [58, 55], [45, 60]],
"Австралия": [[114, -22], [114, -32], [129, -32], [147, -38], [153, -31], [146, -18], [136, -12], [125, -14], [122, -18]],
"Антарктида": [[-180, -72], [-150, -75], [-90, -72], [-30, -72], [30, -69], [90, -66], [150, -70], [180, -72], [180, -85], [-180, -85]]
};
export function drawBaseMap(ctx: CanvasRenderingContext2D, projection: MapProjection, size: MapSize) {
const gradient = ctx.createLinearGradient(0, 0, 0, size.height);
gradient.addColorStop(0, "#0b1717");
gradient.addColorStop(1, "#08100f");
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, size.width, size.height);
drawGrid(ctx, projection);
drawLand(ctx, projection);
}
function drawGrid(ctx: CanvasRenderingContext2D, projection: MapProjection) {
ctx.save();
ctx.strokeStyle = "rgba(116, 160, 154, 0.14)";
ctx.lineWidth = 1;
ctx.beginPath();
for (let lon = -180; lon <= 180; lon += 30) {
const top = projection.project({ lon, lat: 82 });
const bottom = projection.project({ lon, lat: -82 });
ctx.moveTo(top.x, top.y);
ctx.lineTo(bottom.x, bottom.y);
}
for (let lat = -60; lat <= 60; lat += 30) {
const left = projection.project({ lon: -180, lat });
const right = projection.project({ lon: 180, lat });
ctx.moveTo(left.x, left.y);
ctx.lineTo(right.x, right.y);
}
ctx.stroke();
ctx.restore();
}
function drawLand(ctx: CanvasRenderingContext2D, projection: MapProjection) {
ctx.save();
for (const polygon of Object.values(LAND)) {
ctx.beginPath();
polygon.forEach(([lon, lat], index) => {
const point = projection.project({ lon, lat });
if (index === 0) {
ctx.moveTo(point.x, point.y);
} else {
ctx.lineTo(point.x, point.y);
}
});
ctx.closePath();
ctx.fillStyle = "#173128";
ctx.fill();
ctx.strokeStyle = "rgba(139, 210, 180, 0.42)";
ctx.lineWidth = 0.9;
ctx.stroke();
}
ctx.restore();
}
@@ -0,0 +1,45 @@
import { statusStyle } from "../../tgu-planning/tguStatus";
import type { MapProjection } from "../geometry/mapProjection";
import type { MapPolygon } from "../model/mapTypes";
export function drawPlanWorks(
ctx: CanvasRenderingContext2D,
polygons: MapPolygon[],
projection: MapProjection,
viewport: { width: number; height: number }
) {
ctx.save();
for (const polygon of polygons) {
if (polygon.points.length < 3) continue;
const screenPoints = polygon.points.map((point) => projection.project(point));
if (!screenPoints.some((point) => point.x >= -32 && point.x <= viewport.width + 32 && point.y >= -32 && point.y <= viewport.height + 32)) {
continue;
}
const color = statusStyle(polygon.status || "PLANNED").color;
ctx.beginPath();
screenPoints.forEach((point, index) => {
if (index === 0) {
ctx.moveTo(point.x, point.y);
} else {
ctx.lineTo(point.x, point.y);
}
});
ctx.closePath();
ctx.fillStyle = hexToRgba(color, 0.2);
ctx.strokeStyle = hexToRgba(color, 0.72);
ctx.lineWidth = 1.2;
ctx.fill();
ctx.stroke();
}
ctx.restore();
}
function hexToRgba(hex: string, alpha: number): string {
const value = hex.replace("#", "");
const red = Number.parseInt(value.slice(0, 2), 16);
const green = Number.parseInt(value.slice(2, 4), 16);
const blue = Number.parseInt(value.slice(4, 6), 16);
return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
}
@@ -0,0 +1,21 @@
import type { MapProjection } from "../geometry/mapProjection";
import type { MapMarker } from "../model/mapTypes";
export function drawStations(ctx: CanvasRenderingContext2D, markers: MapMarker[], projection: MapProjection) {
ctx.save();
for (const marker of markers) {
const point = projection.project(marker.point);
ctx.beginPath();
ctx.moveTo(point.x, point.y - 6);
ctx.lineTo(point.x + 5, point.y + 4);
ctx.lineTo(point.x - 5, point.y + 4);
ctx.closePath();
ctx.fillStyle = "rgba(213, 226, 226, 0.9)";
ctx.strokeStyle = "#08100f";
ctx.lineWidth = 1;
ctx.fill();
ctx.stroke();
}
ctx.restore();
}
@@ -0,0 +1,13 @@
import type { MapProjection } from "../geometry/mapProjection";
import type { MapPolygon } from "../model/mapTypes";
import { drawPlanWorks } from "./drawPlanWorks";
export function drawSwath(
ctx: CanvasRenderingContext2D,
polygons: MapPolygon[],
projection: MapProjection,
viewport: { width: number; height: number }
) {
drawPlanWorks(ctx, polygons, projection, viewport);
}
@@ -0,0 +1,27 @@
import type { MapProjection } from "../geometry/mapProjection";
import type { MapLine } from "../model/mapTypes";
export function drawTracks(ctx: CanvasRenderingContext2D, lines: MapLine[], projection: MapProjection) {
ctx.save();
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.strokeStyle = "rgba(104, 195, 189, 0.76)";
ctx.lineWidth = 1.2;
for (const line of lines) {
if (line.points.length < 2) continue;
ctx.beginPath();
line.points.forEach((point, index) => {
const screen = projection.project(point);
if (index === 0) {
ctx.moveTo(screen.x, screen.y);
} else {
ctx.lineTo(screen.x, screen.y);
}
});
ctx.stroke();
}
ctx.restore();
}
@@ -0,0 +1,38 @@
import type { GeoPoint } from "../model/mapTypes";
export type BBox = {
minLon: number;
minLat: number;
maxLon: number;
maxLat: number;
};
export function bboxForPoints(points: GeoPoint[]): BBox {
let minLon = Number.POSITIVE_INFINITY;
let minLat = Number.POSITIVE_INFINITY;
let maxLon = Number.NEGATIVE_INFINITY;
let maxLat = Number.NEGATIVE_INFINITY;
for (const point of points) {
minLon = Math.min(minLon, point.lon);
minLat = Math.min(minLat, point.lat);
maxLon = Math.max(maxLon, point.lon);
maxLat = Math.max(maxLat, point.lat);
}
return { minLon, minLat, maxLon, maxLat };
}
export function bboxContainsPoint(bbox: BBox, point: GeoPoint): boolean {
return (
point.lon >= bbox.minLon &&
point.lon <= bbox.maxLon &&
point.lat >= bbox.minLat &&
point.lat <= bbox.maxLat
);
}
export function bboxIntersects(a: BBox, b: BBox): boolean {
return a.minLon <= b.maxLon && a.maxLon >= b.minLon && a.minLat <= b.maxLat && a.maxLat >= b.minLat;
}
@@ -0,0 +1,67 @@
import type { GeoPoint, MapSize, MapViewState, ScreenPoint } from "../model/mapTypes";
const TILE_SIZE = 256;
const MAX_LAT = 85.0511;
const DEG = Math.PI / 180;
const RAD = 180 / Math.PI;
export type MapProjection = {
worldSize: number;
project: (point: GeoPoint) => ScreenPoint;
unproject: (point: ScreenPoint) => GeoPoint;
metersPerPixel: (lat: number) => number;
};
export function createMapProjection(view: MapViewState, size: MapSize): MapProjection {
const worldSize = TILE_SIZE * 2 ** view.zoom;
const centerX = lonToWorldX(view.centerLon, worldSize);
const centerY = latToWorldY(view.centerLat, worldSize);
return {
worldSize,
project(point) {
let worldX = lonToWorldX(point.lon, worldSize);
const halfWorld = worldSize / 2;
while (worldX - centerX > halfWorld) worldX -= worldSize;
while (worldX - centerX < -halfWorld) worldX += worldSize;
return {
x: size.width / 2 + (worldX - centerX),
y: size.height / 2 + (latToWorldY(point.lat, worldSize) - centerY)
};
},
unproject(point) {
return {
lon: normalizeLon(worldXToLon(centerX + (point.x - size.width / 2), worldSize)),
lat: worldYToLat(centerY + (point.y - size.height / 2), worldSize)
};
},
metersPerPixel(lat) {
return (Math.cos(lat * DEG) * 2 * Math.PI * 6378137) / worldSize;
}
};
}
export function lonToWorldX(lon: number, worldSize: number): number {
return ((normalizeLon(lon) + 180) / 360) * worldSize;
}
export function latToWorldY(lat: number, worldSize: number): number {
const clamped = Math.max(-MAX_LAT, Math.min(MAX_LAT, lat));
const sin = Math.sin(clamped * DEG);
return (0.5 - Math.log((1 + sin) / (1 - sin)) / (4 * Math.PI)) * worldSize;
}
export function worldXToLon(worldX: number, worldSize: number): number {
return (worldX / worldSize) * 360 - 180;
}
export function worldYToLat(worldY: number, worldSize: number): number {
const n = Math.PI - (2 * Math.PI * worldY) / worldSize;
return RAD * Math.atan(Math.sinh(n));
}
export function normalizeLon(lon: number): number {
return ((((lon + 180) % 360) + 360) % 360) - 180;
}
@@ -0,0 +1,21 @@
import type { GeoPoint } from "../model/mapTypes";
export function pointInPolygon(point: GeoPoint, polygon: GeoPoint[]): boolean {
if (polygon.length < 3) return false;
let inside = false;
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
const pi = polygon[i]!;
const pj = polygon[j]!;
const intersects =
pi.lat > point.lat !== pj.lat > point.lat &&
point.lon < ((pj.lon - pi.lon) * (point.lat - pi.lat)) / (pj.lat - pi.lat) + pi.lon;
if (intersects) {
inside = !inside;
}
}
return inside;
}
@@ -0,0 +1,22 @@
import type { GeoPoint } from "../model/mapTypes";
export function simplifyGeometry(points: GeoPoint[], minPixelDistance: number, project: (point: GeoPoint) => { x: number; y: number }): GeoPoint[] {
if (points.length <= 2 || minPixelDistance <= 0) return points;
const result: GeoPoint[] = [];
let previousScreenPoint = project(points[0]!);
result.push(points[0]!);
for (let index = 1; index < points.length - 1; index += 1) {
const point = points[index]!;
const screenPoint = project(point);
if (Math.hypot(screenPoint.x - previousScreenPoint.x, screenPoint.y - previousScreenPoint.y) >= minPixelDistance) {
result.push(point);
previousScreenPoint = screenPoint;
}
}
result.push(points[points.length - 1]!);
return result;
}
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import { createPolygonSpatialIndex, hitTestPolygons, queryPolygonsByBBox } from "./spatialIndex";
import type { MapPolygon } from "../model/mapTypes";
const bottom: MapPolygon = {
id: "bottom",
name: "Bottom polygon",
kind: "planWork",
layer: "planWorks",
zIndex: 1,
points: [
{ lon: 0, lat: 0 },
{ lon: 10, lat: 0 },
{ lon: 10, lat: 10 },
{ lon: 0, lat: 10 }
]
};
const top: MapPolygon = {
id: "top",
name: "Top polygon",
kind: "planWork",
layer: "planWorks",
zIndex: 2,
points: [
{ lon: 5, lat: 5 },
{ lon: 15, lat: 5 },
{ lon: 15, lat: 15 },
{ lon: 5, lat: 15 }
]
};
describe("polygon spatial index", () => {
it("uses bbox candidates and returns the top polygon by zIndex", () => {
const index = createPolygonSpatialIndex([bottom, top]);
expect(hitTestPolygons(index, { lon: 6, lat: 6 })?.id).toBe("top");
expect(hitTestPolygons(index, { lon: 2, lat: 2 })?.id).toBe("bottom");
expect(hitTestPolygons(index, { lon: 20, lat: 20 })).toBeUndefined();
});
it("queries polygons by bbox", () => {
const index = createPolygonSpatialIndex([bottom, top]);
expect(queryPolygonsByBBox(index, { minLon: 11, minLat: 11, maxLon: 12, maxLat: 12 }).map((polygon) => polygon.id))
.toEqual(["top"]);
});
});
@@ -0,0 +1,44 @@
import { bboxContainsPoint, bboxForPoints, type BBox } from "./bbox";
import { pointInPolygon } from "./pointInPolygon";
import type { GeoPoint, MapPolygon } from "../model/mapTypes";
export type IndexedPolygon = {
polygon: MapPolygon;
bbox: BBox;
};
export type PolygonSpatialIndex = {
entries: IndexedPolygon[];
};
export function createPolygonSpatialIndex(polygons: MapPolygon[]): PolygonSpatialIndex {
return {
entries: polygons.map((polygon) => ({
polygon,
bbox: bboxForPoints(polygon.points)
}))
};
}
export function hitTestPolygons(index: PolygonSpatialIndex, point: GeoPoint): MapPolygon | undefined {
let selected: MapPolygon | undefined;
for (const entry of index.entries) {
if (!bboxContainsPoint(entry.bbox, point)) continue;
if (!pointInPolygon(point, entry.polygon.points)) continue;
if (!selected || entry.polygon.zIndex >= selected.zIndex) {
selected = entry.polygon;
}
}
return selected;
}
export function queryPolygonsByBBox(index: PolygonSpatialIndex, bbox: BBox): MapPolygon[] {
return index.entries
.filter((entry) => entry.bbox.minLon <= bbox.maxLon && entry.bbox.maxLon >= bbox.minLon)
.filter((entry) => entry.bbox.minLat <= bbox.maxLat && entry.bbox.maxLat >= bbox.minLat)
.map((entry) => entry.polygon);
}
@@ -0,0 +1,46 @@
export type Tgu2DMapLayerKey = "tracks" | "swath" | "planWorks" | "stations" | "spacecraftMarkers";
export type Tgu2DMapLayersState = Record<Tgu2DMapLayerKey, boolean>;
export type Tgu2DMapLayerDefinition = {
key: Tgu2DMapLayerKey;
label: string;
description: string;
};
export const TGU_2D_MAP_LAYERS: Tgu2DMapLayerDefinition[] = [
{
key: "tracks",
label: "Трасса КА",
description: "Проекция движения КА на поверхность."
},
{
key: "swath",
label: "Полоса обзора",
description: "Массовые полосы обзора через canvas."
},
{
key: "planWorks",
label: "Работы плана",
description: "Полигоны работ выбранного плана."
},
{
key: "stations",
label: "Станции",
description: "Наземные станции приёма."
},
{
key: "spacecraftMarkers",
label: "Маркеры КА",
description: "Текущие или расчётные позиции КА."
}
];
export const DEFAULT_TGU_2D_MAP_LAYERS: Tgu2DMapLayersState = {
tracks: true,
swath: true,
planWorks: true,
stations: true,
spacecraftMarkers: true
};
@@ -0,0 +1,20 @@
import type { TguPlanStatus } from "../../../model/tguTypes";
export type Tgu2DMapSelection =
| {
type: "polygon";
id: string;
name: string;
layer: "planWorks" | "swath" | "tracks";
planId?: string;
spacecraftId?: string;
status?: TguPlanStatus;
}
| {
type: "marker";
id: string;
name: string;
layer: "stations" | "spacecraftMarkers";
spacecraftId?: string;
};
@@ -0,0 +1,57 @@
import type { TguPlanStatus } from "../../../model/tguTypes";
export type GeoPoint = {
lon: number;
lat: number;
};
export type ScreenPoint = {
x: number;
y: number;
};
export type MapSize = {
width: number;
height: number;
};
export type MapViewState = {
centerLon: number;
centerLat: number;
zoom: number;
};
export type MapPolygon = {
id: string;
name: string;
kind: "planWork" | "swath" | "trackZone";
layer: "planWorks" | "swath" | "tracks";
points: GeoPoint[];
status?: TguPlanStatus;
planId?: string;
spacecraftId?: string;
zIndex: number;
};
export type MapLine = {
id: string;
layer: "tracks";
points: GeoPoint[];
spacecraftId?: string;
zIndex: number;
};
export type MapMarker = {
id: string;
layer: "stations" | "spacecraftMarkers";
point: GeoPoint;
label: string;
zIndex: number;
};
export type Tgu2DMapScene = {
polygons: MapPolygon[];
lines: MapLine[];
markers: MapMarker[];
};
@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import type { TguPlanUi } from "../../model/timelineTypes";
import { DEFAULT_TGU_2D_MAP_LAYERS } from "./model/mapLayerTypes";
import { getTgu2DMapIntervalState } from "./tgu2DMapInterval";
function plan(startTime: string, endTime: string): TguPlanUi {
return {
planId: "plan-1",
spacecraftId: "56756",
startTime,
endTime,
kppId: "KPP-1",
status: "PLANNED",
startMs: Date.parse(startTime),
endMs: Date.parse(endTime)
};
}
describe("tgu2DMapInterval", () => {
it("marks common map interval longer than 7 days as too large", () => {
const state = getTgu2DMapIntervalState({
fromMs: Date.parse("2026-05-29T00:00:00"),
toMs: Date.parse("2026-06-05T00:00:01")
});
expect(state.tooLarge).toBe(true);
});
it("uses selected plan interval without the common interval restriction", () => {
const selectedPlan = plan("2026-05-29T01:00:00", "2026-06-07T02:00:00");
const state = getTgu2DMapIntervalState(
{
fromMs: Date.parse("2026-05-29T00:00:00"),
toMs: Date.parse("2026-06-10T00:00:00")
},
selectedPlan
);
expect(state).toEqual({
range: {
fromMs: selectedPlan.startMs,
toMs: selectedPlan.endMs
},
tooLarge: false
});
});
it("enables all requested 2D map layers by default", () => {
expect(DEFAULT_TGU_2D_MAP_LAYERS).toEqual({
tracks: true,
swath: true,
planWorks: true,
stations: true,
spacecraftMarkers: true
});
});
});
@@ -0,0 +1,29 @@
import type { TguPlanUi, TimelineRange } from "../../model/timelineTypes";
export const MAX_TGU_2D_MAP_INTERVAL_MS = 7 * 24 * 60 * 60 * 1000;
export const TGU_2D_MAP_INTERVAL_WARNING =
"Интервал карты больше 7 суток. Для тяжёлых canvas-слоёв выберите меньший интервал или конкретный план.";
export type Tgu2DMapIntervalState = {
range: TimelineRange;
tooLarge: boolean;
};
export function getTgu2DMapIntervalState(range: TimelineRange, selectedPlan?: TguPlanUi): Tgu2DMapIntervalState {
if (selectedPlan) {
return {
range: {
fromMs: selectedPlan.startMs,
toMs: selectedPlan.endMs
},
tooLarge: false
};
}
return {
range,
tooLarge: range.toMs - range.fromMs > MAX_TGU_2D_MAP_INTERVAL_MS
};
}
@@ -2,20 +2,14 @@ import type { ReactNode } from "react";
type TguPlanningLayoutProps = {
toolbar: ReactNode;
sidebar: ReactNode;
timeline: ReactNode;
details: ReactNode;
children: ReactNode;
};
export function TguPlanningLayout({ toolbar, sidebar, timeline, details }: TguPlanningLayoutProps) {
export function TguPlanningLayout({ toolbar, children }: TguPlanningLayoutProps) {
return (
<div className="tgu-app-shell">
{toolbar}
<main className="tgu-workspace">
{sidebar}
<section className="tgu-center">{timeline}</section>
{details}
</main>
{children}
</div>
);
}
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { fetchPlans, fetchPlatforms, sendPlanDecision } from "../../api/tguApi";
import type { TguPlanDecision, TguPlatform } from "../../model/tguTypes";
import type { TguPlanUi, TimelineRange } from "../../model/timelineTypes";
import { Tgu2DMapTab } from "../tgu-map-2d/Tgu2DMapTab";
import { TguPlanDetails } from "./TguPlanDetails";
import { TguPlanningLayout } from "./TguPlanningLayout";
import { TguSidebar } from "./TguSidebar";
@@ -24,6 +25,7 @@ export function TguPlanningPage() {
const [search, setSearch] = useState("");
const [selectedSpacecraftId, setSelectedSpacecraftId] = useState<string>();
const [selectedPlanId, setSelectedPlanId] = useState<string>();
const [activeTab, setActiveTab] = useState<"timeline" | "map">("timeline");
const [decisionInFlight, setDecisionInFlight] = useState(false);
const [decisionNotice, setDecisionNotice] = useState<string>();
const [decisionError, setDecisionError] = useState<string>();
@@ -146,7 +148,9 @@ export function TguPlanningPage() {
fromValue={fromValue}
toValue={toValue}
loading={loading}
activeTab={activeTab}
error={pageError}
onTabChange={setActiveTab}
onFromChange={setFromValue}
onToChange={setToValue}
onApply={applyRange}
@@ -154,40 +158,60 @@ export function TguPlanningPage() {
onRefresh={refresh}
/>
}
sidebar={
<TguSidebar
platforms={platforms}
>
{activeTab === "timeline" ? (
<main className="tgu-workspace">
<TguSidebar
platforms={platforms}
selectedSpacecraftId={selectedSpacecraftId}
search={search}
platformLoadFailed={platformLoadFailed}
onSearchChange={setSearch}
onSelectSpacecraft={(spacecraftId) => {
setSelectedSpacecraftId(spacecraftId);
setSelectedPlanId(undefined);
setDecisionNotice(undefined);
}}
/>
<section className="tgu-center">
<TguTimeline
rows={rows}
range={appliedRange}
invalidRange={invalidRange}
selectedPlanId={selectedPlanId}
onSelectPlan={(planId) => {
setSelectedPlanId(planId);
const plan = plans.find((item) => item.planId === planId);
if (plan) {
setSelectedSpacecraftId(plan.spacecraftId);
}
}}
/>
</section>
<TguPlanDetails
plan={selectedPlan}
decisionInFlight={decisionInFlight}
notice={decisionNotice}
error={decisionError}
onDecision={handleDecision}
onClose={() => setSelectedPlanId(undefined)}
/>
</main>
) : (
<Tgu2DMapTab
range={appliedRange}
invalidRange={invalidRange}
selectedSpacecraftId={selectedSpacecraftId}
search={search}
platformLoadFailed={platformLoadFailed}
onSearchChange={setSearch}
selectedPlan={selectedPlan}
platforms={platforms}
onSelectSpacecraft={(spacecraftId) => {
setSelectedSpacecraftId(spacecraftId);
setSelectedPlanId(undefined);
setDecisionNotice(undefined);
}}
/>
}
timeline={
<TguTimeline
rows={rows}
range={appliedRange}
invalidRange={invalidRange}
selectedPlanId={selectedPlanId}
onSelectPlan={setSelectedPlanId}
/>
}
details={
<TguPlanDetails
plan={selectedPlan}
decisionInFlight={decisionInFlight}
notice={decisionNotice}
error={decisionError}
onDecision={handleDecision}
onClose={() => setSelectedPlanId(undefined)}
/>
}
/>
)}
</TguPlanningLayout>
);
}
@@ -136,7 +136,11 @@ export function TguTimeline({ rows, range, selectedPlanId, invalidRange, onSelec
})}
{segments.map((segment) => {
const y = TIMELINE_AXIS_HEIGHT + segment.rowIndex * TIMELINE_ROW_HEIGHT + TIMELINE_ROW_HEIGHT / 2;
const y =
TIMELINE_AXIS_HEIGHT +
segment.rowIndex * TIMELINE_ROW_HEIGHT +
TIMELINE_ROW_HEIGHT / 2 +
segment.laneOffset;
const x1 = TIMELINE_LABEL_WIDTH + segment.x;
const x2 = TIMELINE_LABEL_WIDTH + segment.x + segment.width;
const style = statusStyle(segment.plan.status);
@@ -176,8 +180,8 @@ export function TguTimeline({ rows, range, selectedPlanId, invalidRange, onSelec
}
function TimelineArrow({ from, to }: { from: TimelinePlanSegment; to: TimelinePlanSegment }) {
const y1 = TIMELINE_AXIS_HEIGHT + from.rowIndex * TIMELINE_ROW_HEIGHT + TIMELINE_ROW_HEIGHT / 2;
const y2 = TIMELINE_AXIS_HEIGHT + to.rowIndex * TIMELINE_ROW_HEIGHT + TIMELINE_ROW_HEIGHT / 2;
const y1 = TIMELINE_AXIS_HEIGHT + from.rowIndex * TIMELINE_ROW_HEIGHT + TIMELINE_ROW_HEIGHT / 2 + from.laneOffset;
const y2 = TIMELINE_AXIS_HEIGHT + to.rowIndex * TIMELINE_ROW_HEIGHT + TIMELINE_ROW_HEIGHT / 2 + to.laneOffset;
const x1 = TIMELINE_LABEL_WIDTH + from.x + from.width + 4;
const x2 = TIMELINE_LABEL_WIDTH + to.x - 6;
const mid = x1 + Math.max(18, (x2 - x1) / 2);
@@ -4,7 +4,9 @@ type TguToolbarProps = {
fromValue: string;
toValue: string;
loading: boolean;
activeTab: "timeline" | "map";
error?: string;
onTabChange: (tab: "timeline" | "map") => void;
onFromChange: (value: string) => void;
onToChange: (value: string) => void;
onApply: () => void;
@@ -16,7 +18,9 @@ export function TguToolbar({
fromValue,
toValue,
loading,
activeTab,
error,
onTabChange,
onFromChange,
onToChange,
onApply,
@@ -34,6 +38,27 @@ export function TguToolbar({
</div>
</div>
<div className="tgu-tabs" role="tablist" aria-label="Разделы планирования ТГУ">
<button
className={activeTab === "timeline" ? "is-active" : ""}
type="button"
role="tab"
aria-selected={activeTab === "timeline"}
onClick={() => onTabChange("timeline")}
>
Timeline
</button>
<button
className={activeTab === "map" ? "is-active" : ""}
type="button"
role="tab"
aria-selected={activeTab === "map"}
onClick={() => onTabChange("map")}
>
Карта 2D
</button>
</div>
<label className="tgu-field">
<span>С</span>
<input type="datetime-local" value={fromValue} onChange={(event) => onFromChange(event.target.value)} />
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import type { TguPlanUi, TimelineRow } from "../../model/timelineTypes";
import { buildSequentialLinks, clipPlanToRange, timeToX } from "./tguTimelineLayout";
import { buildSequentialLinks, buildTimelineSegments, clipPlanToRange, timeToX } from "./tguTimelineLayout";
const range = {
fromMs: Date.parse("2026-05-29T00:00:00"),
@@ -55,4 +55,29 @@ describe("tguTimelineLayout", () => {
{ spacecraftId: "56756", fromPlanId: "p1", toPlanId: "p2" }
]);
});
it("places sequential plans on alternating timeline lanes", () => {
const rows: TimelineRow[] = [
{
spacecraftId: "56756",
plans: [
plan("p1", "2026-05-29T01:00:00", "2026-05-29T02:00:00"),
plan("p2", "2026-05-29T03:00:00", "2026-05-29T04:00:00"),
plan("p3", "2026-05-29T05:00:00", "2026-05-29T06:00:00"),
plan("p4", "2026-05-29T07:00:00", "2026-05-29T08:00:00"),
plan("p5", "2026-05-29T09:00:00", "2026-05-29T10:00:00"),
plan("p6", "2026-05-29T11:00:00", "2026-05-29T12:00:00")
]
}
];
expect(buildTimelineSegments(rows, range, 1200).map((segment) => segment.laneOffset)).toEqual([
0,
-12,
0,
12,
0,
-12
]);
});
});
@@ -4,6 +4,7 @@ export const TIMELINE_LABEL_WIDTH = 220;
export const TIMELINE_ROW_HEIGHT = 58;
export const TIMELINE_AXIS_HEIGHT = 46;
export const TIMELINE_BOTTOM_PADDING = 18;
export const TIMELINE_LANE_STEP = 12;
export function timeToX(timeMs: number, range: TimelineRange, timelineWidth: number): number {
return ((timeMs - range.fromMs) / (range.toMs - range.fromMs)) * timelineWidth;
@@ -28,25 +29,32 @@ export function buildTimelineSegments(
const segments: TimelinePlanSegment[] = [];
rows.forEach((row, rowIndex) => {
for (const plan of row.plans) {
const clipped = clipPlanToRange(plan, range);
if (!clipped) continue;
const visiblePlans = row.plans
.map((plan) => ({ plan, clipped: clipPlanToRange(plan, range) }))
.filter((item): item is { plan: TguPlanUi; clipped: { startMs: number; endMs: number } } => item.clipped !== null)
.sort((a, b) => a.plan.startMs - b.plan.startMs || a.plan.endMs - b.plan.endMs);
const x = timeToX(clipped.startMs, range, timelineWidth);
const x2 = timeToX(clipped.endMs, range, timelineWidth);
visiblePlans.forEach((item, planIndex) => {
const x = timeToX(item.clipped.startMs, range, timelineWidth);
const x2 = timeToX(item.clipped.endMs, range, timelineWidth);
segments.push({
plan,
plan: item.plan,
rowIndex,
laneOffset: timelineLaneOffset(planIndex),
x,
x2,
width: Math.max(3, x2 - x)
});
}
});
});
return segments;
}
export function timelineLaneOffset(index: number): number {
return [0, -1, 0, 1][index % 4] * TIMELINE_LANE_STEP;
}
export function buildSequentialLinks(rows: TimelineRow[]): TimelineLink[] {
const links: TimelineLink[] = [];
@@ -31,6 +31,7 @@ export type TimelineRow = {
export type TimelinePlanSegment = {
plan: TguPlanUi;
rowIndex: number;
laneOffset: number;
x: number;
x2: number;
width: number;
@@ -111,6 +111,30 @@
background: rgba(214, 69, 69, 0.14);
}
.tgu-tabs {
display: inline-flex;
gap: 0.15rem;
align-self: center;
border: 1px solid var(--line);
border-radius: 7px;
padding: 0.18rem;
background: #101214;
}
.tgu-tabs button {
min-height: 1.85rem;
border: 0;
border-radius: 5px;
padding: 0.28rem 0.75rem;
background: transparent;
color: var(--text-muted);
}
.tgu-tabs button.is-active {
background: var(--bg-elevated);
color: var(--accent-strong);
}
.tgu-legend {
display: flex;
align-items: center;
@@ -555,6 +579,376 @@
font-size: 0.82rem;
}
.tgu-map-workspace {
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto auto;
min-width: 0;
min-height: 0;
background: #121516;
}
.tgu-map-toolbar {
display: flex;
align-items: center;
gap: 0.85rem;
min-height: 3.4rem;
padding: 0.7rem 0.9rem;
border-bottom: 1px solid var(--line);
background: var(--bg-panel);
}
.tgu-map-toolbar__title {
font-size: 0.92rem;
font-weight: 700;
}
.tgu-map-toolbar__subtitle {
margin-top: 0.12rem;
color: var(--text-dim);
font-size: 0.75rem;
}
.tgu-map-toolbar__toggles {
display: flex;
flex: 1;
flex-wrap: wrap;
gap: 0.35rem;
}
.tgu-map-toolbar__toggles button {
min-height: 1.8rem;
border: 1px solid var(--line);
border-radius: 999px;
padding: 0.25rem 0.7rem;
background: #101214;
color: var(--text-muted);
}
.tgu-map-toolbar__toggles button.is-active {
border-color: rgba(104, 195, 189, 0.58);
background: rgba(104, 195, 189, 0.16);
color: var(--accent-strong);
}
.tgu-map-content {
display: grid;
grid-template-columns: 19rem minmax(0, 1fr);
min-width: 0;
min-height: 0;
}
.tgu-map-panel {
min-height: 0;
overflow: auto;
border-right: 1px solid var(--line);
background: var(--bg-panel);
}
.tgu-map-panel__section {
display: grid;
gap: 0.45rem;
padding: 0.85rem 0.9rem;
border-bottom: 1px solid var(--line-soft);
}
.tgu-map-panel__label {
color: var(--text-dim);
font-size: 0.68rem;
font-weight: 700;
text-transform: uppercase;
}
.tgu-map-panel__value {
overflow-wrap: anywhere;
color: var(--text);
font-size: 0.84rem;
}
.tgu-map-layer-list {
display: grid;
gap: 0.45rem;
}
.tgu-map-layer {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 0.55rem;
align-items: start;
padding: 0.55rem 0.6rem;
border: 1px solid var(--line-soft);
border-radius: 7px;
background: #111416;
}
.tgu-map-layer input {
margin-top: 0.18rem;
accent-color: var(--accent);
}
.tgu-map-layer__title,
.tgu-map-layer__description {
display: block;
}
.tgu-map-layer__title {
font-size: 0.82rem;
font-weight: 650;
}
.tgu-map-layer__description {
margin-top: 0.14rem;
color: var(--text-dim);
font-size: 0.72rem;
line-height: 1.3;
}
.tgu-map-layer__warning {
display: block;
margin-top: 0.24rem;
color: #ffe0a3;
font-size: 0.7rem;
line-height: 1.3;
}
.tgu-map-spacecraft-list {
display: grid;
gap: 0.35rem;
max-height: 18rem;
overflow: auto;
padding-top: 0.55rem;
}
.tgu-map-spacecraft {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.55rem;
align-items: center;
width: 100%;
border: 1px solid transparent;
border-radius: 6px;
padding: 0.5rem 0.55rem;
background: transparent;
color: var(--text-muted);
text-align: left;
}
.tgu-map-spacecraft.is-selected {
border-color: rgba(104, 195, 189, 0.46);
background: rgba(104, 195, 189, 0.12);
color: var(--text);
}
.tgu-map-spacecraft__name,
.tgu-map-spacecraft__meta {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tgu-map-spacecraft__name {
font-size: 0.8rem;
font-weight: 650;
}
.tgu-map-spacecraft__meta {
margin-top: 0.12rem;
color: var(--text-dim);
font-size: 0.68rem;
}
.tgu-map-status-legend {
display: grid;
gap: 0.32rem;
color: var(--text-muted);
font-size: 0.72rem;
}
.tgu-map-status-legend span {
display: inline-flex;
align-items: center;
gap: 0.4rem;
}
.tgu-map-status-legend i {
display: inline-block;
width: 1rem;
height: 0.42rem;
border-radius: 999px;
}
.tgu-map-stage {
position: relative;
min-width: 0;
min-height: 0;
overflow: hidden;
background: #0b0d0e;
}
.tgu-2d-map {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
min-height: 28rem;
cursor: grab;
overflow: hidden;
user-select: none;
touch-action: none;
}
.tgu-2d-map:active {
cursor: grabbing;
}
.tgu-2d-map__canvas,
.tgu-2d-map__overlay {
position: absolute;
inset: 0;
}
.tgu-2d-map__canvas {
width: 100%;
height: 100%;
}
.tgu-2d-map__overlay {
overflow: visible;
pointer-events: none;
}
.tgu-2d-map__overlay polygon {
fill: rgba(104, 195, 189, 0.16);
stroke: rgba(130, 222, 216, 0.9);
stroke-width: 1.6;
}
.tgu-2d-map__overlay polygon.is-selected {
fill: rgba(240, 173, 46, 0.2);
stroke: #f0ad2e;
stroke-width: 2;
}
.tgu-2d-map__tooltip {
position: absolute;
z-index: 8;
max-width: 15rem;
border: 1px solid var(--line);
border-radius: 6px;
padding: 0.45rem 0.55rem;
background: rgba(16, 18, 20, 0.94);
box-shadow: var(--shadow);
color: var(--text);
pointer-events: none;
}
.tgu-2d-map__tooltip div {
font-size: 0.78rem;
font-weight: 700;
}
.tgu-2d-map__tooltip span {
display: block;
margin-top: 0.14rem;
color: var(--text-dim);
font-family: "SFMono-Regular", Consolas, monospace;
font-size: 0.64rem;
text-transform: uppercase;
}
.tgu-2d-map__attribution {
position: absolute;
left: 0.55rem;
bottom: 0.5rem;
border-radius: 4px;
padding: 0.18rem 0.4rem;
background: rgba(8, 16, 15, 0.68);
color: var(--text-dim);
font-family: "SFMono-Regular", Consolas, monospace;
font-size: 0.62rem;
pointer-events: none;
}
.tgu-2d-map__zoom {
position: absolute;
right: 0.75rem;
bottom: 0.75rem;
display: grid;
gap: 0.25rem;
}
.tgu-2d-map__zoom button {
display: grid;
place-items: center;
width: 1.9rem;
height: 1.9rem;
border: 1px solid var(--line);
border-radius: 6px;
background: var(--bg-panel);
color: var(--text);
font-weight: 700;
}
.tgu-map-overlay {
position: absolute;
z-index: 5;
top: 0.85rem;
left: 0.85rem;
max-width: 23rem;
border: 1px solid rgba(240, 173, 46, 0.42);
border-radius: 7px;
padding: 0.65rem 0.75rem;
background: rgba(17, 19, 21, 0.88);
color: #ffe7b5;
font-size: 0.82rem;
backdrop-filter: blur(6px);
}
.tgu-map-errors {
display: grid;
gap: 0.4rem;
padding: 0.6rem 0.9rem 0;
}
.tgu-map-errors:empty {
display: none;
}
.tgu-map-details {
display: grid;
grid-template-columns: 1.4fr 1fr 0.9fr 1fr 0.8fr;
gap: 0.75rem;
padding: 0.75rem 0.9rem;
border-top: 1px solid var(--line);
background: var(--bg-panel);
}
.tgu-map-details__label {
margin-bottom: 0.18rem;
color: var(--text-dim);
font-size: 0.66rem;
font-weight: 700;
text-transform: uppercase;
}
.tgu-map-details__value {
overflow-wrap: anywhere;
color: var(--text);
font-size: 0.82rem;
}
.tgu-map-status-pill {
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.tgu-map-status-pill i {
width: 0.95rem;
height: 0.42rem;
border-radius: 999px;
}
@media (max-width: 1100px) {
.tgu-workspace {
grid-template-columns: 16rem minmax(0, 1fr);
@@ -566,4 +960,18 @@
border-left: 0;
border-top: 1px solid var(--line);
}
.tgu-map-content {
grid-template-columns: 1fr;
}
.tgu-map-panel {
max-height: 18rem;
border-right: 0;
border-bottom: 1px solid var(--line);
}
.tgu-map-details {
grid-template-columns: 1fr 1fr;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,17 @@
[
{"id":"4ae3e206-03ee-410e-aae5-96eb69435557","businessKey":"2013-030A","data":{"name":"Resurs-P No.1","orbit":{"type":"SSO","inclination_deg":97.27},"status":"LOST","country":"RU","mass_kg":5920,"mission":"RESURS_P","norad_id":39186,"operator":"Roscosmos / RSC Progress","decay_date":"2021-12-19","designator":"2013-030A","launch_date":"2013-06-25","spectrum_bands":["VIS","NIR"],"satellite_type_code":"OPT_HIRES_VIS"},"dataScope":"PUBLIC","validFrom":"2026-05-07T21:20:51.710841Z","validTo":"9999-12-31T23:59:59Z","_meta":{"locale":"en-US","defaultLocale":"ru-RU","asOf":"2026-05-25T14:37:45.200356844Z"}},
{"id":"7307c2f2-c92c-44fa-b864-10ffad2fe88c","businessKey":"2014-085A","data":{"name":"Resurs-P No.2","orbit":{"type":"SSO","inclination_deg":97.27},"status":"LOST","country":"RU","mass_kg":5920,"mission":"RESURS_P","norad_id":40360,"operator":"Roscosmos / RSC Progress","decay_date":"2022-04-13","designator":"2014-085A","launch_date":"2014-12-26","satellite_type_code":"OPT_HIRES_VIS"},"dataScope":"PUBLIC","validFrom":"2026-05-07T21:20:51.710841Z","validTo":"9999-12-31T23:59:59Z","_meta":{"locale":"en-US","defaultLocale":"ru-RU","asOf":"2026-05-25T14:37:45.200356844Z"}},
{"id":"17080530-8d5b-47bd-ab9c-f7151d492578","businessKey":"2026-Emissio1","data":{"name":"Emissio 1","orbit":{"type":"SSO","inclination_deg":97.5},"status":"OPERATIONAL","country":"RU","mass_kg":495,"norad_id":2,"operator":"НСТАРТ","designator":"2026-001E","spectrum_bands":["S_BAND"],"satellite_type_code":"OPT_HIRES_VIS"},"dataScope":"PUBLIC","validFrom":"2026-05-21T13:59:00Z","validTo":"9999-12-31T23:59:59Z","_meta":{"locale":"en-US","defaultLocale":"ru-RU","asOf":"2026-05-25T14:37:45.200356844Z"}},
{"id":"2bc048fc-7788-46fd-b368-37ee1b8b1a7a","businessKey":"2026-Reflexio1","data":{"name":"Reflexio 1","orbit":{"type":"SSO","inclination_deg":97.5},"status":"OPERATIONAL","country":"RU","mass_kg":495,"norad_id":30,"operator":"НСТАРТ","designator":"2026-001R","spectrum_bands":["S_BAND"],"satellite_type_code":"OPT_HIRES_VIS"},"dataScope":"PUBLIC","validFrom":"2026-05-21T13:59:00Z","validTo":"9999-12-31T23:59:59Z","_meta":{"locale":"en-US","defaultLocale":"ru-RU","asOf":"2026-05-25T14:37:45.200356844Z"}},
{"id":"933bbfd1-774b-440e-aa83-256936129e0a","businessKey":"2016-016A","data":{"name":"Resurs-P No.3","orbit":{"type":"SSO","inclination_deg":97.28},"status":"STANDBY","country":"RU","mass_kg":5920,"mission":"RESURS_P","norad_id":41386,"operator":"Roscosmos / RSC Progress","designator":"2016-016A","launch_date":"2016-03-13","spectrum_bands":["VIS","NIR"],"satellite_type_code":"OPT_HIRES_VIS"},"dataScope":"PUBLIC","validFrom":"2026-05-21T15:05:00Z","validTo":"9999-12-31T23:59:59Z","_meta":{"locale":"en-US","defaultLocale":"ru-RU","asOf":"2026-05-25T14:37:45.200356844Z"}},
{"id":"da0bed3a-5557-452d-a235-53ca26080bca","businessKey":"2024-046A","data":{"name":"Resurs-P No.4","orbit":{"type":"SSO","inclination_deg":97.28},"status":"STANDBY","country":"RU","mass_kg":5920,"mission":"RESURS_P","operator":"Roscosmos / RSC Progress","designator":"2024-046A","launch_date":"2024-03-31","spectrum_bands":["VIS","NIR"],"satellite_type_code":"OPT_HIRES_VIS"},"dataScope":"PUBLIC","validFrom":"2026-05-21T15:05:00Z","validTo":"9999-12-31T23:59:59Z","_meta":{"locale":"en-US","defaultLocale":"ru-RU","asOf":"2026-05-25T14:37:45.200356844Z"}},
{"id":"5293be80-4107-4bed-a8f1-4aeb8d833e03","businessKey":"1999-068A","data":{"name":"Terra (EOS AM-1)","orbit":{"type":"SSO","eccentricity":1.0E-4,"inclination_deg":98.2,"semimajor_axis_km":7083},"status":"STANDBY","country":"US","mass_kg":4864,"mission":"TERRA_AQUA","norad_id":25994,"operator":"NASA","designator":"1999-068A","launch_date":"1999-12-18","spectrum_bands":["VIS","NIR","SWIR","TIR"],"satellite_type_code":"OPT_MIDRES_MS"},"dataScope":"PUBLIC","validFrom":"2026-05-21T15:05:00Z","validTo":"9999-12-31T23:59:59Z","_meta":{"locale":"en-US","defaultLocale":"ru-RU","asOf":"2026-05-25T14:37:45.200356844Z"}},
{"id":"1435cdcc-0b81-4761-9b4e-a4e991160156","businessKey":"2021-016A","data":{"name":"Arktika-M No.1","orbit":{"type":"HEO"},"status":"STANDBY","country":"RU","mass_kg":2200,"mission":"ARKTIKA_M","operator":"Roshydromet / Lavochkin Association","designator":"2021-016A","launch_date":"2021-02-28","spectrum_bands":["VIS","NIR","MWIR","TIR"],"satellite_type_code":"OPT_MIDRES_MS"},"dataScope":"PUBLIC","validFrom":"2026-05-21T15:05:00Z","validTo":"9999-12-31T23:59:59Z","_meta":{"locale":"en-US","defaultLocale":"ru-RU","asOf":"2026-05-25T14:37:45.200356844Z"}},
{"id":"906b8317-72c7-436e-a582-d4e8df774430","businessKey":"2015-074A","data":{"name":"Elektro-L No.2","orbit":{"type":"GEO"},"status":"STANDBY","country":"RU","mass_kg":1740,"mission":"ELEKTRO_L","operator":"Roshydromet / Lavochkin Association","designator":"2015-074A","launch_date":"2015-12-11","spectrum_bands":["VIS","NIR","MWIR","TIR"],"satellite_type_code":"OPT_MIDRES_MS"},"dataScope":"PUBLIC","validFrom":"2026-05-21T15:06:00Z","validTo":"9999-12-31T23:59:59Z","_meta":{"locale":"en-US","defaultLocale":"ru-RU","asOf":"2026-05-25T14:37:45.200356844Z"}},
{"id":"2cf4b26e-f44c-43eb-9155-9e412df8d0ba","businessKey":"2019-038A","data":{"name":"Meteor-M No.2-2","orbit":{"type":"SSO","inclination_deg":98.83},"status":"STANDBY","country":"RU","mass_kg":2900,"mission":"METEOR_M","operator":"Roshydromet / VNIIEM","designator":"2019-038A","launch_date":"2019-07-05","satellite_type_code":"OPT_MIDRES_MS"},"dataScope":"PUBLIC","validFrom":"2026-05-21T15:06:00Z","validTo":"9999-12-31T23:59:59Z","_meta":{"locale":"en-US","defaultLocale":"ru-RU","asOf":"2026-05-25T14:37:45.200356844Z"}},
{"id":"ea2e466c-3c8a-4e2d-8c0e-d0634e7cda53","businessKey":"2014-037A","data":{"name":"Meteor-M No.2","orbit":{"type":"SSO","inclination_deg":98.86},"status":"STANDBY","country":"RU","mass_kg":2900,"mission":"METEOR_M","operator":"Roshydromet / VNIIEM","designator":"2014-037A","launch_date":"2014-07-08","spectrum_bands":["VIS","NIR","SWIR","TIR"],"satellite_type_code":"OPT_MIDRES_MS"},"dataScope":"PUBLIC","validFrom":"2026-05-21T15:06:00Z","validTo":"9999-12-31T23:59:59Z","_meta":{"locale":"en-US","defaultLocale":"ru-RU","asOf":"2026-05-25T14:37:45.200356844Z"}},
{"id":"99c1d6d2-0e0d-4cb5-a7a8-7e8f934e22c2","businessKey":"2017-076A","data":{"name":"Kanopus-V No.5 (Grand)","orbit":{"type":"SSO","inclination_deg":97.45},"status":"STANDBY","country":"RU","mass_kg":473,"mission":"KANOPUS_V","operator":"Roscosmos / VNIIEM","designator":"2017-076A","launch_date":"2017-12-01","satellite_type_code":"OPT_HIRES_VIS"},"dataScope":"PUBLIC","validFrom":"2026-05-21T15:06:00Z","validTo":"9999-12-31T23:59:59Z","_meta":{"locale":"en-US","defaultLocale":"ru-RU","asOf":"2026-05-25T14:37:45.200356844Z"}},
{"id":"5381755f-2397-4863-b4f3-55301e73b27a","businessKey":"2012-039A","data":{"name":"Kanopus-V No.1","orbit":{"type":"SSO","inclination_deg":97.43},"status":"STANDBY","country":"RU","mass_kg":473,"mission":"KANOPUS_V","norad_id":38707,"operator":"Roscosmos / VNIIEM","designator":"2012-039A","launch_date":"2012-07-22","spectrum_bands":["VIS","NIR"],"satellite_type_code":"OPT_HIRES_VIS"},"dataScope":"PUBLIC","validFrom":"2026-05-21T15:06:00Z","validTo":"9999-12-31T23:59:59Z","_meta":{"locale":"en-US","defaultLocale":"ru-RU","asOf":"2026-05-25T14:37:45.200356844Z"}},
{"id":"f0fc5aad-5b4a-49b0-9ee6-0ab79327a7e6","businessKey":"2024-149E","data":{"name":"Kondor-FKA No.2","orbit":{"type":"SSO","inclination_deg":97.5},"status":"STANDBY","country":"RU","mass_kg":1050,"mission":"KONDOR_FKA","norad_id":56756,"operator":"Roscosmos","designator":"2024-149E","launch_date":"2024-08-13","spectrum_bands":["S_BAND"],"satellite_type_code":"SAR_X_HIRES"},"dataScope":"PUBLIC","validFrom":"2026-05-21T15:06:00Z","validTo":"9999-12-31T23:59:59Z","_meta":{"locale":"en-US","defaultLocale":"ru-RU","asOf":"2026-05-25T14:37:45.200356844Z"}},
{"id":"54fbf999-b1c0-4868-a51c-eb7191f1e867","businessKey":"2014-016A","data":{"name":"Sentinel-1A","orbit":{"type":"SSO","eccentricity":1.0E-4,"inclination_deg":98.18,"semimajor_axis_km":7064},"status":"STANDBY","country":"FR","mass_kg":2300,"mission":"SENTINEL_1","norad_id":39634,"operator":"ESA / Copernicus","designator":"2014-016A","launch_date":"2014-04-03","spectrum_bands":["C_BAND"],"satellite_type_code":"SAR_X_HIRES"},"dataScope":"PUBLIC","validFrom":"2026-05-21T15:07:00Z","validTo":"9999-12-31T23:59:59Z","_meta":{"locale":"en-US","defaultLocale":"ru-RU","asOf":"2026-05-25T14:37:45.200356844Z"}},
{"id":"d5cf2788-cec5-43a1-9bda-ab3e8ec5c4aa","businessKey":"2015-028A","data":{"name":"Sentinel-2A","orbit":{"type":"SSO","inclination_deg":98.62,"semimajor_axis_km":7167},"status":"STANDBY","country":"FR","mass_kg":1140,"mission":"SENTINEL_2","norad_id":40697,"operator":"ESA / Copernicus","designator":"2015-028A","launch_date":"2015-06-23","spectrum_bands":["VIS","NIR","SWIR"],"satellite_type_code":"OPT_MIDRES_MS"},"dataScope":"PUBLIC","validFrom":"2026-05-21T15:07:00Z","validTo":"9999-12-31T23:59:59Z","_meta":{"locale":"en-US","defaultLocale":"ru-RU","asOf":"2026-05-25T14:37:45.200356844Z"}}]
@@ -7,7 +7,7 @@ spring:
import: "configserver:"
cloud:
config:
uri: ${CONFIG_SERVER_URI:http://192.168.60.201:8888}
uri: ${CONFIG_SERVER_URI:http://192.168.1.8: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}
@@ -7,7 +7,7 @@ spring:
import: "configserver:"
cloud:
config:
uri: ${CONFIG_SERVER_URI:http://192.168.60.201:8888}
uri: ${CONFIG_SERVER_URI:http://192.168.1.8: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}
@@ -7,7 +7,7 @@ spring:
import: "configserver:"
cloud:
config:
uri: ${CONFIG_SERVER_URI:http://192.168.60.201:8888}
uri: ${CONFIG_SERVER_URI:http://192.168.1.8: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}
@@ -7,7 +7,7 @@ spring:
import: "configserver:"
cloud:
config:
uri: ${CONFIG_SERVER_URI:http://192.168.60.201:8888}
uri: ${CONFIG_SERVER_URI:http://192.168.1.8: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}