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.
@@ -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/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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 "$@"
|
||||
@@ -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
|
||||
@@ -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 "$@"
|
||||
@@ -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`.*
|
||||
|
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>
|
||||
@@ -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,
|
||||
};
|
||||
})();
|
||||
@@ -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.4–99.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,
|
||||
});
|
||||
|
After Width: | Height: | Size: 218 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
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 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 });
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
.vite
|
||||
npm-debug.log*
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||