Новый сервис pcp-tgu-ui-service: ТГУ-фронт на headless-auth nstart + платформенный CI/деплой

This commit is contained in:
Дмитрий Соловьев
2026-06-09 13:35:13 +03:00
parent 0b617594fd
commit 505bf1f31c
129 changed files with 21640 additions and 0 deletions
@@ -0,0 +1,4 @@
node_modules
dist
.vite
npm-debug.log*
+23
View File
@@ -0,0 +1,23 @@
# DEV (npm run dev): Vite экспонирует в import.meta.env ТОЛЬКО переменные с
# префиксом VITE_ — их читает src/config/env.ts на этапе сборки. Поэтому локально
# настраиваем именно VITE_* (APP_* Vite игнорирует).
#
# PROD (Docker): /config.js генерируется из APP_*-переменных контейнера скриптом
# docker-entrypoint.d/20-nstart-runtime-config.sh (window.__NSTART_APP_ENV__).
# Прод-контракт — APP_* (APP_API_URL / APP_AUTH_MODE / APP_NEW_START_ID_URL /
# APP_NEW_START_ID_REALM / APP_NEW_START_ID_CLIENT_ID); .env в проде не используется.
# Скопируйте в .env для локального запуска: cp .env.example .env
VITE_API_URL=/api
# 'anonymous' — сервис открывается сразу, без Keycloak (как ТГУ работал в дизайнере).
# Для закрытого контура: VITE_AUTH_MODE=new-start-id и заполнить параметры ниже
# реальным адресом Keycloak (НЕ :8080 — там Kafka-UI).
VITE_AUTH_MODE=anonymous
# VITE_AUTH_MODE=new-start-id
# Используется только при authMode=new-start-id.
VITE_NEW_START_ID_URL=http://localhost:8080/auth
VITE_NEW_START_ID_REALM=new-start
VITE_NEW_START_ID_CLIENT_ID=pcp-tgu-ui-service
+6
View File
@@ -0,0 +1,6 @@
dist
node_modules
.npm
.npm-cache
.env
.DS_Store
+132
View File
@@ -0,0 +1,132 @@
stages:
- build
- verify
- push
default:
image: repo.nstart.cloud/nstart/node:24-trixie-slim
cache:
paths:
- .npm/
install:
stage: build
script:
- npm ci --cache .npm --prefer-offline
artifacts:
expire_in: 1h
paths:
- node_modules/
oci:
stage: build
image: repo.nstart.cloud/nstart/docker-cli-buildx:1.0.0
services:
- name: repo.nstart.cloud/library/docker:29.1.2-dind
alias: docker
command: ["--tls=false"]
variables:
DOCKER_HOST: tcp://docker:2375
DOCKER_TLS_CERTDIR: ""
script:
- |
export VERSION=$(jq -r '.version' package.json)
export IMAGE_NAME=$CI_PROJECT_NAME
docker bake save
docker bake app --print \
| jq -r '.target.app.tags[]' \
> dist/tags.txt
artifacts:
paths:
- dist/
expire_in: 1 day
trivy:
stage: verify
image:
name: repo.nstart.cloud/aquasec/trivy:0.69.3
entrypoint: [""]
needs:
- job: oci
artifacts: true
variables:
TRIVY_CACHE_DIR: .trivycache
cache:
paths:
- .trivycache/
script:
- |
mkdir -p dist/oci-layout
OCI_TAR="$(find dist -maxdepth 1 -type f -name '*.tar' -print -quit)"
tar -xf "$OCI_TAR" -C dist/oci-layout
trivy image --input "dist/oci-layout" \
--format table \
--table-mode detailed \
--scanners vuln,secret,misconfig \
--image-config-scanners misconfig,secret \
--pkg-types os,library \
--severity HIGH,CRITICAL \
--ignore-unfixed \
--exit-code 1
build:
stage: verify
needs: ["install"]
script:
- npm run build
semgrep:
stage: verify
image: repo.nstart.cloud/semgrep/semgrep:1.160.0
script:
- semgrep scan --config auto --error .
.push_oci:
image:
name: repo.nstart.cloud/skopeo/stable:latest
entrypoint: [""]
script:
- |
skopeo login "$REGISTRY" -u "$REGISTRY_USER" -p "$REGISTRY_PASSWORD"
OCI_TAR="$(find dist -maxdepth 1 -type f -name '*.tar' -print -quit)"
while IFS= read -r DEST_TAG; do
[ -n "$DEST_TAG" ] || continue
skopeo copy --dest-tls-verify=false \
"oci-archive:${OCI_TAR}:latest" \
"docker://${DEST_TAG}"
done < dist/tags.txt
manual:
stage: push
extends: .push_oci
needs:
- job: oci
artifacts: true
rules:
- if: $CI_COMMIT_TAG
when: manual
allow_failure: true
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
when: manual
allow_failure: true
- when: never
manual_confirmation: "Вы уверены, что хотите запустить push в registry?"
auto:
stage: push
extends: .push_oci
needs:
- job: oci
artifacts: true
- job: trivy
rules:
- if: $CI_COMMIT_TAG
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
- when: never
@@ -0,0 +1,6 @@
node_modules/
dist/
build/
coverage/
storybook-static/
*.min.js
+30
View File
@@ -0,0 +1,30 @@
FROM repo.nstart.cloud/nstart/node:24-trixie-slim AS builder
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
COPY . .
RUN npm run build
FROM repo.nstart.cloud/library/nginx:alpine
RUN apk upgrade --no-cache
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --chmod=755 docker-entrypoint.d/20-nstart-runtime-config.sh /docker-entrypoint.d/20-nstart-runtime-config.sh
RUN sed -i '/^user /d; s|pid\s*/run/nginx.pid;|pid /tmp/nginx.pid;|' /etc/nginx/nginx.conf \
&& chown -R nginx:nginx /usr/share/nginx/html /var/cache/nginx /var/log/nginx
USER nginx
EXPOSE 8081
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD wget -qO- http://localhost:8081/healthz || exit 1
CMD ["nginx", "-g", "daemon off;"]
+56
View File
@@ -0,0 +1,56 @@
# pcp-tgu-ui-service
Фронтенд «ТГУ — Операции». Сервис собран **с нуля** на чистой базе исходного
`pcp-tgu-ops-ui` (React + Vite, своя тёмная тема), в которую добавлены только две
вещи с платформы NStart:
1. **Авторизация** — headless-ядро `@nstart/auth` (New Start ID / Keycloak): токен,
login, logout, refresh. **Без `@nstart/ui`** — UI-компоненты библиотеки
(ProfilePage/UserPanel) намеренно не подключены, экраны ТГУ остаются на своём UI.
2. **Сборка и деплой** — платформенный контракт: hardened `Dockerfile` (non-root,
healthcheck), runtime-config (`config.js` из `APP_*`), `docker-bake.hcl`,
`.gitlab-ci.yml` (build → trivy/semgrep → push в registry), `nginx.conf`.
В отличие от `pcp-tgu-ops-ui-template`, здесь нет демо-страниц шаблона, `@nstart/ui`,
Tailwind, i18n и борьбы тем — только рабочий ТГУ + auth + контур сборки/деплоя.
## Авторизация (headless)
- Режим выбирается переменной `authMode` (`anonymous` | `new-start-id`).
- `anonymous` (дефолт): приложение открывается сразу, токена нет, заголовок
`Authorization` не добавляется — поведение как у исходного сервиса.
- `new-start-id`: вход редиректом на New Start ID; токен подставляется в каждый
запрос `src/api/*` через мост [`apiFetch`](src/api/apiFetch.ts) +
[`AuthTokenBridge`](src/auth/appAuth.tsx). В шапке появляется кнопка «Выйти».
- Конфиг входа берётся из [`src/config/env.ts`](src/config/env.ts) (runtime `APP_*`
имеют приоритет над build `VITE_*`).
## Конфигурация окружения
| Назначение | DEV (`npm run dev`) | PROD (Docker) |
|---|---|---|
| источник | `.env` (`VITE_*`) | `APP_*` контейнера → `config.js` |
| api | `VITE_API_URL` | `APP_API_URL` |
| режим auth | `VITE_AUTH_MODE` | `APP_AUTH_MODE` |
| New Start ID | `VITE_NEW_START_ID_*` | `APP_NEW_START_ID_*` |
Доступ к приватному реестру `@nstart/*`: scope `@nstart:registry=...` должен быть в
`~/.npmrc` (или в конфиге CI-раннера). Проектный `.npmrc` задаёт только
`min-release-age=7`.
## Запуск
```bash
cp .env.example .env # режим anonymous, открывается сразу
npm install # нужен доступ к repo.nstart.cloud (scope @nstart)
npm run dev # http://localhost:5174
npm run build # tsc -b + vite build
npm run test # vitest
```
## Прод-прокси (осталось настроить)
В [`nginx.conf`](nginx.conf) активный `/api/` — заглушка (`127.0.0.1:8000`), а
блоки `pcp-*` закомментированы (зеркало `server.proxy` из `vite.config.ts`).
Подставьте реальные `UPSTREAM_*` кластера и раскомментируйте, либо вынесите
маршрутизацию на gateway/ingress.
@@ -0,0 +1,67 @@
variable "REGISTRY" {
default = "repo.nstart.cloud"
}
variable "NAMESPACE" {
default = "nstart"
}
variable "VERSION" {
default = "latest"
}
variable "IMAGE_NAME" {
default = "pcp-tgu-ui-service"
}
function "oci_labels" {
params = []
result = {
"org.opencontainers.image.version" = VERSION,
"org.opencontainers.image.vendor" = "New Start",
"org.opencontainers.image.title" = "ТГУ — Операции",
"org.opencontainers.image.description" = "ТГУ — Операции frontend application.",
"org.opencontainers.image.licenses" = "LicenseRef-Proprietary",
"org.opencontainers.image.ref.name" = VERSION
}
}
function "image_ref" {
params = [name]
result = REGISTRY != "" ? "${REGISTRY}/${NAMESPACE}/${name}" : "${NAMESPACE}/${name}"
}
target "common" {
platforms = ["linux/amd64"]
output = ["type=image"]
attest = [
"type=sbom,generator=${REGISTRY}/docker/buildkit-syft-scanner:stable-1",
"type=provenance,mode=max"
]
}
target "app" {
inherits = ["common"]
context = "."
dockerfile = "Dockerfile"
tags = [
"${image_ref(IMAGE_NAME)}:${VERSION}",
"${image_ref(IMAGE_NAME)}:latest"
]
labels = oci_labels()
}
target "save" {
inherits = ["app"]
output = [
"type=oci,dest=./dist/${NAMESPACE}-${IMAGE_NAME}-${VERSION}.tar"
]
}
group "all" {
targets = ["app"]
}
@@ -0,0 +1,16 @@
#!/bin/sh
set -eu
# Генерирует /config.js из APP_*-переменных контейнера (прод-контракт окружения).
# Читается фронтом как window.__NSTART_APP_ENV__ (см. src/config/env.ts), runtime
# имеет приоритет над build-значениями VITE_*. Тема не конфигурируется: у ТГУ
# фиксированная тёмная тема на :root (styles/theme.css).
cat > /usr/share/nginx/html/config.js <<EOF
window.__NSTART_APP_ENV__ = {
apiUrl: "${APP_API_URL:-}",
authMode: "${APP_AUTH_MODE:-}",
newStartIdUrl: "${APP_NEW_START_ID_URL:-}",
newStartIdRealm: "${APP_NEW_START_ID_REALM:-}",
newStartIdClientId: "${APP_NEW_START_ID_CLIENT_ID:-}"
}
EOF
@@ -0,0 +1,60 @@
import { chromium } from "playwright";
const BASE = "http://localhost:5174/";
const results = [];
const pass = (n, d = "") => { results.push(["PASS", n, d]); console.log("PASS ::", n, d); };
const fail = (n, d = "") => { results.push(["FAIL", n, d]); console.log("FAIL ::", n, d); };
const b = await chromium.launch({ headless: true });
const p = await (await b.newContext({ viewport: { width: 1600, height: 900 } })).newPage();
const consoleErrors = [];
p.on("console", (m) => { if (m.type() === "error") consoleErrors.push(m.text()); });
p.on("pageerror", (e) => consoleErrors.push("PAGEERROR: " + e.message));
try {
await p.goto(BASE, { waitUntil: "networkidle", timeout: 30000 });
await p.waitForTimeout(800);
// выбрать КА 56756 и открыть редактор
await p.getByText("NORAD 56756", { exact: false }).first().click();
await p.waitForTimeout(500);
await p.getByRole("tab", { name: "Редактор", exact: true }).click();
await p.waitForTimeout(700);
// контролы редактора
const shoot = p.getByRole("button", { name: "+ Съёмка", exact: true });
const drop = p.getByRole("button", { name: "+ Сброс", exact: true });
(await shoot.count()) > 0 && (await drop.count()) > 0
? pass("editor:controls", "+ Съёмка / + Сброс присутствуют")
: fail("editor:controls");
// режим «Сброс» -> ЗРВ реальных станций
await drop.click();
const rva = await p.waitForResponse((r) => /\/api\/satellites\/\d+\/rva/.test(r.url()), { timeout: 15000 }).catch(() => null);
await p.waitForTimeout(1000);
rva && rva.status() === 200
? pass("editor:drop-fetches-rva", `GET rva ${rva.status()}`)
: fail("editor:drop-fetches-rva", rva ? `status ${rva.status()}` : "не вызван");
const panelText = (await p.locator("aside, [class*='panel']").allInnerTexts().catch(() => [])).join(" ").replace(/\n+/g, " ");
const stationNames = ["Шпицберген", "Отрадное", "Байконур", "Ханты-Мансийск"];
const found = stationNames.filter((s) => panelText.includes(s));
found.length >= 2
? pass("editor:drop-real-stations", `зоны по станциям: ${found.join(", ")}`)
: fail("editor:drop-real-stations", `найдено станций: ${JSON.stringify(found)}`);
// зоны содержат виток + время (UTC-окно)
/виток\s*\d+/i.test(panelText) && /\d{2}:\d{2}[-]\d{2}:\d{2}/.test(panelText)
? pass("editor:drop-zone-format", "виток + временное окно показаны")
: fail("editor:drop-zone-format", panelText.slice(0, 200));
consoleErrors.length === 0 ? pass("no-console-errors") : fail("no-console-errors", JSON.stringify(consoleErrors.slice(0, 8)));
} catch (e) {
fail("SCRIPT", e.message);
console.log(e.stack);
} finally {
const npass = results.filter((r) => r[0] === "PASS").length;
const nfail = results.filter((r) => r[0] === "FAIL").length;
console.log(`\n=== EDITOR SUMMARY: ${npass} PASS / ${nfail} FAIL ===`);
await b.close();
process.exit(nfail > 0 ? 1 : 0);
}
@@ -0,0 +1,79 @@
// Проверка keep-alive: состояние вкладок не сбрасывается при переключении.
import { chromium } from "playwright";
const BASE = "http://localhost:5174/";
const results = [];
const pass = (n, d = "") => { results.push(["PASS", n, d]); console.log("PASS ::", n, d); };
const fail = (n, d = "") => { results.push(["FAIL", n, d]); console.log("FAIL ::", n, d); };
const b = await chromium.launch({ headless: true });
const ctx = await b.newContext({ viewport: { width: 1600, height: 900 } });
const p = await ctx.newPage();
const consoleErrors = [];
p.on("console", (m) => { if (m.type() === "error") consoleErrors.push(m.text()); });
p.on("pageerror", (e) => consoleErrors.push("PAGEERROR: " + e.message));
const tab = (name) => p.getByRole("tab", { name, exact: true });
// только видимая (активная) вкладка — карта присутствует и в Заявках (рисование полигона)
const mapToggle = (label) => p.locator(".tgu-map-toolbar__toggles button:visible", { hasText: label });
const isActive = async (loc) => ((await loc.getAttribute("class")) || "").includes("is-active");
try {
await p.goto(BASE, { waitUntil: "networkidle", timeout: 30000 });
await p.waitForTimeout(600);
// --- Карта: меняем UI-состояние (выключаем слой «Станции») ---
await tab("Карта").click();
await p.waitForTimeout(500);
const stationsToggle = mapToggle("Станции");
(await isActive(stationsToggle)) ? pass("map:stations-on-initially") : fail("map:stations-on-initially");
await stationsToggle.click();
await p.waitForTimeout(150);
(await isActive(stationsToggle)) === false
? pass("map:stations-toggled-off")
: fail("map:stations-toggled-off");
// --- Заявки: открываем форму создания (внутреннее состояние panelMode) ---
await tab("Заявки").click();
await p.waitForTimeout(400);
await p.getByRole("button", { name: "+ Создать заявку", exact: true }).click();
await p.waitForTimeout(250);
(await p.locator(".tgu-requests-form").count()) > 0
? pass("requests:create-form-open")
: fail("requests:create-form-open");
// --- уходим на Планы и возвращаемся ---
await tab("Планы").click();
await p.waitForTimeout(300);
// Карта: слой «Станции» по-прежнему выключен?
await tab("Карта").click();
await p.waitForTimeout(400);
(await isActive(mapToggle("Станции"))) === false
? pass("map:state-preserved", "слой «Станции» остался выключен")
: fail("map:state-preserved", "состояние слоёв сбросилось");
// Заявки: форма создания всё ещё открыта?
await tab("Заявки").click();
await p.waitForTimeout(400);
(await p.locator(".tgu-requests-form").count()) > 0
? pass("requests:state-preserved", "форма создания осталась открытой")
: fail("requests:state-preserved", "форма закрылась (состояние сброшено)");
// Скрытые вкладки не должны размонтироваться: в DOM присутствуют сразу несколько <main>
await tab("Планы").click();
await p.waitForTimeout(200);
const mains = await p.locator(".tgu-app-shell main").count();
mains >= 3
? pass("keep-alive:tabs-mounted", `<main> в DOM: ${mains}`)
: fail("keep-alive:tabs-mounted", `ожидалось >=3, найдено ${mains}`);
} catch (e) {
fail("EXCEPTION", e.message);
} finally {
if (consoleErrors.length) console.log("CONSOLE_ERRORS", JSON.stringify(consoleErrors, null, 2));
const failed = results.filter((r) => r[0] === "FAIL").length;
console.log(`\n=== ${results.length - failed}/${results.length} PASS, ${failed} FAIL ===`);
await b.close();
process.exit(failed ? 1 : 0);
}
@@ -0,0 +1,225 @@
import { chromium } from "playwright";
const BASE = "http://localhost:5174/";
const results = [];
const pass = (n, d = "") => { results.push(["PASS", n, d]); console.log("PASS ::", n, d); };
const fail = (n, d = "") => { results.push(["FAIL", n, d]); console.log("FAIL ::", n, d); };
const b = await chromium.launch({ headless: true });
const ctx = await b.newContext({ viewport: { width: 1600, height: 900 } });
const p = await ctx.newPage();
const api = [];
p.on("request", (r) => { if (r.url().includes("/api/")) api.push({ m: r.method(), u: r.url() }); });
const apiResp = [];
p.on("response", (r) => { if (r.url().includes("/api/")) apiResp.push({ m: r.request().method(), u: r.url(), s: r.status() }); });
const consoleErrors = [];
p.on("console", (m) => { if (m.type() === "error") consoleErrors.push(m.text()); });
p.on("pageerror", (e) => consoleErrors.push("PAGEERROR: " + e.message));
// авто-принятие window.confirm (для удаления)
p.on("dialog", (d) => d.accept());
const tab = (name) => p.getByRole("tab", { name, exact: true });
try {
await p.goto(BASE, { waitUntil: "networkidle", timeout: 30000 });
await p.waitForTimeout(800);
// ---- A: 4 вкладки ----
for (const t of ["Планы", "Карта", "Редактор", "Заявки"]) {
(await tab(t).count()) > 0 ? pass(`tab:${t}`) : fail(`tab:${t}`, "не найдена");
}
// ---- B: Карта — дефолтные слои ----
await tab("Карта").click();
await p.waitForTimeout(600);
const toggles = p.locator(".tgu-map-toolbar__toggles button");
const n = await toggles.count();
const labels = [];
for (let i = 0; i < n; i++) {
const el = toggles.nth(i);
labels.push({ t: (await el.innerText()).trim(), active: (await el.getAttribute("class") || "").includes("is-active") });
}
console.log("LAYER_TOGGLES", JSON.stringify(labels));
const wanted = ["Работы плана", "Станции", "Заявки на съёмку"];
const got = labels.map((l) => l.t);
JSON.stringify(got) === JSON.stringify(wanted)
? pass("map:layers-picker", "ровно planWorks/stations/requests")
: fail("map:layers-picker", `got=${JSON.stringify(got)}`);
labels.every((l) => l.active)
? pass("map:layers-default-on", "все 3 активны")
: fail("map:layers-default-on", JSON.stringify(labels));
const forbidden = got.some((t) => /трек|track|полос|swath|маркер|spacecraft/i.test(t));
forbidden ? fail("map:no-tracks-swath-markers", "найден лишний слой") : pass("map:no-tracks-swath-markers");
// ---- C: Заявки — загрузка ----
await tab("Заявки").click();
await p.waitForTimeout(400);
const before = apiResp.length;
await p.getByRole("button", { name: "Загрузить", exact: true }).click();
await p.waitForResponse((r) => r.url().includes("/v1/requests/map"), { timeout: 15000 }).catch(() => {});
await p.waitForTimeout(800);
const mapCall = apiResp.find((r) => r.u.includes("/v1/requests/map"));
mapCall && mapCall.s === 200 ? pass("requests:load", `GET map ${mapCall.s}`) : fail("requests:load", JSON.stringify(mapCall));
const subtitle = await p.locator(".tgu-map-toolbar__subtitle").first().innerText().catch(() => "");
console.log("REQUESTS_SUBTITLE", subtitle);
// ---- D: создание + валидации ----
await p.getByRole("button", { name: "+ Создать заявку", exact: true }).click();
await p.waitForTimeout(300);
const form = p.locator(".tgu-requests-form");
const submitBtn = form.getByRole("button", { name: "Создать заявку", exact: true });
const alert = form.locator(".tgu-alert--danger");
// d1: до рисования — кнопка disabled, подсказка про полигон
(await submitBtn.isDisabled()) ? pass("create:disabled-before-draw") : fail("create:disabled-before-draw");
console.log("ALERT_INITIAL", await alert.innerText().catch(() => "(нет)"));
// d2: рисуем полигон 3 кликами по карте
// :visible — keep-alive держит смонтированными карты сразу нескольких вкладок
const map = p.locator(".tgu-2d-map:visible");
const box = await map.boundingBox();
const pts = [
{ x: box.width * 0.45, y: box.height * 0.40 },
{ x: box.width * 0.60, y: box.height * 0.40 },
{ x: box.width * 0.52, y: box.height * 0.55 }
];
for (const pt of pts) { await map.click({ position: pt }); await p.waitForTimeout(150); }
const vtext = await form.locator(".tgu-requests-form__geo-status").innerText().catch(() => "");
console.log("VERTEX_TEXT", vtext);
/Вершин:\s*3/.test(vtext) ? pass("create:3-vertices") : fail("create:3-vertices", vtext);
await form.getByRole("button", { name: "Завершить", exact: true }).click();
await p.waitForTimeout(300);
// d3: имя пустое -> валидация
let a = await alert.innerText().catch(() => "");
/название/i.test(a) && (await submitBtn.isDisabled()) ? pass("validate:empty-name", a) : fail("validate:empty-name", a);
// заполняем имя
await form.getByPlaceholder("Название задания").fill("E2E-VERIFY");
await p.waitForTimeout(200);
// d4: оба блока выключены
const opticsCb = form.locator("label.tgu-requests-form__block-toggle", { hasText: "Оптическая" }).getByRole("checkbox");
await opticsCb.uncheck();
await p.waitForTimeout(200);
a = await alert.innerText().catch(() => "");
/хотя бы один блок/i.test(a) && (await submitBtn.isDisabled()) ? pass("validate:no-optics-no-rsa", a) : fail("validate:no-optics-no-rsa", a);
await opticsCb.check();
await p.waitForTimeout(200);
// d5: from >= to
const begin = form.locator('input[type="datetime-local"]').nth(0);
const end = form.locator('input[type="datetime-local"]').nth(1);
const beginVal = await begin.inputValue();
await end.fill("2000-01-01T00:00");
await p.waitForTimeout(200);
a = await alert.innerText().catch(() => "");
/раньше конца/i.test(a) && (await submitBtn.isDisabled()) ? pass("validate:from-after-to", a) : fail("validate:from-after-to", a);
// восстановить корректный конец
await end.fill("2100-01-01T00:00");
await p.waitForTimeout(200);
// d6: submit -> POST 201 + новая заявка
(await submitBtn.isEnabled()) ? pass("create:enabled-when-valid") : fail("create:enabled-when-valid");
const postBefore = apiResp.length;
await submitBtn.click();
const postResp = await p.waitForResponse((r) => r.url().includes("/v1/requests") && r.request().method() === "POST", { timeout: 15000 }).catch(() => null);
await p.waitForTimeout(1000);
if (postResp && (postResp.status() === 201 || postResp.status() === 200)) {
pass("create:POST", `status ${postResp.status()}`);
// проверим тело запроса: WKT lon lat + id
const reqBody = postResp.request().postDataJSON?.() || JSON.parse(postResp.request().postData() || "{}");
const wktOk = /POLYGON\s*\(\(/.test(reqBody.geometry || "");
const idOk = /^[0-9a-f-]{36}$/i.test(reqBody.id || "");
wktOk && idOk ? pass("create:payload", `wkt+uuid ok`) : fail("create:payload", JSON.stringify({ id: reqBody.id, geo: (reqBody.geometry||"").slice(0,30) }));
console.log("CREATE_WKT", (reqBody.geometry || "").slice(0, 80));
} else {
fail("create:POST", postResp ? `status ${postResp.status()}` : "нет ответа");
}
const noticeTxt = await p.locator(".tgu-alert--success").innerText().catch(() => "");
/создана/i.test(noticeTxt) ? pass("create:notice", noticeTxt) : fail("create:notice", noticeTxt || "(нет)");
// ---- E: детали + удаление ----
// клик по нарисованному полигону -> панель деталей. Свипаем точки внутри треугольника,
// т.к. точное соответствие пиксель↔гео зависит от проекции.
const delBtn = p.getByRole("button", { name: /Удалить заявку/, exact: false });
const rows = p.locator(".tgu-map-request-row");
const detailsHeading = p.locator("aside .tgu-panel-heading span", { hasText: "Заявки в точке" });
const tip = p.locator(".tgu-2d-map__tooltip");
// hover-свип: ищем пиксель, где появляется тултип нашей заявки (надёжно к проекции/виду)
const mbox = await map.boundingBox();
let target = null;
outer:
for (let gx = 0.40; gx <= 0.65; gx += 0.02) {
for (let gy = 0.35; gy <= 0.58; gy += 0.02) {
await p.mouse.move(mbox.x + mbox.width * gx, mbox.y + mbox.height * gy);
await p.waitForTimeout(25);
if ((await tip.count()) > 0 && /E2E-VERIFY/.test(await tip.innerText().catch(() => ""))) {
target = { x: mbox.width * gx, y: mbox.height * gy };
break outer;
}
}
}
let opened = false;
if (target) {
await map.click({ position: target });
await p.waitForTimeout(400);
if ((await delBtn.count()) > 0 || (await detailsHeading.count()) > 0) opened = true;
}
if (opened) {
// если в точке несколько заявок — выбрать строку, чтобы появилась кнопка удаления
if ((await delBtn.count()) === 0 && (await rows.count()) > 0) {
await rows.first().click();
await p.waitForTimeout(300);
}
pass("details:open", `панель деталей открылась (строк в точке: ${await rows.count() || 1})`);
const initialDel = p.getByRole("button", { name: "Удалить заявку", exact: true });
const confirmTitle = p.locator(".tgu-requests-confirm__title", { hasText: "Удалить заявку?" });
const yesBtn = p.getByRole("button", { name: "Да, удалить", exact: true });
const noBtn = p.getByRole("button", { name: "Нет, не удалять", exact: true });
// шаг 1: клик «Удалить заявку» -> появляется панель подтверждения с Да/Нет
await initialDel.first().click();
await p.waitForTimeout(250);
(await confirmTitle.count()) > 0 && (await yesBtn.count()) > 0 && (await noBtn.count()) > 0
? pass("delete:confirm-panel", "панель «Удалить заявку?» с «Да, удалить»/«Нет, не удалять»")
: fail("delete:confirm-panel", `title=${await confirmTitle.count()} yes=${await yesBtn.count()} no=${await noBtn.count()}`);
// «Нет, не удалять» — не шлёт DELETE и закрывает подтверждение
const delCallsBefore = apiResp.filter((r) => r.m === "DELETE").length;
await noBtn.click();
await p.waitForTimeout(300);
(await confirmTitle.count()) === 0 && apiResp.filter((r) => r.m === "DELETE").length === delCallsBefore
? pass("delete:cancel", "«Нет, не удалять» отменяет без DELETE")
: fail("delete:cancel");
// «Да, удалить» -> DELETE
await initialDel.first().click();
await p.waitForTimeout(200);
await yesBtn.click();
const delResp = await p.waitForResponse((r) => r.url().includes("/v1/requests/") && r.request().method() === "DELETE", { timeout: 15000 }).catch(() => null);
await p.waitForTimeout(800);
delResp && (delResp.status() === 200 || delResp.status() === 204)
? pass("delete:DELETE", `status ${delResp.status()}`)
: fail("delete:DELETE", delResp ? `status ${delResp.status()}` : "нет DELETE-запроса");
} else {
fail("details:open", "панель деталей не открылась (не попал по полигону)");
}
// ---- console errors ----
consoleErrors.length === 0 ? pass("no-console-errors") : fail("no-console-errors", JSON.stringify(consoleErrors.slice(0, 8)));
} catch (e) {
fail("SCRIPT", e.message);
console.log(e.stack);
} finally {
console.log("\n=== API CALLS (последние 25) ===");
for (const r of apiResp.slice(-25)) console.log(r.s, r.m, r.u.replace("http://localhost:5174", ""));
const npass = results.filter((r) => r[0] === "PASS").length;
const nfail = results.filter((r) => r[0] === "FAIL").length;
console.log(`\n=== SUMMARY: ${npass} PASS / ${nfail} FAIL ===`);
await b.close();
process.exit(nfail > 0 ? 1 : 0);
}
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ТГУ — Операции</title>
</head>
<body>
<div id="root"></div>
<!-- Runtime-конфиг: в проде /config.js генерится из APP_*-переменных контейнера
(docker-entrypoint.d/20-nstart-runtime-config.sh). В деве это заглушка
public/config.js ({}), а значения берутся из VITE_* (.env). -->
<script src="/config.js"></script>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+92
View File
@@ -0,0 +1,92 @@
server {
listen 8081;
root /usr/share/nginx/html;
index index.html;
location = /healthz {
access_log off;
return 200 "ok\n";
add_header Content-Type text/plain;
}
location = /config.js {
expires -1;
add_header Cache-Control "no-store, no-cache, must-revalidate";
try_files $uri =404;
}
location ~* \.(js|css|png|svg|ico|woff2|woff|ttf)$ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
# --- ТГУ: маршрутизация на бэкенды (зеркало server.proxy из vite.config.ts).
# СЕМАНТИКА (важно повторить точно, иначе бэкенды получат неверный путь):
# - префиксы pcp-* СРЕЗАЮТСЯ: trailing-slash в proxy_pass отбрасывает префикс
# location (как rewrite в vite). Пример: /api/pcp-tgu/api/plans → бэкенд /api/plans.
# - /api/stations путь СОХРАНЯЕТ (в vite без rewrite): proxy_pass без URI-части.
# - общий /api/ — catch-all (tgu-planning, current-plans, auth) на ui-service/gateway.
# Порядок важен: специфичные префиксы идут РАНЬШЕ общего /api/.
# Цели (UPSTREAM_*) — заглушки: подставьте реальные адреса кластера ЛИБО перенесите
# маршрутизацию на gateway/ingress. Порты dev (vite) даны как ориентир в комментариях.
# Раскомментируйте после подстановки реальных upstream (нерезолвимый хост ломает старт nginx).
# Заголовки proxy_set_header в каждом блоке — те же, что в активном /api/ ниже.
#
# location /api/pcp-request/ { # dev :7005
# proxy_pass http://UPSTREAM_REQUEST/;
# proxy_set_header Host $host;
# proxy_set_header X-Real-IP $remote_addr;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# proxy_set_header X-Forwarded-Proto $scheme;
# }
# location /api/pcp-ballistics/ { # dev :7003
# proxy_pass http://UPSTREAM_BALLISTICS/;
# proxy_set_header Host $host;
# proxy_set_header X-Real-IP $remote_addr;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# proxy_set_header X-Forwarded-Proto $scheme;
# }
# location /api/pcp-mission/ { # dev :7010
# proxy_pass http://UPSTREAM_MISSION/;
# proxy_set_header Host $host;
# proxy_set_header X-Real-IP $remote_addr;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# proxy_set_header X-Forwarded-Proto $scheme;
# }
# location /api/pcp-tgu/ { # dev :7011
# proxy_pass http://UPSTREAM_TGU/;
# proxy_set_header Host $host;
# proxy_set_header X-Real-IP $remote_addr;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# proxy_set_header X-Forwarded-Proto $scheme;
# }
# location /api/pcp-complex/ { # dev :7002
# proxy_pass http://UPSTREAM_COMPLEX/;
# proxy_set_header Host $host;
# proxy_set_header X-Real-IP $remote_addr;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# proxy_set_header X-Forwarded-Proto $scheme;
# }
# location /api/stations/ { # dev :7009 — путь СОХРАНЯЕМ (proxy_pass без / и без URI)
# proxy_pass http://UPSTREAM_STATIONS;
# proxy_set_header Host $host;
# proxy_set_header X-Real-IP $remote_addr;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# proxy_set_header X-Forwarded-Proto $scheme;
# }
#
# Общий catch-all. В деве это ui-service (:7008): /api/tgu-planning, /api/current-plans, /api/auth.
location /api/ {
proxy_pass http://127.0.0.1:8000/api/; # ЗАГЛУШКА: замените на ui-service/gateway
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 60s;
}
location / {
try_files $uri $uri/ /index.html;
}
}
File diff suppressed because it is too large Load Diff
+41
View File
@@ -0,0 +1,41 @@
{
"name": "pcp-tgu-ui-service",
"version": "0.1.0",
"private": true,
"type": "module",
"engines": {
"node": "^24.0.0 || ^22.0.0"
},
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"test": "vitest run"
},
"comment-deps": "@nstart/ui и соседние пакеты — peer-зависимости barrel-экспорта @nstart/auth (его ProfilePage/UserPanel). В наших экранах НЕ используются и вырезаются tree-shaking из итогового бандла, но должны быть установлены, чтобы сборка резолвила реэкспорты.",
"dependencies": {
"@hookform/resolvers": "^5.2.2",
"@nstart/auth": "^0.3.3",
"@nstart/ui": "^0.4.0",
"@phosphor-icons/react": "^2.1.10",
"@tanstack/react-query": "^5.96.1",
"d3-geo": "^3.1.1",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-hook-form": "^7.66.0",
"react-i18next": "^17.0.2",
"topojson-client": "^3.1.0",
"zod": "^4.1.12"
},
"devDependencies": {
"@types/node": "^22.10.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"jsdom": "^27.2.0",
"playwright": "^1.60.0",
"typescript": "^6.0.2",
"vite": "^8.0.3",
"vitest": "^4.0.14"
}
}
@@ -0,0 +1 @@
window.__NSTART_APP_ENV__ = {}
+113
View File
@@ -0,0 +1,113 @@
import { useAppAuth } from './auth/appAuth'
import { env } from './config/env'
import { TguPlanningPage } from './features/tgu-planning/TguPlanningPage'
const isNewStartIdMode = env.authMode === 'new-start-id'
/**
* Экран ошибки авторизации в тёмной палитре ТГУ (var(--bg-base) и т.п. из
* styles/theme.css). Без @nstart/ui — обычная разметка, тексты на русском.
*
* @param props Код ошибки и технические детали для отображения.
* @returns React-элемент экрана ошибки.
*/
function AuthErrorScreen({
errorCode,
errorDetails,
}: {
errorCode: 'notAuthenticated' | 'initFailed'
errorDetails: string | null
}) {
const description =
errorCode === 'notAuthenticated'
? 'Сессия не подтверждена. Войдите через New Start ID, чтобы продолжить.'
: 'Не удалось инициализировать авторизацию. Проверьте доступность New Start ID и обновите страницу.'
return (
<div
style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '1rem',
background: 'var(--bg-base)',
}}
>
<div
style={{
maxWidth: '36rem',
width: '100%',
padding: '1.5rem',
border: '1px solid var(--border, #5b2330)',
borderRadius: '8px',
background: 'var(--bg-elevated, rgba(0,0,0,0.25))',
}}
>
<h1 style={{ margin: '0 0 0.75rem', fontSize: '1.1rem' }}>Ошибка авторизации</h1>
<p style={{ margin: '0 0 0.5rem' }}>{description}</p>
{errorDetails ? (
<p style={{ margin: 0, fontSize: '0.85rem', color: 'var(--text-muted)' }}>
Техническая причина: {errorDetails}
</p>
) : null}
</div>
</div>
)
}
/**
* Кнопка выхода из New Start ID. Показывается только в режиме new-start-id,
* не мешает полноэкранному UI ТГУ (фиксированный угол).
*
* @returns React-элемент кнопки выхода.
*/
function LogoutButton() {
const { logout } = useAppAuth()
return (
<button
type="button"
onClick={logout}
title="Выйти"
style={{
position: 'fixed',
top: '8px',
right: '12px',
zIndex: 1000,
padding: '4px 10px',
fontSize: '0.8rem',
cursor: 'pointer',
color: 'var(--text-muted)',
background: 'transparent',
border: '1px solid var(--border, #5b2330)',
borderRadius: '6px',
}}
>
Выйти
</button>
)
}
/**
* Корень приложения: гейт авторизации + полноэкранная рабочая страница ТГУ.
*
* @returns React-элемент приложения.
*/
export default function App() {
const { token, loading, errorCode } = useAppAuth()
// Anonymous: loading=false, token=null, errorCode=null → сразу рендерим ТГУ.
if (isNewStartIdMode && loading) {
return null
}
if (isNewStartIdMode && errorCode && !token) {
return <AuthErrorScreen errorCode={errorCode} errorDetails={null} />
}
return (
<>
{isNewStartIdMode ? <LogoutButton /> : null}
<TguPlanningPage />
</>
)
}
@@ -0,0 +1,34 @@
// Мост токена авторизации в сетевой слой. Модули src/api/* — обычные функции,
// и хук useAppAuth() в них вызвать нельзя, поэтому текущий токен держим в
// модульной переменной, которую обновляет React-слой (см. AuthTokenBridge в
// auth/appAuth). В режиме anonymous токен остаётся null → заголовок Authorization
// не добавляется и сетевое поведение не меняется.
let authToken: string | null = null
/**
* Сохранить текущий токен авторизации для последующих вызовов apiFetch.
* @param token Токен доступа либо null, чтобы не выставлять заголовок (anonymous).
*/
export function setApiAuthToken(token: string | null): void {
authToken = token
}
/**
* Обёртка над fetch: при наличии токена добавляет заголовок Authorization: Bearer.
* По сигнатуре совместима с fetch и служит drop-in заменой в модулях src/api/*.
* @param input URL или Request, как у штатного fetch.
* @param init Параметры запроса, как у штатного fetch.
*
* @returns Промис ответа сервера, идентичный результату fetch.
*/
export function apiFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
if (!authToken) {
return fetch(input, init)
}
const headers = new Headers(init?.headers)
if (!headers.has('Authorization')) {
headers.set('Authorization', `Bearer ${authToken}`)
}
return fetch(input, { ...init, headers })
}
@@ -0,0 +1,202 @@
import { TguApiError } from '../model/tguTypes'
import { toLocalIso } from '../utils/localTime'
import { apiFetch } from './apiFetch'
const BASE = '/api/pcp-ballistics'
export type PointVisibilityParam = {
/** NORAD-идентификатор КА. */
noradId: number
/** Идентификатор объекта (цели) из запроса. */
objectId: string
/** Момент видимости (локальное время, ISO без зоны). */
time: string
/** Номер витка. */
revolution: number
/** Крен (gamma), град. */
gamma: number
/** Угол Солнца, град. */
sunAngle: number
/** Наклонная дальность. */
range: number
/** Направление витка. */
revSign: 'ASC' | 'DESC'
}
export type PointVisibilityFilters = {
/** Минимальный угол Солнца, град. */
sunAngleMin?: number
/** Минимальный крен, град. */
rollMin?: number
/** Максимальный крен, град. */
rollMax?: number
}
export type GeoPointTarget = {
id?: string
lat: number
lon: number
height?: number
}
/**
* Параметры видимости точки на заданном интервале времени из pcp-ballistics-service
* (POST /api/obj-view/mpl-point). Возвращает по одному моменту видимости на виток.
* @param noradId NORAD-идентификатор КА.
* @param target Цель (координаты точки) для расчёта видимости.
* @param fromMs Начало интервала, мс от эпохи.
* @param toMs Конец интервала, мс от эпохи.
* @param filters Фильтры по углу Солнца и крену (необязательны).
*
* @returns По одному моменту видимости на виток на интервале.
*/
export async function fetchPointVisibility(
noradId: number,
target: GeoPointTarget,
fromMs: number,
toMs: number,
filters: PointVisibilityFilters = {},
): Promise<PointVisibilityParam[]> {
const body = {
satellites: [noradId],
timeStart: toLocalDateTime(fromMs),
timeStop: toLocalDateTime(toMs),
obj: {
id: target.id ?? '',
position: {
lat: target.lat,
long: target.lon,
height: target.height ?? 0,
},
},
sunAngleMin: filters.sunAngleMin ?? null,
rollMin: filters.rollMin ?? null,
rollMax: filters.rollMax ?? null,
}
let response: Response
try {
response = await apiFetch(`${BASE}/api/obj-view/mpl-point`, {
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
} catch {
throw new TguApiError('Не удалось подключиться к сервису баллистики.', 'network')
}
const text = await response.text()
if (!response.ok) {
throw new TguApiError(
extractErrorText(text) || 'Ошибка сервиса баллистики.',
'http',
response.status,
)
}
if (!text.trim()) {
return []
}
return JSON.parse(text) as PointVisibilityParam[]
}
/** Целеуказание (момент/угол места/азимут/дальность) границы зоны радиовидимости. */
export type RvaTargetPosition = {
/** Момент (локальное время, ISO без зоны). */
time: string
/** Угол места, град. */
elevation: number
/** Азимут, град. */
azimuth: number
/** Наклонная дальность. */
range: number
}
/** Зона радиовидимости (ЗРВ) станции для КА на витке. */
export type RvaZone = {
/** NORAD-идентификатор КА. */
noradId: number
/** Номер станции приёма (совпадает с number из /api/stations). */
stationId: number
/** Номер витка. */
revolution: number
/** Вход в зону. */
onStart: RvaTargetPosition
/** Максимум (кульминация). */
onMaximum: RvaTargetPosition
/** Выход из зоны. */
onStop: RvaTargetPosition
/** Длительность зоны, секунды. */
duration: number
}
/**
* Зоны радиовидимости (ЗРВ) для КА на заданном интервале из pcp-ballistics-service
* (GET /api/satellites/{norad}/rva). Баллистика считает зоны по реальным станциям приёма
* (каталог берёт у сервиса станций); каждая зона помечена номером станции (stationId).
* @param noradId NORAD-идентификатор КА.
* @param fromMs Начало интервала, мс от эпохи.
* @param toMs Конец интервала, мс от эпохи.
*
* @returns Зоны радиовидимости КА на интервале (помечены номером станции).
*/
export async function fetchSatelliteRva(
noradId: number,
fromMs: number,
toMs: number,
): Promise<RvaZone[]> {
const params = new URLSearchParams({
time_start: toLocalDateTime(fromMs),
time_stop: toLocalDateTime(toMs),
})
const url = `${BASE}/api/satellites/${noradId}/rva?${params.toString()}`
let response: Response
try {
response = await apiFetch(url, { headers: { Accept: 'application/json' } })
} catch {
throw new TguApiError('Не удалось подключиться к сервису баллистики.', 'network')
}
const text = await response.text()
if (!response.ok) {
throw new TguApiError(
extractErrorText(text) || 'Ошибка сервиса баллистики.',
'http',
response.status,
)
}
if (!text.trim()) {
return []
}
return JSON.parse(text) as RvaZone[]
}
/**
* LocalDateTime без зоны (локальные настенные часы): YYYY-MM-DDTHH:mm:ss.
* @param ms Время в миллисекундах от эпохи.
*
* @returns Строка LocalDateTime без зоны.
*/
function toLocalDateTime(ms: number): string {
return toLocalIso(ms)
}
function extractErrorText(body: string): string {
const trimmed = body.trim()
if (!trimmed) return ''
try {
const parsed = JSON.parse(trimmed) as Record<string, unknown>
for (const field of ['message', 'error', 'reason', 'detail']) {
const value = parsed[field]
if (typeof value === 'string' && value.trim()) {
return value
}
}
} catch {
return trimmed
}
return trimmed
}
@@ -0,0 +1,222 @@
import { TguApiError } from '../model/tguTypes'
import { toLocalIso } from '../utils/localTime'
import { apiFetch } from './apiFetch'
/**
* Расчёт комплексного плана (этап 4) напрямую в pcp-complex-mission-service через Vite-прокси
* (`/api/pcp-complex` → :7002), как и остальные сервисы — без BFF. Это тот самый snapshot,
* который затем потребляет редактор (stage 5, `calculateSurveys`).
*/
const BASE = '/api/pcp-complex'
/**
* Максимальная длина интервала расчёта комплексного плана — 10 суток. Дольше считать запрещено
* (расчёт по всем КА на больший горизонт слишком тяжёлый и теряет смысл для оперативного плана).
*/
export const MAX_COMPLEX_PLAN_RANGE_DAYS = 10
export const MAX_COMPLEX_PLAN_RANGE_MS = MAX_COMPLEX_PLAN_RANGE_DAYS * 24 * 60 * 60 * 1000
/** Статус запуска расчёта комплексного плана (ComplexPlanRunStatus бэка). */
export type ComplexPlanRunStatus = 'CREATED' | 'RUNNING' | 'COMPLETED' | 'FAILED'
export type RunComplexPlanParams = {
/** Начало интервала расчёта (эпоха-мс, локальное). */
fromMs: number
/** Конец интервала расчёта (эпоха-мс, локальное). */
toMs: number
/** NORAD-id КА, по которым считаем (все эксплуатируемые КА вкладки «Планы»). */
satelliteIds: number[]
}
/**
* Ответ синхронного расчёта комплексного плана (ComplexPlanRunResponseDTO).
* Расчёт уже завершён к моменту ответа: пишет snapshot’ы этапа 4 и возвращает их id + статистику.
*/
export type ComplexPlanRunResult = {
runId: number
status: ComplexPlanRunStatus
intervalStart: string
intervalEnd: string
satelliteIds: number[]
durationMs: number | null
errorMessage: string | null
calculatedModesCount: number
bookedModesCount: number
complanModesCount: number
mixedModesCount: number
affectedSatellitesCount: number
processedCellsCount: number
bookedSlotLinksCount: number
snapshotIds: number[]
totalRequestsArea: number
coveredArea: number
coveredAreaPercent: number
}
/**
* Краткая карточка прогона из истории расчётов (ComplexPlanRunSummaryDTO).
* Возвращается списком из GET /api/com-plan/runs, отсортированным по убыванию даты создания.
*/
export type ComplexPlanRunSummary = {
runId: number
status: ComplexPlanRunStatus
intervalStart: string
intervalEnd: string
satelliteIds: number[]
requestedBy: string | null
createdAt: string
startedAt: string | null
finishedAt: string | null
durationMs: number | null
errorMessage: string | null
calculatedModesCount: number
bookedModesCount: number
complanModesCount: number
mixedModesCount: number
affectedSatellitesCount: number
processedCellsCount: number
bookedSlotLinksCount: number
snapshotIds: number[]
totalRequestsArea: number
coveredArea: number
coveredAreaPercent: number
}
/** Режим съёмки/сброса из результата расчёта (SatelliteModeResponseDTO). Времена — локальные. */
export type ComplexPlanMode = {
id: number
satelliteId: number
source: string
type: 'SURVEY' | 'DROP' | string
startTime: string
endTime: string
revolution: number
lat: number
longitude: number
duration: number
/** Контур полосы съёмки, WKT POLYGON (lon lat, ...). Для сбросов обычно отсутствует. */
contourWkt: string | null
roll: number
cellNum: number
bookedSlotIds: number[]
snapshotId: number | null
/** Объём режима, МБ (скорость накопления/сброса × длительность). Считает бэк (pcp-types-lib). */
volumeMb?: number
}
/**
* Запуск расчёта комплексного плана на интервал по списку КА
* (pcp-complex-mission-service, POST /api/com-plan/process). Вызов синхронный: возвращает
* завершённый run со статистикой и id записанных snapshot’ов. Бэк отклоняет запуск, если по
* одному из КА уже идёт пересекающийся расчёт.
* @param params Интервал расчёта и список КА.
*
* @returns Завершённый прогон со статистикой и id записанных snapshot’ов.
*/
export async function runComplexPlan(params: RunComplexPlanParams): Promise<ComplexPlanRunResult> {
const body = {
intervalStart: toLocalIso(params.fromMs),
intervalEnd: toLocalIso(params.toMs),
satelliteIds: params.satelliteIds,
}
const url = `${BASE}/api/com-plan/process`
let response: Response
try {
response = await apiFetch(url, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-Requested-By': 'pcp-tgu-ops-ui',
},
body: JSON.stringify(body),
})
} catch {
throw new TguApiError('Не удалось подключиться к сервису комплексного плана.', 'network')
}
const text = await response.text()
if (!response.ok) {
throw new TguApiError(
extractErrorText(text) || 'Не удалось рассчитать комплексный план.',
'http',
response.status,
)
}
if (!text.trim()) {
throw new TguApiError('Сервис комплексного плана вернул пустой ответ.', 'http', response.status)
}
return JSON.parse(text) as ComplexPlanRunResult
}
/**
* История прогонов комплексного плана (pcp-complex-mission-service, GET /api/com-plan/runs).
* Список отсортирован бэком по убыванию даты создания, поэтому первый завершённый элемент —
* это последний построенный комплексный план.
* @returns История прогонов комплексного плана (по убыванию даты создания).
*/
export async function fetchComplexPlanRuns(): Promise<ComplexPlanRunSummary[]> {
const url = `${BASE}/api/com-plan/runs`
let response: Response
try {
response = await apiFetch(url, { method: 'GET', headers: { Accept: 'application/json' } })
} catch {
throw new TguApiError('Не удалось подключиться к сервису комплексного плана.', 'network')
}
const text = await response.text()
if (!response.ok) {
throw new TguApiError(
extractErrorText(text) || 'Не удалось загрузить историю расчётов.',
'http',
response.status,
)
}
if (!text.trim()) return []
return JSON.parse(text) as ComplexPlanRunSummary[]
}
/**
* Режимы (съёмки/сбросы) рассчитанного run’а для отрисовки на карте
* (pcp-complex-mission-service, GET /api/com-plan/runs/{runId}/modes).
* @param runId Идентификатор прогона расчёта.
*
* @returns Режимы (съёмки/сбросы) прогона для отрисовки на карте.
*/
export async function fetchComplexPlanRunModes(runId: number): Promise<ComplexPlanMode[]> {
const url = `${BASE}/api/com-plan/runs/${encodeURIComponent(runId)}/modes`
let response: Response
try {
response = await apiFetch(url, { method: 'GET', headers: { Accept: 'application/json' } })
} catch {
throw new TguApiError('Не удалось подключиться к сервису комплексного плана.', 'network')
}
const text = await response.text()
if (!response.ok) {
throw new TguApiError(
extractErrorText(text) || 'Не удалось загрузить результат расчёта.',
'http',
response.status,
)
}
if (!text.trim()) return []
return JSON.parse(text) as ComplexPlanMode[]
}
function extractErrorText(body: string): string {
const trimmed = body.trim()
if (!trimmed) return ''
try {
const parsed = JSON.parse(trimmed) as Record<string, unknown>
for (const field of ['message', 'error', 'reason', 'detail']) {
const value = parsed[field]
if (typeof value === 'string' && value.trim()) {
return value
}
}
} catch {
return trimmed
}
return trimmed
}
@@ -0,0 +1,414 @@
import { TguApiError } from '../model/tguTypes'
import { apiFetch } from './apiFetch'
const BASE = '/api/pcp-mission'
/** Способ построения окна маршрута относительно момента видимости. */
export type SurveyRouteAnchor = 'CENTERED' | 'FORWARD'
export type BuildSurveyRouteParams = {
/** Идентификатор КА (NORAD — тот же, что в obj-view/mpl-point). */
satelliteId: number
/** Момент видимости (локальное время, ISO без зоны). */
time: string
/** Длительность съёмки, секунды. */
durationSeconds: number
/** Крен (gamma), градусы. */
roll: number
/** Угол захвата (полуширина полосы), градусы. */
captureAngle: number
/** Номер витка (0 — определить по орбитальным точкам). */
revolution?: number
/** Способ построения окна относительно момента видимости. */
anchor?: SurveyRouteAnchor
/**
* Начало интервала плана (локальное время, ISO без зоны). Передаётся при сохранении,
* чтобы при автосоздании миссии/плана задать корректное окно (для пересечений/наложений).
*/
planStart?: string
/** Конец интервала плана (локальное время, ISO без зоны). См. planStart. */
planEnd?: string
}
export type SurveyRoute = {
satelliteId: number
/** Начало съёмки (локальное время, ISO без зоны). */
timeStart: string
/** Длительность съёмки, секунды. */
duration: number
revolution: number
/** Крен (gamma), градусы. */
roll: number
/** Угол захвата, градусы. */
captureAngle: number
/** Широта центра контура, градусы. */
lat: number
/** Долгота центра контура, градусы. */
longitude: number
/** Контур полосы съёмки, WKT POLYGON (lon lat, ...). */
contourWkt: string
/** Объём маршрута, МБ (скорость накопления × длительность). Считает бэк (pcp-types-lib). */
volumeMb?: number
/** Идентификатор сохранённого режима съёмки в БД (есть только после сохранения). */
surveyModeId?: number
}
/**
* Построение одного маршрута съёмки по параметрам видимости точки без сохранения
* (pcp-mission-planing-service, POST /api/missions/survey-route/build). Live-превью.
* @param params Параметры окна съёмки (КА, момент, длительность, крен, угол захвата).
*
* @returns Построенный маршрут съёмки (контур, объём) без сохранения.
*/
export async function buildSurveyRoute(params: BuildSurveyRouteParams): Promise<SurveyRoute> {
return postSurveyRoute(`${BASE}/api/missions/survey-route/build`, params)
}
/**
* Построение и сохранение маршрута съёмки в БД по идентификатору выбранного плана
* (pcp-mission-planing-service, POST /api/missions/{planId}/survey-route).
* planId — идентификатор выбранного плана в pcp-tgu-service (= mission_id).
* @param planId Идентификатор выбранного плана в pcp-tgu-service (= mission_id).
* @param params Параметры окна съёмки.
*
* @returns Сохранённый маршрут съёмки (с surveyModeId).
*/
export async function saveSurveyRoute(
planId: string,
params: BuildSurveyRouteParams,
): Promise<SurveyRoute> {
return postSurveyRoute(`${BASE}/api/missions/${encodeURIComponent(planId)}/survey-route`, params)
}
export type SaveDropRouteParams = {
/** Идентификатор КА (NORAD). */
satelliteId: number
/** Идентификатор/номер станции приёма (из ЗРВ). */
station: string
/** Номер витка зоны радиовидимости. */
revolution: number
/** Начало зоны (локальное время, ISO без зоны). */
zoneStart: string
/** Конец зоны (локальное время, ISO без зоны). */
zoneStop: string
/** Идентификаторы режимов съёмки (survey_modes), сбрасываемых в этой зоне. */
surveyModeIds: number[]
/** Начало интервала плана (для автосоздания миссии/плана). */
planStart?: string
/** Конец интервала плана. */
planEnd?: string
}
/** Сохранённый режим сброса (ответ /drop-route). */
export type DropMode = {
id: number
planId: number
timeStart: string | null
revolution: number
type: 'DROP'
station: string | null
duration: number
surveys: number[]
/** Объём сброса, Мбайт (канал 1200 Мбит/с ÷ 8 × длительность). Считает бэк (pcp-types-lib). */
volumeMb?: number
}
/**
* Ручное добавление сброса по выбранной зоне радиовидимости
* (pcp-mission-planing-service, POST /api/missions/{planId}/drop-route).
* planId — идентификатор выбранного плана в pcp-tgu-service (= mission_id).
* @param planId Идентификатор выбранного плана в pcp-tgu-service (= mission_id).
* @param params Параметры сброса (КА, станция, зона радиовидимости, режимы съёмки).
*
* @returns Сохранённый режим сброса.
*/
export async function saveDropRoute(
planId: string,
params: SaveDropRouteParams,
): Promise<DropMode> {
const body = {
satelliteId: params.satelliteId,
station: params.station,
revolution: params.revolution,
zoneStart: params.zoneStart,
zoneStop: params.zoneStop,
surveyModeIds: params.surveyModeIds,
...(params.planStart ? { planStart: params.planStart } : {}),
...(params.planEnd ? { planEnd: params.planEnd } : {}),
}
const url = `${BASE}/api/missions/${encodeURIComponent(planId)}/drop-route`
let response: Response
try {
response = await apiFetch(url, {
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
} catch {
throw new TguApiError('Не удалось подключиться к сервису планирования миссий.', 'network')
}
const text = await response.text()
if (!response.ok) {
throw new TguApiError(
extractErrorText(text) || 'Ошибка сохранения сброса.',
'http',
response.status,
)
}
if (!text.trim()) {
throw new TguApiError(
'Сервис планирования миссий вернул пустой ответ.',
'http',
response.status,
)
}
return JSON.parse(text) as DropMode
}
/** Итог расчёта плана съёмки (ответ /surveys/calculate). Времена — локальные. */
export type SurveyCalculationResult = {
missionId: string
completed: boolean
comPlanSnapshotId: number | null
surveyModesCount: number
manualModesCount: number
complanModesCount: number
slotsModesCount: number
mixedModesCount: number
bookedSlotIdsCount: number
}
/**
* Запуск расчёта плана съёмки для плана-черновика
* (pcp-mission-planing-service, POST /api/missions/{planId}/surveys/calculate).
* Источник комплексного плана (snapshot этапа 4) бэкенд выбирает сам по интервалу плана.
* Пересчёт сохраняет ручные маршруты (source=MANUAL), затирая только пересчитываемые.
* @param planId Идентификатор плана-черновика (= mission_id).
*
* @returns Итог расчёта плана съёмки (счётчики режимов, completed).
*/
export async function calculateSurveys(planId: string): Promise<SurveyCalculationResult> {
const url = `${BASE}/api/missions/${encodeURIComponent(planId)}/surveys/calculate`
let response: Response
try {
response = await apiFetch(url, { method: 'POST', headers: { Accept: 'application/json' } })
} catch {
throw new TguApiError('Не удалось подключиться к сервису планирования миссий.', 'network')
}
const text = await response.text()
if (!response.ok) {
throw new TguApiError(
extractErrorText(text) || 'Не удалось рассчитать съёмку.',
'http',
response.status,
)
}
return JSON.parse(text) as SurveyCalculationResult
}
/**
* Запуск расчёта плана сбросов для плана-черновика по уже рассчитанным/добавленным съёмкам
* (pcp-mission-planing-service, POST /api/missions/{planId}/drops/calculate). Возвращает 204.
* @param planId Идентификатор плана-черновика (= mission_id).
*/
export async function calculateDrops(planId: string): Promise<void> {
const url = `${BASE}/api/missions/${encodeURIComponent(planId)}/drops/calculate`
let response: Response
try {
response = await apiFetch(url, { method: 'POST', headers: { Accept: 'application/json' } })
} catch {
throw new TguApiError('Не удалось подключиться к сервису планирования миссий.', 'network')
}
if (!response.ok) {
const text = await response.text()
throw new TguApiError(
extractErrorText(text) || 'Не удалось рассчитать сбросы.',
'http',
response.status,
)
}
}
/**
* Очистка плана-черновика (pcp-mission-planing-service, DELETE /api/missions/{planId}/modes).
* Сбросы удаляются всегда; `keepManual=true` сохраняет ручные маршруты съёмки (source=MANUAL),
* `keepManual=false` (по умолчанию) удаляет все съёмки, включая ручные. Возвращает 204.
* @param planId Идентификатор плана-черновика (= mission_id).
* @param keepManual Сохранить ручные маршруты съёмки (source=MANUAL); по умолчанию удалять всё.
*/
export async function clearPlanModes(planId: string, keepManual = false): Promise<void> {
const query = keepManual ? '?keepManual=true' : ''
const url = `${BASE}/api/missions/${encodeURIComponent(planId)}/modes${query}`
let response: Response
try {
response = await apiFetch(url, { method: 'DELETE', headers: { Accept: 'application/json' } })
} catch {
throw new TguApiError('Не удалось подключиться к сервису планирования миссий.', 'network')
}
if (!response.ok) {
const text = await response.text()
throw new TguApiError(
extractErrorText(text) || 'Не удалось очистить план.',
'http',
response.status,
)
}
}
/**
* Перестроение контура существующего режима съёмки по новым параметрам окна/крена
* (pcp-mission-planing-service, PATCH /api/missions/survey-modes/{modeId}). Правка включения
* в плане-черновике: контур пересчитывается тем же алгоритмом, что и /survey-route/build.
* @param modeId Идентификатор существующего режима съёмки (survey_modes).
* @param params Новые параметры окна/крена для перестроения контура.
*
* @returns Перестроенный маршрут съёмки.
*/
export async function updateSurveyRoute(
modeId: number,
params: BuildSurveyRouteParams,
): Promise<SurveyRoute> {
return postSurveyRoute(
`${BASE}/api/missions/survey-modes/${encodeURIComponent(modeId)}`,
params,
'PATCH',
)
}
/**
* Удаление включения (съёмки или сброса) из плана-черновика
* (pcp-mission-planing-service, DELETE /api/missions/modes/{modeId}). Для съёмки бэк снимает
* брони слотов и связи со сбросами, осиротевший сброс удаляется; для сброса — только его связи.
* @param modeId Идентификатор включения (режима съёмки или сброса).
*/
export async function deleteMode(modeId: number): Promise<void> {
const url = `${BASE}/api/missions/modes/${encodeURIComponent(modeId)}`
let response: Response
try {
response = await apiFetch(url, { method: 'DELETE', headers: { Accept: 'application/json' } })
} catch {
throw new TguApiError('Не удалось подключиться к сервису планирования миссий.', 'network')
}
if (!response.ok) {
const text = await response.text()
throw new TguApiError(
extractErrorText(text) || 'Ошибка удаления включения.',
'http',
response.status,
)
}
}
/** Уровень конфликта валидации: error блокирует «Применить», warning — информативный. */
export type ConflictSeverity = 'error' | 'warning'
/** Один конфликт на затронутом включении (дифф по режимам). */
export type ModeConflict = {
/** id режима (survey или drop), к которому относится конфликт. */
modeId: number
/** Код правила (TIME_OVERLAP / TARGET_VISIBILITY / COMMS_SESSION / DROP_ORDER / ONBOARD_LIMIT). */
rule: string
severity: ConflictSeverity
message: string
/** id второго участника парного правила (например, пересечения по времени), если есть. */
relatedModeId?: number | null
}
export type MissionValidation = {
missionId: string
conflicts: ModeConflict[]
}
/**
* Валидация миссии-черновика на конфликты/наложения включений
* (pcp-mission-planing-service, POST /api/missions/{id}/validate). MVP: бэк-заглушка возвращает
* корректный контракт с пустым списком конфликтов; контракт стабилен для UI.
* @param missionId Идентификатор миссии-черновика.
*
* @returns Результат валидации со списком конфликтов.
*/
export async function validateMission(missionId: string): Promise<MissionValidation> {
const url = `${BASE}/api/missions/${encodeURIComponent(missionId)}/validate`
let response: Response
try {
response = await apiFetch(url, { method: 'POST', headers: { Accept: 'application/json' } })
} catch {
throw new TguApiError('Не удалось подключиться к сервису планирования миссий.', 'network')
}
const text = await response.text()
if (!response.ok) {
throw new TguApiError(
extractErrorText(text) || 'Ошибка валидации плана.',
'http',
response.status,
)
}
return JSON.parse(text) as MissionValidation
}
async function postSurveyRoute(
url: string,
params: BuildSurveyRouteParams,
method: 'POST' | 'PATCH' = 'POST',
): Promise<SurveyRoute> {
const body = {
satelliteId: params.satelliteId,
time: params.time,
durationSeconds: params.durationSeconds,
roll: params.roll,
captureAngle: params.captureAngle,
revolution: params.revolution ?? 0,
anchor: params.anchor ?? 'CENTERED',
...(params.planStart ? { planStart: params.planStart } : {}),
...(params.planEnd ? { planEnd: params.planEnd } : {}),
}
let response: Response
try {
response = await apiFetch(url, {
method,
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
} catch {
throw new TguApiError('Не удалось подключиться к сервису планирования миссий.', 'network')
}
const text = await response.text()
if (!response.ok) {
throw new TguApiError(
extractErrorText(text) || 'Ошибка построения маршрута съёмки.',
'http',
response.status,
)
}
if (!text.trim()) {
throw new TguApiError(
'Сервис планирования миссий вернул пустой ответ.',
'http',
response.status,
)
}
return JSON.parse(text) as SurveyRoute
}
function extractErrorText(body: string): string {
const trimmed = body.trim()
if (!trimmed) return ''
try {
const parsed = JSON.parse(trimmed) as Record<string, unknown>
for (const field of ['message', 'error', 'reason', 'detail']) {
const value = parsed[field]
if (typeof value === 'string' && value.trim()) {
return value
}
}
} catch {
return trimmed
}
return trimmed
}
@@ -0,0 +1,130 @@
import type {
CreateRequestPayload,
CreateRequestResult,
RequestFilter,
RequestMapItem,
} from '../model/requestTypes'
import { apiFetch } from './apiFetch'
const BASE = '/api/pcp-request/v1'
const PAGE_SIZE = 500
const MAX_PAGES = 10 // up to 5000 requests
type WithGeometryPage = {
items: RequestMapItem[]
page: number
size: number
totalItems: number
totalPages: number
}
async function fetchWithGeometryPage(
filter: RequestFilter,
page: number,
): Promise<WithGeometryPage> {
const params = new URLSearchParams()
if (filter.beginFrom) params.set('beginFrom', filter.beginFrom)
if (filter.beginTo) params.set('beginTo', filter.beginTo)
if (filter.endFrom) params.set('endFrom', filter.endFrom)
if (filter.endTo) params.set('endTo', filter.endTo)
if (filter.status) params.set('status', filter.status)
if (filter.surveyType) params.set('surveyType', filter.surveyType)
params.set('size', String(PAGE_SIZE))
params.set('page', String(page))
params.set('sort', 'beginDateTime,asc')
const response = await apiFetch(`${BASE}/requests/map?${params}`, {
headers: { Accept: 'application/json' },
})
if (!response.ok) {
throw new Error(`Ошибка загрузки заявок: ${response.status}`)
}
return response.json() as Promise<WithGeometryPage>
}
/**
* Загружает заявки для карты постранично (до MAX_PAGES страниц) и склеивает их в один список.
* @param filter Фильтр заявок (даты, статус, тип съёмки).
* @param onProgress Колбэк прогресса (загружено, всего); необязателен.
*
* @returns Список заявок, общее число и флаг усечения по лимиту страниц.
*/
export async function fetchRequestsForMap(
filter: RequestFilter,
onProgress?: (loaded: number, total: number) => void,
): Promise<{ items: RequestMapItem[]; totalItems: number; truncated: boolean }> {
const first = await fetchWithGeometryPage(filter, 0)
onProgress?.(first.items.length, first.totalItems)
if (first.totalPages <= 1) {
return { items: first.items, totalItems: first.totalItems, truncated: false }
}
const pagesToLoad = Math.min(first.totalPages, MAX_PAGES) - 1
const remaining = await Promise.all(
Array.from({ length: pagesToLoad }, (_, i) => fetchWithGeometryPage(filter, i + 1)),
)
const allItems = [...first.items, ...remaining.flatMap((page) => page.items)]
onProgress?.(allItems.length, first.totalItems)
return {
items: allItems,
totalItems: first.totalItems,
truncated: first.totalPages > MAX_PAGES,
}
}
type ErrorDetail = { field?: string; message?: string }
type ErrorBody = { code?: string; message?: string; details?: ErrorDetail[] }
async function readErrorMessage(response: Response, fallback: string): Promise<string> {
try {
const body = (await response.json()) as ErrorBody
const details = body.details
?.map((d) => (d.field ? `${d.field}: ${d.message}` : d.message))
.filter(Boolean)
const detailText = details && details.length > 0 ? ` (${details.join('; ')})` : ''
if (body.message) return `${body.message}${detailText}`
} catch {
// тело ответа не JSON — используем fallback
}
return `${fallback}: ${response.status}`
}
/**
* Создаёт заявку (pcp-request-service, POST /requests).
* @param payload Данные новой заявки.
*
* @returns Результат создания заявки.
*/
export async function createRequest(payload: CreateRequestPayload): Promise<CreateRequestResult> {
const response = await apiFetch(`${BASE}/requests`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(payload),
})
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Не удалось создать заявку'))
}
return response.json() as Promise<CreateRequestResult>
}
/**
* Удаляет заявку по идентификатору (pcp-request-service, DELETE /requests/{id}).
* @param id Идентификатор заявки.
*/
export async function deleteRequest(id: string): Promise<void> {
const response = await apiFetch(`${BASE}/requests/${encodeURIComponent(id)}`, {
method: 'DELETE',
headers: { Accept: 'application/json' },
})
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Не удалось удалить заявку'))
}
}
@@ -0,0 +1,23 @@
import { apiFetch } from './apiFetch'
export type StationDto = {
id: string | null
number: number
name: string
position: { lat: number; long: number; height: number }
elevationMin: number
elevationMax: number
}
/**
* Список наземных станций приёма (каталог станций).
* @returns Станции приёма с координатами и углами места.
*/
export async function fetchStations(): Promise<StationDto[]> {
const response = await apiFetch('/api/stations', {
headers: { Accept: 'application/json' },
})
if (!response.ok) {
throw new Error(`Ошибка загрузки станций: ${response.status}`)
}
return response.json() as Promise<StationDto[]>
}
@@ -0,0 +1,188 @@
import {
TguApiError,
type TguPlan,
type TguPlanDecision,
type TguPlanDecisionMessage,
type TguPlatform,
} from '../model/tguTypes'
import { toLocalIso } from '../utils/localTime'
import { apiFetch } from './apiFetch'
export type FlightLinePoint = {
time: string
revolution: number
lat: number
long: number
latLeft: number
longLeft: number
latInnerLeft: number
longInnerLeft: number
latInnerRight: number
longInnerRight: number
latRight: number
longRight: number
revSign: 'ASC' | 'DESC'
}
/**
* Трасса полёта КА (flight-line) на интервале для отрисовки на карте/таймлайне.
* @param noradId NORAD-идентификатор КА.
* @param fromMs Начало интервала, мс от эпохи.
* @param toMs Конец интервала, мс от эпохи.
*
* @returns Точки трассы полёта на интервале.
*/
export async function fetchFlightLine(
noradId: string,
fromMs: number,
toMs: number,
): Promise<FlightLinePoint[]> {
const query = new URLSearchParams({
time_start: toLocalIso(fromMs),
time_stop: toLocalIso(toMs),
})
return fetchJson<FlightLinePoint[]>(
`/api/tgu-planning/spacecraft/${encodeURIComponent(noradId)}/flight-line?${query}`,
)
}
/**
* Список платформ (КА) ТГУ.
* @returns Платформы планирования ТГУ.
*/
export async function fetchPlatforms(): Promise<TguPlatform[]> {
return fetchJson<TguPlatform[]>('/api/tgu-planning/platforms')
}
/**
* Планы ТГУ с заданной глубиной истории, опционально по одному КА.
* @param historyDays Глубина истории в сутках.
* @param spacecraftId Идентификатор КА для фильтра (необязателен — иначе по всем КА).
*
* @returns Планы ТГУ.
*/
export async function fetchPlans(historyDays: number, spacecraftId?: string): Promise<TguPlan[]> {
const query = new URLSearchParams({ historyDays: String(historyDays) })
const path = spacecraftId
? `/api/tgu-planning/spacecraft/${encodeURIComponent(spacecraftId)}/plans?${query}`
: `/api/tgu-planning/plans?${query}`
return fetchJson<TguPlan[]>(path)
}
/**
* Отправляет решение оператора по плану (принять/отклонить и т.п.).
* @param planId Идентификатор плана.
* @param decision Решение по плану.
* @param reason Причина решения (необязательна).
*
* @returns Сообщение-результат обработки решения.
*/
export async function sendPlanDecision(
planId: string,
decision: TguPlanDecision,
reason?: string,
): Promise<TguPlanDecisionMessage> {
const query = new URLSearchParams({ decision })
if (reason?.trim()) {
query.set('reason', reason.trim())
}
return fetchJson<TguPlanDecisionMessage>(
`/api/tgu-planning/plans/${encodeURIComponent(planId)}/decision?${query}`,
{ method: 'POST' },
true,
)
}
/**
* «Снять с учёта» терминально-негативный план: он перестаёт считаться проблемой и красить
* индикатор КА. Статус и цепочку не трогает; обратимо ([restorePlan]). 422 — статус не снимаемый.
* @param planId Идентификатор плана.
*/
export async function dismissPlan(planId: string): Promise<void> {
await fetchJson<void>(`/api/tgu-planning/plans/${encodeURIComponent(planId)}/dismiss`, {
method: 'POST',
})
}
/**
* «Вернуть на учёт» ранее снятый план.
* @param planId Идентификатор плана.
*/
export async function restorePlan(planId: string): Promise<void> {
await fetchJson<void>(`/api/tgu-planning/plans/${encodeURIComponent(planId)}/restore`, {
method: 'POST',
})
}
/**
* ТЕСТ: подтверждает закладку плана (AWAITING_LAYIN → LAID_IN) через тест-контроллер ТГУ.
* decisionRequest=false — пробрасываем реальный текст ошибки бэка (напр. гард «закладка ±10 мин»).
* @param planId Идентификатор плана.
*/
export async function confirmPlanLayin(planId: string): Promise<void> {
await fetchJson<void>(`/api/tgu-planning/plans/${encodeURIComponent(planId)}/confirm-layin`, {
method: 'POST',
})
}
async function fetchJson<T>(url: string, init?: RequestInit, decisionRequest = false): Promise<T> {
let response: Response
try {
response = await apiFetch(url, {
headers: { Accept: 'application/json' },
...init,
})
} catch {
throw new TguApiError('Не удалось подключиться к backend API.', 'network')
}
const text = await response.text()
if (!response.ok) {
throw new TguApiError(
errorMessage(response.status, text, decisionRequest),
'http',
response.status,
)
}
if (!text.trim()) {
return undefined as T
}
return JSON.parse(text) as T
}
function errorMessage(status: number, body: string, decisionRequest: boolean): string {
if (decisionRequest && status === 404) {
return 'План не найден.'
}
if (decisionRequest && status === 409) {
return 'План не является активным планом, ожидающим решения, или для него нет active attempt.'
}
if (decisionRequest && status >= 500) {
return 'Ошибка сервиса при отправке решения.'
}
return extractErrorText(body) || 'Ошибка backend API.'
}
function extractErrorText(body: string): string {
const trimmed = body.trim()
if (!trimmed) return ''
try {
const parsed = JSON.parse(trimmed) as Record<string, unknown>
for (const field of ['reason', 'message', 'error', 'detail']) {
const value = parsed[field]
if (typeof value === 'string' && value.trim()) {
return value
}
}
} catch {
return trimmed
}
return trimmed
}
@@ -0,0 +1,273 @@
import { TguApiError } from '../model/tguTypes'
import { apiFetch } from './apiFetch'
/**
* Вызовы версионирования/выдачи плана в pcp-tgu-service напрямую через Vite-прокси
* (`/api/pcp-tgu` → :7011), как и pcp-mission напрямую на :7010. BFF не используется.
*/
const BASE = '/api/pcp-tgu'
/** Режим наполнения черновика; та же строка уходит в clone миссии. */
export type DraftMode = 'replace' | 'fork-forward'
export type CreateDraftRequest = {
mode: DraftMode
/** Начало окна целевой ЗРВ (naive, ISO без зоны, локальное). Обязательно для fork-forward. */
windowStart?: string
/** Конец окна целевой ЗРВ. Обязательно для fork-forward. */
windowStop?: string
/** КПП целевой ЗРВ. Обязательно для fork-forward. */
windowKppId?: string
}
export type CreateDraftResponse = {
/** Идентификатор созданного черновика (= mission_id клон-миссии). */
draftPlanId: string
}
export type PromoteDraftResponse = {
processInstanceKey: number
processDefinitionKey: number
bpmnProcessId: string
version: number
planId: string
attemptId: string
}
/** План КА из проекции pcp-tgu-service (persisted будущая проекция). Времена — ISO с `Z`. */
export type TguPlanResponse = {
planId: string
spacecraftId: string
startTime: string
endTime: string
kppId: string
status: string
}
/** «Сырое» окно ЗРВ КА (граница реальной зоны) из pcp-tgu-service. Времена — ISO с `Z`. */
export type ZrvWindowResponse = {
startTime: string
endTime: string
kppId: string
}
/**
* Запрос создания плана новой цепочки (вкладка «Планы»). Окно набирается из двух ЗРВ:
* `windowStart`/`windowKppId` — старт выбранной стартовой зоны, `windowStop` — конец конечной.
* Времена — naive (ISO без зоны, локальное), как ждёт `LocalDateTime` бэка. `basePlanId` — предыстория
* (история тянется) либо `null` (цепочка без истории, пустой план).
*/
export type CreateChainPlanRequest = {
spacecraftId: string
basePlanId: string | null
windowStart: string
windowStop: string
windowKppId: string
}
export type CreateChainPlanResponse = {
draftPlanId: string
/** Прогноз будущей цепочки от созданного плана (what-if, состояние КА не тронуто). */
projectedChain: TguPlanResponse[]
}
/**
* Цепочка планов одного КА из pcp-tgu-service (GET /api/plans/{spacecraftId}): persisted планы плюс
* будущая проекция (PLANNED). Используется fork-forward-пикером для выбора целевой ЗРВ — бэк сверяет
* выбранное окно ровно с этой же проекцией (assertWindowMatchesProjection).
* @param spacecraftId Идентификатор КА.
*
* @returns Цепочка планов КА (persisted ∪ будущая проекция).
*/
export async function fetchSpacecraftPlans(spacecraftId: string): Promise<TguPlanResponse[]> {
const url = `${BASE}/api/plans/${encodeURIComponent(spacecraftId)}`
let response: Response
try {
response = await apiFetch(url, { method: 'GET', headers: { Accept: 'application/json' } })
} catch {
throw new TguApiError('Не удалось подключиться к сервису планирования ТГУ.', 'network')
}
const text = await response.text()
if (!response.ok) {
throw new TguApiError(
extractErrorText(text) || 'Не удалось получить планы КА.',
'http',
response.status,
)
}
return text.trim() ? (JSON.parse(text) as TguPlanResponse[]) : []
}
/**
* «Сырые» ЗРВ КА (границы реальных зон) из pcp-tgu-service (GET /api/plans/{spacecraftId}/zrv).
* Из них оператор набирает окно нового плана и видит доступные зоны на таймлайне.
* @param spacecraftId Идентификатор КА.
*
* @returns Список «сырых» окон ЗРВ КА (границы реальных зон).
*/
export async function fetchZrvWindows(spacecraftId: string): Promise<ZrvWindowResponse[]> {
const url = `${BASE}/api/plans/${encodeURIComponent(spacecraftId)}/zrv`
let response: Response
try {
response = await apiFetch(url, { method: 'GET', headers: { Accept: 'application/json' } })
} catch {
throw new TguApiError('Не удалось подключиться к сервису планирования ТГУ.', 'network')
}
const text = await response.text()
if (!response.ok) {
throw new TguApiError(
extractErrorText(text) || 'Не удалось получить ЗРВ КА.',
'http',
response.status,
)
}
return text.trim() ? (JSON.parse(text) as ZrvWindowResponse[]) : []
}
/**
* Черновики (DRAFT) из pcp-tgu-service (GET /api/plans/drafts), опц. по одному КА. Основной
* read-path планов их отсекает, поэтому режим «Черновики» во вкладке «Планы» читает их отдельно.
* @param spacecraftId Идентификатор КА для фильтра (необязателен — иначе все черновики).
*
* @returns Список планов-черновиков (DRAFT).
*/
export async function fetchDraftPlans(spacecraftId?: string): Promise<TguPlanResponse[]> {
const url = spacecraftId
? `${BASE}/api/plans/drafts?spacecraftId=${encodeURIComponent(spacecraftId)}`
: `${BASE}/api/plans/drafts`
let response: Response
try {
response = await apiFetch(url, { method: 'GET', headers: { Accept: 'application/json' } })
} catch {
throw new TguApiError('Не удалось подключиться к сервису планирования ТГУ.', 'network')
}
const text = await response.text()
if (!response.ok) {
throw new TguApiError(
extractErrorText(text) || 'Не удалось получить черновики.',
'http',
response.status,
)
}
return text.trim() ? (JSON.parse(text) as TguPlanResponse[]) : []
}
/**
* Создаёт план новой цепочки (pcp-tgu-service, POST /api/plans/new-chain). Возвращает план-кандидат
* (DRAFT) и прогноз будущей цепочки от него. Кандидат становится рабочим после ручной отправки
* (promote) и приёмки. 404 — базы нет; 422 — окно не из ЗРВ / база чужого КА / нечисловой КА.
* @param request Параметры новой цепочки (КА, база, окно из двух ЗРВ).
*
* @returns План-кандидат (DRAFT) и прогноз будущей цепочки от него.
*/
export async function createChainPlan(
request: CreateChainPlanRequest,
): Promise<CreateChainPlanResponse> {
return postJson<CreateChainPlanResponse>(
`${BASE}/api/plans/new-chain`,
request,
'Не удалось создать план новой цепочки.',
)
}
/**
* Создаёт черновик плана (DRAFT) на основе базового плана
* (pcp-tgu-service, POST /api/plans/{basePlanId}/draft). Триггерит клон миссии; возвращает
* draftPlanId, в который адресуются правки (Ф3) и который применяется promote (Ф5).
* @param basePlanId Идентификатор базового плана, от которого создаётся черновик.
* @param request Режим наполнения черновика (replace/fork-forward) и окно целевой ЗРВ.
*
* @returns Идентификатор созданного черновика (= mission_id клон-миссии).
*/
export async function createDraft(
basePlanId: string,
request: CreateDraftRequest,
): Promise<CreateDraftResponse> {
return postJson<CreateDraftResponse>(
`${BASE}/api/plans/${encodeURIComponent(basePlanId)}/draft`,
request,
'Не удалось создать черновик плана.',
)
}
/**
* «Применить» черновик: промоутит DRAFT в живую цепочку и выдаёт его
* (pcp-tgu-service, POST /api/plans/{draftPlanId}/promote). Под оптимистичным локом базы и
* гардом точки невозврата: 409 — база уехала / уже идёт выдача; 422 — не DRAFT / слот сместился /
* заморожено; 404 — черновика нет.
* @param draftPlanId Идентификатор черновика для применения.
*
* @returns Данные запущенного процесса выдачи (ключи процесса, planId, attemptId).
*/
export async function promoteDraft(draftPlanId: string): Promise<PromoteDraftResponse> {
return postJson<PromoteDraftResponse>(
`${BASE}/api/plans/${encodeURIComponent(draftPlanId)}/promote`,
undefined,
'Не удалось применить черновик плана.',
)
}
/**
* Отказ от черновика (pcp-tgu-service, DELETE /api/plans/{draftPlanId}): удаляет DRAFT и его
* клон-миссию, снимая блокировку «один открытый черновик на базу». 404 — нет; 422 — не DRAFT.
* @param draftPlanId Идентификатор черновика для отмены.
*/
export async function abandonDraft(draftPlanId: string): Promise<void> {
const url = `${BASE}/api/plans/${encodeURIComponent(draftPlanId)}`
let response: Response
try {
response = await apiFetch(url, { method: 'DELETE', headers: { Accept: 'application/json' } })
} catch {
throw new TguApiError('Не удалось подключиться к сервису планирования ТГУ.', 'network')
}
if (!response.ok) {
const text = await response.text()
throw new TguApiError(
extractErrorText(text) || 'Не удалось отменить черновик плана.',
'http',
response.status,
)
}
}
async function postJson<T>(url: string, body: unknown, fallbackMessage: string): Promise<T> {
let response: Response
try {
response = await apiFetch(url, {
method: 'POST',
headers:
body === undefined
? { Accept: 'application/json' }
: { Accept: 'application/json', 'Content-Type': 'application/json' },
...(body === undefined ? {} : { body: JSON.stringify(body) }),
})
} catch {
throw new TguApiError('Не удалось подключиться к сервису планирования ТГУ.', 'network')
}
const text = await response.text()
if (!response.ok) {
throw new TguApiError(extractErrorText(text) || fallbackMessage, 'http', response.status)
}
if (!text.trim()) {
return undefined as T
}
return JSON.parse(text) as T
}
function extractErrorText(body: string): string {
const trimmed = body.trim()
if (!trimmed) return ''
try {
const parsed = JSON.parse(trimmed) as Record<string, unknown>
for (const field of ['message', 'error', 'reason', 'detail']) {
const value = parsed[field]
if (typeof value === 'string' && value.trim()) {
return value
}
}
} catch {
return trimmed
}
return trimmed
}
@@ -0,0 +1,74 @@
import { createAppAuth, type DefaultAuthUser } from '@nstart/auth'
import { createContext, type ReactNode, useContext, useEffect } from 'react'
import { setApiAuthToken } from '../api/apiFetch'
import { env } from '../config/env'
export type AppUser = DefaultAuthUser
// Состояние, совместимое с AuthState из @nstart/auth (headless-ядро). Используем
// ТОЛЬКО headless-слой пакета: createAppAuth/useAppAuth дают токен, login, logout,
// loading и коды ошибок — без единого компонента @nstart/ui (их UI: ProfilePage/
// UserPanel — намеренно не подключаем).
interface AppAuthState {
user: AppUser | null
token: string | null
loading: boolean
errorCode: 'notAuthenticated' | 'initFailed' | null
errorDetails: string | null
login: () => void
logout: () => void
}
// Anonymous-режим: фейковый контекст, чтобы useAppAuth() работал везде в коде
// без условий. Токен null → apiFetch не добавляет Authorization, поведение как у
// исходного сервиса (открывается сразу, без Keycloak).
const ANONYMOUS_STATE: AppAuthState = {
user: null,
token: null,
loading: false,
errorCode: null,
errorDetails: null,
login: () => undefined,
logout: () => undefined,
}
const AnonymousAuthContext = createContext<AppAuthState>(ANONYMOUS_STATE)
function AnonymousAuthProvider({ children }: { children: ReactNode }) {
return (
<AnonymousAuthContext.Provider value={ANONYMOUS_STATE}>
{children}
</AnonymousAuthContext.Provider>
)
}
function useAnonymousAuth(): AppAuthState {
return useContext(AnonymousAuthContext)
}
const newStartIdAuth = createAppAuth({
url: env.newStartIdUrl,
realm: env.newStartIdRealm,
clientId: env.newStartIdClientId,
loginMode: 'redirect',
})
export const { AppAuthProvider, useAppAuth } =
env.authMode === 'new-start-id'
? newStartIdAuth
: { AppAuthProvider: AnonymousAuthProvider, useAppAuth: useAnonymousAuth }
/**
* Прокидывает токен из useAppAuth() в сетевой слой (держатель apiFetch). Ничего
* не рендерит; монтируется внутри AppAuthProvider. В режиме anonymous token === null,
* поэтому заголовок Authorization не выставляется и поведение запросов не меняется.
*
* @returns Ничего не отображает (null).
*/
export function AuthTokenBridge(): null {
const { token } = useAppAuth()
useEffect(() => {
setApiAuthToken(token ?? null)
}, [token])
return null
}
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { maskTime } from "./timeFields";
describe("maskTime", () => {
it("не пропускает невозможные значения", () => {
expect(maskTime("99:99", false)).toBe("09:09"); // 9>2 → 09; 9>5 → 09
expect(maskTime("3070", false)).toBe("03:07"); // 3>2 → 03; 7>5 → 07
expect(maskTime("2599", false)).toBe("23:09"); // 25→23 (зажим), 9>5 → 09
});
it("расставляет двоеточия и ограничивает длину", () => {
expect(maskTime("1234", false)).toBe("12:34");
expect(maskTime("12", false)).toBe("12");
expect(maskTime("123456789", false)).toBe("12:34");
});
it("поддерживает секунды", () => {
expect(maskTime("123456", true)).toBe("12:34:56");
expect(maskTime("235959", true)).toBe("23:59:59");
expect(maskTime("990000", true)).toBe("09:09:00");
});
it("игнорирует мусор и лишние двоеточия", () => {
expect(maskTime("1a2:b3", false)).toBe("12:3");
expect(maskTime("", false)).toBe("");
});
});
@@ -0,0 +1,135 @@
import { useEffect, useState, type CSSProperties } from "react";
/**
* Ввод времени суток в строгом 24-часовом формате `ЧЧ:ММ[:СС]` (локальное).
*
* Нативные `<input type="time">`/`type="datetime-local"` рендерят время по локали
* браузера/ОС (часто AM/PM) и не подчиняются атрибуту `lang`, поэтому время вводим
* обычным текстовым полем с собственной валидацией. Локальный черновик позволяет
* печатать промежуточные значения; наружу отдаём только корректное `ЧЧ:ММ[:СС]`.
*/
type Time24FieldProps = {
/** Текущее значение в формате `ЧЧ:ММ` или `ЧЧ:ММ:СС`. */
value: string;
/** Показывать и принимать секунды. */
withSeconds?: boolean;
/** Вызывается при вводе корректного значения. */
onChange: (value: string) => void;
className?: string;
style?: CSSProperties;
"aria-label"?: string;
};
const TIME_RE_HM = /^([01]\d|2[0-3]):[0-5]\d$/;
const TIME_RE_HMS = /^([01]\d|2[0-3]):[0-5]\d:[0-5]\d$/;
/**
* Забирает из потока цифр один сегмент времени `00..max` (часы → 23, мин/сек → 59).
* Если первая цифра заведомо больше старшего разряда (напр. `9` для часов), сразу
* дополняет нулём (`09`) и переходит к следующему сегменту; двузначное значение
* сверх максимума зажимается. Возвращает [сегмент, остаток цифр].
*/
function takeSegment(digits: string, max: number): [string, string] {
if (digits.length === 0) return ["", ""];
const firstMax = Math.floor(max / 10);
if (Number(digits[0]) > firstMax) {
return [`0${digits[0]}`, digits.slice(1)];
}
let segment = digits.slice(0, 2);
if (segment.length === 2 && Number(segment) > max) {
segment = String(max);
}
return [segment, digits.slice(2)];
}
/**
* Маскирует произвольный ввод в `ЧЧ:ММ[:СС]`: оставляет только цифры, расставляет
* двоеточия и зажимает каждый сегмент в допустимый диапазон, поэтому невозможные
* значения (`99:99`, `30:70`) набрать нельзя — они приводятся к корректным.
*/
export function maskTime(input: string, withSeconds: boolean): string {
const digits = input.replace(/\D/g, "").slice(0, withSeconds ? 6 : 4);
const segments: string[] = [];
let [segment, rest] = takeSegment(digits, 23);
segments.push(segment);
if (rest.length > 0) {
[segment, rest] = takeSegment(rest, 59);
segments.push(segment);
}
if (withSeconds && rest.length > 0) {
[segment, rest] = takeSegment(rest, 59);
segments.push(segment);
}
return segments.join(":");
}
export function Time24Field({ value, withSeconds = false, onChange, className, style, ...rest }: Time24FieldProps) {
const re = withSeconds ? TIME_RE_HMS : TIME_RE_HM;
const [draft, setDraft] = useState(value);
const [editing, setEditing] = useState(false);
// Пока поле не редактируется — синхронизируем отображение с внешним значением.
useEffect(() => {
if (!editing) setDraft(value);
}, [value, editing]);
return (
<input
{...rest}
type="text"
inputMode="numeric"
maxLength={withSeconds ? 8 : 5}
className={className}
style={style}
placeholder={withSeconds ? "чч:мм:сс" : "чч:мм"}
value={draft}
onFocus={() => setEditing(true)}
onChange={(event) => {
const next = maskTime(event.target.value, withSeconds);
setDraft(next);
if (re.test(next)) onChange(next);
}}
onBlur={() => {
setEditing(false);
if (re.test(draft)) onChange(draft);
else setDraft(value); // невалидный ввод — откат к последнему корректному
}}
/>
);
}
/**
* Замена нативного `<input type="datetime-local">`: нативный календарь для даты
* (у даты нет проблемы AM/PM) + [Time24Field] для времени в 24-часовом формате.
* Контракт значения тот же — строка `YYYY-MM-DDTHH:mm` (локальное), как у datetime-local.
*/
type DateTime24FieldProps = {
value: string;
onChange: (value: string) => void;
className?: string;
};
export function DateTime24Field({ value, onChange, className }: DateTime24FieldProps) {
const sep = value.indexOf("T");
const datePart = sep >= 0 ? value.slice(0, sep) : value;
const timePart = sep >= 0 ? value.slice(sep + 1, sep + 6) : "";
const combine = (date: string, time: string) => `${date}T${time || "00:00"}`;
return (
<span className={`tgu-dt24${className ? ` ${className}` : ""}`} style={{ display: "flex", alignItems: "center", gap: "0.3rem" }}>
<input
type="date"
lang="ru-RU"
style={{ width: "8rem" }}
value={datePart}
onChange={(event) => onChange(combine(event.target.value, timePart))}
/>
<Time24Field
style={{ width: "4.6rem", textAlign: "center" }}
value={timePart}
onChange={(time) => onChange(combine(datePart, time))}
/>
</span>
);
}
@@ -0,0 +1,74 @@
// Конфигурация окружения сервиса.
//
// Два источника, runtime имеет приоритет над build:
// - DEV (npm run dev): Vite экспонирует в import.meta.env ТОЛЬКО переменные с
// префиксом VITE_ — их читаем на этапе сборки (см. .env / .env.example).
// - PROD (Docker): docker-entrypoint.d/20-nstart-runtime-config.sh генерит
// /config.js из APP_*-переменных контейнера → window.__NSTART_APP_ENV__.
// Это прод-контракт; .env в проде не используется.
//
// У ТГУ собственная тёмная тема на :root (styles/theme.css), переключателя тем
// нет — поэтому параметра темы здесь намеренно нет (в отличие от nstart-шаблона,
// где тема нужна была только чтобы бороться со светлым дефолтом @nstart/ui).
export type AuthMode = 'new-start-id' | 'anonymous'
export interface AppEnv {
apiUrl: string
authMode: AuthMode
newStartIdUrl: string
newStartIdRealm: string
newStartIdClientId: string
}
interface RuntimeEnv {
apiUrl?: string
authMode?: string
newStartIdUrl?: string
newStartIdRealm?: string
newStartIdClientId?: string
}
declare global {
interface Window {
__NSTART_APP_ENV__?: RuntimeEnv
}
}
/** Берёт первое непустое значение из runtime → build → default. */
function pick(runtime: string | undefined, build: string | undefined, fallback: string): string {
const r = runtime?.trim()
if (r) return r
const b = build?.trim()
if (b) return b
return fallback
}
function resolveAuthMode(value: string): AuthMode {
return value === 'new-start-id' ? 'new-start-id' : 'anonymous'
}
function resolveEnv(): AppEnv {
const runtime = window.__NSTART_APP_ENV__ ?? {}
const build = import.meta.env
return {
apiUrl: pick(runtime.apiUrl, build.VITE_API_URL, '/api'),
// Дефолт anonymous: сервис открывается сразу, без редиректа на Keycloak.
// Закрытый контур включается явно (APP_AUTH_MODE / VITE_AUTH_MODE=new-start-id).
authMode: resolveAuthMode(pick(runtime.authMode, build.VITE_AUTH_MODE, 'anonymous')),
newStartIdUrl: pick(
runtime.newStartIdUrl,
build.VITE_NEW_START_ID_URL,
'http://localhost:8080/auth',
),
newStartIdRealm: pick(runtime.newStartIdRealm, build.VITE_NEW_START_ID_REALM, 'new-start'),
newStartIdClientId: pick(
runtime.newStartIdClientId,
build.VITE_NEW_START_ID_CLIENT_ID,
'pcp-tgu-ui-service',
),
}
}
export const env: AppEnv = resolveEnv()
@@ -0,0 +1,226 @@
/* ============================================================
ConstellationDashboard.jsx — wiring, RAF clock, memory sim (ESM)
Default export: <ConstellationDashboard /> — монтируется как вкладка.
============================================================ */
import React, { useState, useEffect, useRef, useMemo } from "react";
import MapView from "./MapView.jsx";
import Timeline from "./Timeline.jsx";
import { Header, SidePanel, STATUS_META, fmtDur } from "./Panels.jsx";
import * as SIM from "./sim.js";
import { SATELLITES, STATIONS, ROIS, COL } from "./data.js";
import "./dashboard.css";
function buildEnv() {
const epochMs = Date.now();
const sats = SATELLITES, stations = STATIONS, rois = ROIS;
const prohibits = SIM.buildProhibits(sats, epochMs);
const env = { epochMs, sats, stations, rois, prohibits };
const domain = { start: epochMs - 6 * 3600e3, end: epochMs + 18 * 3600e3 };
const sched = SIM.buildSchedule(sats, env, domain.start, domain.end, 1);
return { env, domain, sched };
}
function findInterval(list, t) {
if (!list) return null;
for (const iv of list) if (t >= iv.start && t < iv.end) return iv;
return null;
}
// Без пропсов — встроенная симуляция (buildEnv). С пропсами env/domain/sched —
// реальные данные из API (см. ConstellationTab + realData.ts, INTEGRATION.md §5).
// Обёртка монтирует компонент только когда данные готовы, поэтому cfg стабилен
// от первого рендера и mount-once замыкания RAF/apiRef остаются валидными.
function App({ env: extEnv, domain: extDomain, sched: extSched } = {}) {
const cfg = useMemo(
() => (extEnv ? { env: extEnv, domain: extDomain, sched: extSched } : buildEnv()),
[extEnv, extDomain, extSched]
);
const { env, domain, sched } = cfg;
const apiRef = useRef({});
const simRef = useRef(env.epochMs);
const playingRef = useRef(true);
const speedRef = useRef(10);
const liveRef = useRef(false);
const focusRef = useRef(null);
const hoverRef = useRef(null);
const memRef = useRef(Object.fromEntries(env.sats.map((s) => [s.id, env.memAt ? env.memAt(s, env.epochMs) : s.mem0])));
const lastT = useRef(performance.now());
const [, setTick] = useState(0);
const [playing, setPlaying] = useState(true);
const [speed, setSpeed] = useState(10);
const [live, setLive] = useState(false);
const [focusId, setFocusId] = useState(null);
const [query, setQuery] = useState("");
const [cursor, setCursor] = useState(null);
const [hover, setHover] = useState(null);
function setFocus(id) { focusRef.current = id; setFocusId(id); }
// map api callbacks
useEffect(() => {
const a = apiRef.current;
a.simTime = () => simRef.current;
a.onCursor = (ll) => setCursor(ll);
a.onPick = (id, x, y) => { hoverRef.current = id; setHover(id ? { id, x, y } : null); };
a.onClickPick = () => { if (hoverRef.current) setFocus(focusRef.current === hoverRef.current ? null : hoverRef.current); };
a.onReady = () => a.draw && a.draw(simRef.current, { focusId: focusRef.current });
}, []);
// RAF loop
useEffect(() => {
let raf;
let acc = 0;
const loop = (now) => {
const dt = Math.min(100, now - lastT.current);
lastT.current = now;
if (playingRef.current) {
if (liveRef.current) simRef.current = Date.now();
else simRef.current += dt * speedRef.current;
if (simRef.current > domain.end) simRef.current = domain.end;
if (simRef.current < domain.start) simRef.current = domain.start;
// memory dynamics — только когда нет авторитетного источника заполнения борта
if (!env.memAt) {
const secEq = (dt * speedRef.current) / 1000;
for (const s of env.sats) {
const stt = SIM.statusAt(s, simRef.current, env);
let m = memRef.current[s.id];
if (stt.state === "capture") m += 0.7 * secEq;
else if (stt.state === "downlink") m -= 1.5 * secEq;
else m += 0.02 * secEq; // idle housekeeping drift
memRef.current[s.id] = Math.max(0, Math.min(100, m));
}
}
}
// Реальное заполнение борта (объёмы маршрутов плана) — сэмплируем каждый кадр,
// чтобы значение следовало и за скрабом на паузе.
if (env.memAt) {
for (const s of env.sats) memRef.current[s.id] = env.memAt(s, simRef.current);
}
if (apiRef.current.draw) apiRef.current.draw(simRef.current, { focusId: focusRef.current });
acc += dt;
if (acc >= 80) { acc = 0; setTick((t) => (t + 1) % 1e6); }
raf = requestAnimationFrame(loop);
};
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
}, []);
// ---- controls ----
function onPlay() { const p = !playingRef.current; playingRef.current = p; setPlaying(p); if (!p) { liveRef.current = false; setLive(false); } }
function onSpeed(s) { speedRef.current = s; setSpeed(s); if (s !== 1) { liveRef.current = false; setLive(false); } }
function onNow() { liveRef.current = true; playingRef.current = true; speedRef.current = 1; simRef.current = Date.now(); setLive(true); setPlaying(true); setSpeed(1); }
function onStep(d) { liveRef.current = false; setLive(false); simRef.current = Math.max(domain.start, Math.min(domain.end, simRef.current + d)); }
function onScrub(t) { liveRef.current = false; setLive(false); simRef.current = Math.max(domain.start, Math.min(domain.end, t)); }
// ---- derived (per render) ----
const tNow = simRef.current;
const statuses = {};
const counts = { capture: 0, downlink: 0, prohibit: 0, eclipse: 0, optic: 0, radar: 0, total: env.sats.length };
for (const s of env.sats) {
const st = SIM.statusAt(s, tNow, env);
statuses[s.id] = st;
if (counts[st.state] !== undefined) counts[st.state]++;
if (st.eclipse) counts.eclipse++;
if (s.payload === "optic") counts.optic++; else counts.radar++;
}
const events = [];
for (const s of env.sats) {
const st = statuses[s.id];
let iv = null, type = null, target = null, color = null;
if (st.state === "capture") { iv = findInterval(sched[s.id].capture, tNow); type = "СЪЁМКА"; target = st.roi.name; color = STATUS_META.capture.c; }
else if (st.state === "downlink") { iv = findInterval(sched[s.id].downlink, tNow); type = "СБРОС"; target = "ст. " + st.station.name; color = STATUS_META.downlink.c; }
else if (st.state === "prohibit") { iv = findInterval(sched[s.id].prohibit, tNow); type = "ЗАПРЕТ"; target = st.prohibit.reason; color = STATUS_META.prohibit.c; }
if (type) events.push({ id: s.id, satId: s.id, sat: s.name, type, target, color, remain: fmtDur((iv ? iv.end : tNow) - tNow) });
}
events.sort((a, b) => a.remain.localeCompare(b.remain));
const focusSat = focusId ? env.sats.find((s) => s.id === focusId) : null;
return (
<div className="app cdash">
<Header simTime={tNow} playing={playing} speed={speed} live={live}
onPlay={onPlay} onSpeed={onSpeed} onNow={onNow} onStep={onStep} />
<div className="body">
<SidePanel sats={env.sats} statuses={statuses} memory={memRef.current} counts={counts}
events={events} focusId={focusId} onFocus={setFocus} query={query} setQuery={setQuery} />
<div className="main">
<div className="map-area">
<MapView env={env} apiRef={apiRef} />
<div className="map-caption">
{focusSat ? (
<FocusCard sat={focusSat} status={statuses[focusId]} mem={memRef.current[focusId]} onClose={() => setFocus(null)} />
) : (
<div className="cap-hint">КЛИК ПО АППАРАТУ ФОКУС И ПОДСВЕТКА СОБЫТИЙ</div>
)}
</div>
<MapLegend />
<div className="map-cursor">
{cursor ? `${Math.abs(cursor.lat).toFixed(3)}° ${cursor.lat >= 0 ? "с.ш." : "ю.ш."}, ${Math.abs(cursor.lon).toFixed(3)}° ${cursor.lon >= 0 ? "в.д." : "з.д."}` : "—"}
</div>
{hover && hover.id !== focusId && (
<div className="map-tip" style={{ left: hover.x + 14, top: hover.y - 8 }}>
{env.sats.find((s) => s.id === hover.id)?.name}
</div>
)}
</div>
<div className="tl-area">
<div className="tl-head">
<span className="tl-title">ТАЙМЛАЙН СОБЫТИЙ</span>
<div className="tl-legend">
<LegItem c="#d99a4e" t="СЪЁМКА" />
<LegItem c="#5b9bd5" t="СБРОС" />
<LegItem c="rgba(111,143,174,0.6)" t="СВЯЗЬ С КА" />
<LegItem c="#d9685f" t="ЗАПРЕТ РАБОТЫ" hatch />
<LegItem c="rgba(120,140,180,0.5)" t="ТЕНЬ ЗЕМЛИ" />
</div>
<span className="tl-hint">{live ? "РЕЖИМ LIVE · ×1" : "СИМУЛЯЦИЯ ×" + speed} · перетащите для скраба</span>
</div>
<Timeline env={env} sched={sched} simTime={tNow} domain={domain} focusId={focusId}
onScrub={onScrub} onFocus={setFocus} live={live} />
</div>
</div>
</div>
</div>
);
}
function FocusCard({ sat, status, mem, onClose }) {
const meta = STATUS_META[status.state];
return (
<div className="focus-card">
<span className="fc-pay" style={{ background: sat.color }} />
<div className="fc-info">
<div className="fc-name">{sat.name}<button className="fc-x" onClick={onClose}></button></div>
<div className="fc-meta">
<span>NORAD {sat.norad}</span><span className="fc-dot">·</span><span>{sat.group}</span>
<span className="fc-dot">·</span><span style={{ color: meta.c }}>{status.eclipse ? "☾ " : ""}{meta.t}</span>
</div>
</div>
<div className="fc-pos">
<div>{Math.abs(status.p.lat).toFixed(2)}° {status.p.lat >= 0 ? "N" : "S"} · {Math.abs(status.p.lon).toFixed(2)}° {status.p.lon >= 0 ? "E" : "W"}</div>
<div className="fc-mem">ПАМЯТЬ <b>{Math.round(mem)}%</b></div>
</div>
</div>
);
}
function MapLegend() {
return (
<div className="map-legend">
<div className="ml-row"><span className="ml-tri" />СТАНЦИЯ ПРИЁМА</div>
<div className="ml-row"><span className="ml-sw" style={{ background: COL.optic }} />ОПТИКА</div>
<div className="ml-row"><span className="ml-sw" style={{ background: COL.radar }} />ЛОКАЦИЯ</div>
<div className="ml-row"><span className="ml-sw amber" />МАРШРУТ СЪЁМКИ</div>
<div className="ml-row"><span className="ml-sw blue dash" />СБРОС НА СТАНЦИЮ</div>
<div className="ml-row"><span className="ml-sw dash" style={{ background: "#6f8fae" }} />СВЯЗЬ С КА</div>
<div className="ml-row"><span className="ml-sw night" />ТЕНЬ ЗЕМЛИ</div>
</div>
);
}
function LegItem({ c, t, hatch }) {
return <span className="leg-item"><i className={"leg-sw" + (hatch ? " hatch" : "")} style={{ background: hatch ? undefined : c }} />{t}</span>;
}
export default App;
@@ -0,0 +1,75 @@
/* ============================================================
ConstellationTab.tsx — обёртка вкладки «Группировка» с реальными данными.
Грузит состав/геометрию/расписание из API (realData.ts) и монтирует
прототипный ConstellationDashboard уже с готовыми env/domain/sched — поэтому
его mount-once замыкания (RAF/canvas) видят стабильные данные с первого рендера.
============================================================ */
import { useEffect, useState, type ComponentType } from "react";
import ConstellationDashboardImpl from "./ConstellationDashboard.jsx";
import { loadConstellationEnv, type ConstellationConfig } from "./realData";
import "./dashboard.css";
// ConstellationDashboard — JS-модуль прототипа; типизируем точку монтирования.
const ConstellationDashboard = ConstellationDashboardImpl as ComponentType<ConstellationConfig>;
type LoadState =
| { kind: "loading" }
| { kind: "error"; message: string }
| { kind: "ready"; cfg: ConstellationConfig };
export function ConstellationTab() {
const [state, setState] = useState<LoadState>({ kind: "loading" });
useEffect(() => {
let cancelled = false;
setState({ kind: "loading" });
loadConstellationEnv()
.then((cfg) => {
if (!cancelled) setState({ kind: "ready", cfg });
})
.catch((error: unknown) => {
if (!cancelled) {
setState({ kind: "error", message: error instanceof Error ? error.message : "Не удалось загрузить данные группировки." });
}
});
return () => {
cancelled = true;
};
}, []);
if (state.kind === "loading") {
return <ConstellationNotice text="Загрузка состояния группировки…" />;
}
if (state.kind === "error") {
return <ConstellationNotice text={`Ошибка загрузки: ${state.message}`} tone="error" />;
}
if (state.cfg.env.sats.length === 0) {
return <ConstellationNotice text="Нет эксплуатируемых КА с доступной трассой на горизонте планирования." />;
}
return <ConstellationDashboard env={state.cfg.env} domain={state.cfg.domain} sched={state.cfg.sched} />;
}
function ConstellationNotice({ text, tone }: { text: string; tone?: "error" }) {
return (
<div
className="cdash"
style={{
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#0a0c0b",
color: tone === "error" ? "#d9685f" : "#95a19d",
fontFamily: "'IBM Plex Mono', monospace",
fontSize: 13,
letterSpacing: ".04em",
padding: 24,
textAlign: "center"
}}
>
{text}
</div>
);
}
export default ConstellationTab;
@@ -0,0 +1,360 @@
/* ============================================================
MapView.jsx — canvas world map (ESM, default export)
Renders: land + graticule, ROIs, stations, night/terminator,
satellite sine tracks, footprints, capture swaths, downlinks.
Imperative draw(simTime, ui) called from the app RAF loop.
============================================================ */
import React, { useRef, useEffect } from "react";
import { geoEquirectangular, geoPath, geoGraticule, geoCircle } from "d3-geo";
import { feature } from "topojson-client";
import * as SIM from "./sim.js";
import { hexA } from "./util.js";
// Карта мира раздаётся со своего origin (Vite ?url), без рантайм-зависимости от CDN
// (см. INTEGRATION.md §3). Файл: assets/land-110m.json (world-atlas@2).
import landUrl from "./assets/land-110m.json?url";
// keep the original call-sites (d3.* / topojson.*) working unchanged
const d3 = { geoEquirectangular, geoPath, geoGraticule, geoCircle };
const topojson = { feature };
// Подспутниковая точка: реальный сэмплер env.subSat (flight-line) или встроенная
// симуляция SIM.subSat. См. INTEGRATION.md §5.2.
function subSatOf(env, sat, tMs) {
return env.subSat ? env.subSat(sat, tMs) : SIM.subSat(sat, tMs, env.epochMs);
}
const MAP_C = {
ocean: "#0a0e0f",
land: "#171c1e",
landStroke: "#252d30",
grat: "rgba(120,150,150,0.06)",
gratText: "rgba(150,180,178,0.25)",
amber: "#d99a4e",
blue: "#5b9bd5",
link: "#6f8fae",
red: "#d9685f",
teal: "#5fb3a3",
night: "rgba(20,28,46,0.55)",
nightEdge: "rgba(90,120,170,0.18)",
station: "#cdd6d3",
};
function MapView({ env, apiRef }) {
const wrapRef = useRef(null);
const canvasRef = useRef(null);
const st = useRef({
w: 0, h: 0, dpr: 1, proj: null, path: null,
base: null, land: null, ready: false,
});
// ---- load world land (topojson via unpkg, graceful fallback) ----
useEffect(() => {
let alive = true;
fetch(landUrl)
.then((r) => r.json())
.then((topo) => {
if (!alive) return;
st.current.land = topojson.feature(topo, topo.objects.land);
buildBase();
if (apiRef.current.onReady) apiRef.current.onReady();
})
.catch(() => { buildBase(); });
return () => { alive = false; };
}, []);
// ---- sizing ----
function resize() {
const wrap = wrapRef.current, cv = canvasRef.current;
if (!wrap || !cv) return;
const r = wrap.getBoundingClientRect();
const dpr = Math.min(window.devicePixelRatio || 1, 2);
st.current.w = r.width; st.current.h = r.height; st.current.dpr = dpr;
cv.width = r.width * dpr; cv.height = r.height * dpr;
cv.style.width = r.width + "px"; cv.style.height = r.height + "px";
const proj = d3.geoEquirectangular()
.scale(r.width / (2 * Math.PI))
.translate([r.width / 2, r.height / 2])
.rotate([-10, 0]);
st.current.proj = proj;
buildBase();
}
useEffect(() => {
resize();
const ro = new ResizeObserver(resize);
if (wrapRef.current) ro.observe(wrapRef.current);
return () => ro.disconnect();
}, []);
// ---- cached base layer (land, graticule, ROIs, stations) ----
function buildBase() {
const s = st.current;
if (!s.w || !s.proj) return;
const dpr = s.dpr;
const off = document.createElement("canvas");
off.width = s.w * dpr; off.height = s.h * dpr;
const ctx = off.getContext("2d");
ctx.scale(dpr, dpr);
const path = d3.geoPath(s.proj, ctx);
// ocean
ctx.fillStyle = MAP_C.ocean;
ctx.fillRect(0, 0, s.w, s.h);
// graticule
const grat = d3.geoGraticule().step([30, 30]);
ctx.beginPath(); path(grat());
ctx.strokeStyle = MAP_C.grat; ctx.lineWidth = 1; ctx.stroke();
// land
if (s.land) {
ctx.beginPath(); path(s.land);
ctx.fillStyle = MAP_C.land; ctx.fill();
ctx.strokeStyle = MAP_C.landStroke; ctx.lineWidth = 0.6; ctx.stroke();
}
// ROIs (заявки) — faint amber dashed (planar path, avoids d3 winding-complement)
ctx.save();
ctx.lineWidth = 1; ctx.setLineDash([4, 3]);
for (const r of env.rois) {
tracePoly(ctx, s.proj, r.poly);
ctx.fillStyle = "rgba(217,154,78,0.05)"; ctx.fill();
ctx.strokeStyle = "rgba(217,154,78,0.5)"; ctx.stroke();
}
ctx.restore();
// station labels
ctx.font = "9px 'IBM Plex Mono', monospace";
ctx.textAlign = "left"; ctx.textBaseline = "middle";
for (const stn of env.stations) {
const pt = s.proj([stn.lon, stn.lat]); if (!pt) continue;
ctx.fillStyle = "rgba(205,214,211,0.55)";
ctx.fillText(stn.name.toUpperCase(), pt[0] + 8, pt[1] - 6);
}
s.base = off; s.path = path; s.ready = true;
}
// ---- helpers ----
function project(lon, lat) {
const p = st.current.proj && st.current.proj([lon, lat]);
return p;
}
// draw a great-circle-ish track polyline, breaking at antimeridian wrap
function strokeTrack(ctx, pts, color, width, alpha) {
const s = st.current, half = s.w * 0.5;
ctx.save(); ctx.globalAlpha = alpha; ctx.strokeStyle = color;
ctx.lineWidth = width; ctx.lineJoin = "round"; ctx.lineCap = "round";
ctx.beginPath();
let prev = null, started = false;
for (const ll of pts) {
const p = s.proj(ll); if (!p) { started = false; continue; }
if (started && prev && Math.abs(p[0] - prev[0]) > half) started = false;
if (!started) { ctx.moveTo(p[0], p[1]); started = true; }
else ctx.lineTo(p[0], p[1]);
prev = p;
}
ctx.stroke(); ctx.restore();
}
// ---- main per-frame draw ----
function draw(tMs, ui) {
const s = st.current, cv = canvasRef.current;
if (!cv || !s.proj) return;
const ctx = cv.getContext("2d");
ctx.setTransform(s.dpr, 0, 0, s.dpr, 0, 0);
ctx.clearRect(0, 0, s.w, s.h);
if (s.base) ctx.drawImage(s.base, 0, 0, s.w, s.h);
const path = d3.geoPath(s.proj, ctx);
const focus = ui.focusId;
// --- night overlay (тень Земли) ---
const ss = SIM.subSolar(tMs);
const anti = [SIM.wrapLon(ss.lon + 180), -ss.lat];
const nightPoly = d3.geoCircle().center(anti).radius(90)();
ctx.beginPath(); path(nightPoly);
ctx.fillStyle = MAP_C.night; ctx.fill();
ctx.lineWidth = 1; ctx.strokeStyle = MAP_C.nightEdge; ctx.stroke();
// --- per-sat dynamic ---
const dash = (tMs / 30) % 16;
const sats = env.sats;
// tracks first (under glyphs)
for (const sat of sats) {
const dim = focus && focus !== sat.id;
const pts = [];
for (let dm = -52; dm <= 52; dm += 1.6) {
pts.push([0, 0]); // placeholder replaced below
}
// build actual track points
pts.length = 0;
for (let dm = -52; dm <= 52; dm += 1.6) {
const p = subSatOf(env, sat, tMs + dm * 60000);
if (p) pts.push([p.lon, p.lat]);
}
strokeTrack(ctx, pts, sat.color, dim ? 0.8 : 1.4, dim ? 0.10 : 0.30);
}
// events + glyphs
for (const sat of sats) {
const dim = focus && focus !== sat.id;
const stt = SIM.statusAt(sat, tMs, env);
const p = stt.p;
const pt = s.proj([p.lon, p.lat]); if (!pt) continue;
const baseA = dim ? 0.25 : 1;
// footprint circle (swath of view)
const fp = d3.geoCircle().center([p.lon, p.lat]).radius(sat.payload === "radar" ? 6.5 : 8)();
ctx.beginPath(); path(fp);
ctx.fillStyle = hexA(sat.color, dim ? 0.03 : 0.055); ctx.fill();
ctx.strokeStyle = hexA(sat.color, dim ? 0.08 : 0.16); ctx.lineWidth = 1; ctx.stroke();
// capture (маршрут съёмки) — amber swath along track + ROI highlight
if (stt.state === "capture") {
const seg = [];
for (let dm = -7; dm <= 7; dm += 1) {
const q = subSatOf(env, sat, tMs + dm * 60000);
if (q) seg.push([q.lon, q.lat]);
}
strokeTrack(ctx, seg, MAP_C.amber, dim ? 3 : 6, dim ? 0.3 : 0.85);
ctx.setLineDash([]);
// контур съёмки есть не у всех режимов плана — рисуем только когда задан
if (stt.roi && stt.roi.poly && stt.roi.poly.length) {
tracePoly(ctx, s.proj, stt.roi.poly);
ctx.fillStyle = "rgba(217,154,78,0.16)"; ctx.fill();
ctx.strokeStyle = MAP_C.amber; ctx.lineWidth = 1.4; ctx.stroke();
}
}
// link (связь с КА) — пунктир к станции на всём окне радиовидимости (ЗРВ),
// даже когда сброс не идёт. Тоньше/бледнее сброса; реальный сброс ляжет поверх.
if (env.sched) {
const lk = SIM.intervalAt((env.sched[sat.id] || {}).link, tMs);
if (lk && lk.lon != null && lk.lat != null) {
const lp = s.proj([lk.lon, lk.lat]);
if (lp) {
ctx.save();
ctx.setLineDash([3, 6]); ctx.lineDashOffset = -dash;
ctx.strokeStyle = hexA(MAP_C.link, dim ? 0.22 : 0.55); ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(pt[0], pt[1]); ctx.lineTo(lp[0], lp[1]); ctx.stroke();
ctx.restore();
}
}
}
// downlink (сброс) — animated dashed line to station
if (stt.state === "downlink" && stt.station && stt.station.lon != null && stt.station.lat != null) {
const sp = s.proj([stt.station.lon, stt.station.lat]);
if (sp) {
ctx.save();
ctx.setLineDash([5, 5]); ctx.lineDashOffset = -dash;
ctx.strokeStyle = hexA(MAP_C.blue, dim ? 0.4 : 0.95); ctx.lineWidth = 1.6;
ctx.beginPath(); ctx.moveTo(pt[0], pt[1]); ctx.lineTo(sp[0], sp[1]); ctx.stroke();
ctx.restore();
// station glow
ctx.beginPath(); ctx.arc(sp[0], sp[1], 7, 0, 7);
ctx.fillStyle = hexA(MAP_C.blue, 0.18); ctx.fill();
}
}
// stations as triangles (drawn here so glow sits under)
// (drawn once globally below to avoid repaint per sat) -> skip
// sat glyph
drawSatGlyph(ctx, pt[0], pt[1], sat, stt, baseA, sat.id === focus);
// label
if (!dim || sat.id === focus) {
ctx.font = "10px 'IBM Plex Mono', monospace";
ctx.textAlign = "left"; ctx.textBaseline = "middle";
ctx.fillStyle = hexA("#e6ebe9", baseA);
ctx.fillText(sat.name, pt[0] + 11, pt[1] + 0.5);
}
}
// stations triangles on top
for (const stn of env.stations) {
const sp = s.proj([stn.lon, stn.lat]); if (!sp) continue;
drawTriangle(ctx, sp[0], sp[1], 5, MAP_C.station);
}
}
function drawSatGlyph(ctx, x, y, sat, stt, alpha, isFocus) {
const ringColor = stt.state === "capture" ? MAP_C.amber
: stt.state === "downlink" ? MAP_C.blue
: stt.state === "prohibit" ? MAP_C.red : null;
// halo
ctx.beginPath(); ctx.arc(x, y, 9, 0, 7);
ctx.fillStyle = hexA(sat.color, 0.10 * alpha); ctx.fill();
// ring
if (ringColor) {
ctx.beginPath(); ctx.arc(x, y, 7.5, 0, 7);
ctx.strokeStyle = hexA(ringColor, alpha); ctx.lineWidth = 1.6; ctx.setLineDash([]); ctx.stroke();
}
if (isFocus) {
ctx.beginPath(); ctx.arc(x, y, 11, 0, 7);
ctx.strokeStyle = hexA(MAP_C.teal, 0.9); ctx.lineWidth = 1; ctx.stroke();
}
// diamond body
ctx.save(); ctx.translate(x, y); ctx.rotate(Math.PI / 4);
const r = 3.4;
ctx.fillStyle = stt.eclipse ? hexA(sat.color, 0.5 * alpha) : hexA(sat.color, alpha);
ctx.strokeStyle = hexA("#05080a", alpha); ctx.lineWidth = 1;
ctx.beginPath(); ctx.rect(-r, -r, r * 2, r * 2); ctx.fill(); ctx.stroke();
ctx.restore();
}
function drawTriangle(ctx, x, y, r, color) {
ctx.beginPath();
ctx.moveTo(x, y - r); ctx.lineTo(x + r, y + r * 0.8); ctx.lineTo(x - r, y + r * 0.8);
ctx.closePath();
ctx.fillStyle = hexA(color, 0.85); ctx.fill();
ctx.strokeStyle = "rgba(10,14,16,0.9)"; ctx.lineWidth = 0.8; ctx.stroke();
}
// pointer → lon/lat + nearest sat
function onMove(e) {
const s = st.current, r = canvasRef.current.getBoundingClientRect();
const x = e.clientX - r.left, y = e.clientY - r.top;
const ll = s.proj && s.proj.invert([x, y]);
if (apiRef.current.onCursor) apiRef.current.onCursor(ll ? { lon: ll[0], lat: ll[1] } : null);
// hit test sats handled by app via getHover; expose nearest
if (apiRef.current.onPick) {
let best = null, bd = 16;
for (const sat of env.sats) {
const p = subSatOf(env, sat, apiRef.current.simTime());
const pp = p && s.proj([p.lon, p.lat]); if (!pp) continue;
const d = Math.hypot(pp[0] - x, pp[1] - y);
if (d < bd) { bd = d; best = sat.id; }
}
apiRef.current.onPick(best, x, y);
}
}
function onClick() {
if (apiRef.current.onClickPick) apiRef.current.onClickPick();
}
// expose imperative API
useEffect(() => {
apiRef.current.draw = draw;
apiRef.current.project = project;
});
return (
<div className="map-wrap" ref={wrapRef}>
<canvas ref={canvasRef} onMouseMove={onMove} onMouseLeave={() => apiRef.current.onCursor && apiRef.current.onCursor(null)} onClick={onClick} />
</div>
);
}
// trace a small lon/lat polygon as a planar projected path (no antimeridian crossing)
function tracePoly(ctx, proj, poly) {
ctx.beginPath();
poly.forEach((pt, i) => {
const p = proj(pt); if (!p) return;
if (i === 0) ctx.moveTo(p[0], p[1]); else ctx.lineTo(p[0], p[1]);
});
ctx.closePath();
}
export default MapView;
@@ -0,0 +1,182 @@
/* ============================================================
Panels.jsx — header + left panel (ESM)
Exports: Header, SidePanel, STATUS_META, fmtDur.
============================================================ */
import React from "react";
import { COL } from "./data.js";
const STATUS_META = {
operational: { c: "#5cae7e", t: "НА ОРБИТЕ" },
capture: { c: "#d99a4e", t: "СЪЁМКА" },
downlink: { c: "#5b9bd5", t: "СБРОС" },
prohibit: { c: "#d9685f", t: "ЗАПРЕТ" },
};
// Время в проекте — локальное настенное (см. память project_tgu_ui_utc): часы, планы
// и flight-line идут в зоне браузера без UTC-семантики. Поэтому local-геттеры, не UTC.
function fmtClock(ms) {
const d = new Date(ms);
const p = (n) => String(n).padStart(2, "0");
return {
time: `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`,
date: `${p(d.getDate())}.${p(d.getMonth() + 1)}.${d.getFullYear()}`,
dow: ["ВС", "ПН", "ВТ", "СР", "ЧТ", "ПТ", "СБ"][d.getDay()],
};
}
function fmtDur(ms) {
const s = Math.max(0, Math.round(ms / 1000));
const m = Math.floor(s / 60), ss = s % 60;
return `${m}:${String(ss).padStart(2, "0")}`;
}
function Header({ simTime, playing, speed, live, onPlay, onSpeed, onNow, onStep }) {
const ck = fmtClock(simTime);
const speeds = [1, 10, 60, 600];
return (
<header className="hdr">
<div className="hdr-brand">
<span className="brand-mark" />
<div className="brand-txt">
<div className="brand-1">СОСТОЯНИЕ ГРУППИРОВКИ</div>
<div className="brand-2">MISSION OPS · ТГУ</div>
</div>
</div>
<div className="hdr-transport">
<button className="t-btn" onClick={() => onStep(-3600e3)} title="1 час">1ч</button>
<button className={"t-btn t-play" + (playing ? " on" : "")} onClick={onPlay}>
{playing ? "❚❚" : "▶"}
</button>
<button className="t-btn" onClick={() => onStep(3600e3)} title="+1 час">+1ч</button>
<div className="t-sep" />
<div className="speed-seg">
{speeds.map((s) => (
<button key={s} className={"sp" + (speed === s ? " on" : "")} onClick={() => onSpeed(s)}>×{s}</button>
))}
</div>
<button className={"t-btn t-now" + (live ? " live" : "")} onClick={onNow}>СЕЙЧАС</button>
</div>
<div className="hdr-clock">
<div className={"live-badge" + (live ? " on" : "")}>
<span className="live-dot" />{live ? "LIVE" : "СИМ"}
</div>
<div className="clock-main">
<span className="clock-time">{ck.time}</span>
<span className="clock-utc">местн.</span>
</div>
<div className="clock-date">{ck.dow} · {ck.date}</div>
</div>
</header>
);
}
function SummaryTiles({ counts }) {
const tiles = [
{ k: "capture", label: "СНИМАЮТ", n: counts.capture, c: STATUS_META.capture.c },
{ k: "downlink", label: "СБРОС", n: counts.downlink, c: STATUS_META.downlink.c },
{ k: "prohibit", label: "ЗАПРЕТ", n: counts.prohibit, c: STATUS_META.prohibit.c },
{ k: "eclipse", label: "В ТЕНИ", n: counts.eclipse, c: "#7d92c4" },
];
return (
<div className="summary">
<div className="summary-tiles">
{tiles.map((t) => (
<div key={t.k} className="s-tile">
<div className="s-num" style={{ color: t.c }}>{t.n}</div>
<div className="s-lbl">{t.label}</div>
</div>
))}
</div>
<div className="summary-foot">
<span><i className="dot" style={{ background: COL.optic }} />ОПТИКА {counts.optic}</span>
<span><i className="dot" style={{ background: COL.radar }} />ЛОКАЦИЯ {counts.radar}</span>
<span className="muted">{counts.total} КА</span>
</div>
</div>
);
}
function SatRow({ sat, status, mem, focus, onClick }) {
const meta = STATUS_META[status.state];
const memColor = mem > 92 ? "#d9685f" : mem > 78 ? "#d99a4e" : "#5fb3a3";
return (
<div className={"sat-row" + (focus ? " focus" : "")} onClick={onClick}>
<span className="sr-payload" style={{ background: sat.color }} />
<div className="sr-main">
<div className="sr-top">
<span className="sr-name">{sat.name}</span>
<span className="sr-status" style={{ color: meta.c }}>
{status.eclipse && <span className="ecl"></span>}{meta.t}
</span>
</div>
<div className="sr-bot">
<span className="sr-norad">NORAD {sat.norad}</span>
<div className="mem-bar"><span style={{ width: mem + "%", background: memColor }} /></div>
<span className="sr-mem" style={{ color: memColor }}>{Math.round(mem)}%</span>
</div>
</div>
</div>
);
}
function Roster({ sats, statuses, memory, focusId, onFocus, query }) {
const q = query.trim().toLowerCase();
const filtered = sats.filter((s) =>
!q || s.name.toLowerCase().includes(q) || String(s.norad).includes(q) || s.group.toLowerCase().includes(q));
const groups = [];
let curD = null, curG = null;
filtered.forEach((s) => {
if (s.domain !== curD) { groups.push({ type: "domain", label: s.domain, color: s.color }); curD = s.domain; curG = null; }
if (s.group !== curG) { groups.push({ type: "group", label: s.group }); curG = s.group; }
groups.push({ type: "sat", sat: s });
});
return (
<div className="roster">
{groups.map((g, i) => {
if (g.type === "domain") return <div key={i} className="r-domain"><i className="dot" style={{ background: g.color }} />{g.label}</div>;
if (g.type === "group") return <div key={i} className="r-group">{g.label}</div>;
const s = g.sat;
return <SatRow key={s.id} sat={s} status={statuses[s.id]} mem={memory[s.id]}
focus={focusId === s.id} onClick={() => onFocus(focusId === s.id ? null : s.id)} />;
})}
</div>
);
}
function ActivityFeed({ events, onFocus, focusId }) {
return (
<div className="activity">
<div className="sec-label">АКТИВНЫЕ СОБЫТИЯ <span className="count">{events.length}</span></div>
<div className="act-list">
{events.length === 0 && <div className="act-empty">нет активных событий</div>}
{events.map((e) => (
<div key={e.id} className={"act-row" + (focusId === e.satId ? " focus" : "")} onClick={() => onFocus(e.satId)}>
<span className="act-bar" style={{ background: e.color }} />
<div className="act-body">
<div className="act-top"><span className="act-type" style={{ color: e.color }}>{e.type}</span><span className="act-tgt">{e.target}</span></div>
<div className="act-sub">{e.sat}<span className="act-rem">ещё {e.remain}</span></div>
</div>
</div>
))}
</div>
</div>
);
}
function SidePanel({ sats, statuses, memory, counts, events, focusId, onFocus, query, setQuery }) {
return (
<aside className="side">
<div className="sec-label">СВОДКА ГРУППИРОВКИ</div>
<SummaryTiles counts={counts} />
<div className="side-search">
<input placeholder="Поиск name / NORAD / группа" value={query} onChange={(e) => setQuery(e.target.value)} />
</div>
<div className="sec-label tight">КОСМИЧЕСКИЕ АППАРАТЫ</div>
<Roster sats={sats} statuses={statuses} memory={memory} focusId={focusId} onFocus={onFocus} query={query} />
<ActivityFeed events={events} onFocus={onFocus} focusId={focusId} />
</aside>
);
}
export { Header, SidePanel, STATUS_META, fmtDur };
@@ -0,0 +1,163 @@
/* ============================================================
Timeline.jsx — bottom event timeline (ESM, default export)
Per-sat rows with capture / downlink / prohibit / shadow bands,
moving playhead, real-"now" marker, drag-to-scrub.
============================================================ */
import React, { useRef as useRefTL, useEffect as useEffectTL, useState as useStateTL } from "react";
const TL_SPAN = 12 * 3600e3; // visible window
const TL_LEAD = 0.32; // playhead position fraction
const TL_GUTTER = 188;
const TL_ROW = 22;
const TL_AXIS = 26;
const TL_COL = {
capture: "#d99a4e",
downlink: "#5b9bd5",
link: "rgba(111,143,174,0.38)", // связь с КА (ЗРВ) — фоновое окно радиовидимости
prohibit: "#d9685f",
shadow: "rgba(120,140,180,0.30)",
};
function Timeline({ env, sched, simTime, domain, focusId, onScrub, onFocus, live }) {
const wrapRef = useRefTL(null);
const [w, setW] = useStateTL(1200);
const dragRef = useRefTL(false);
useEffectTL(() => {
const ro = new ResizeObserver((es) => setW(es[0].contentRect.width));
if (wrapRef.current) ro.observe(wrapRef.current);
return () => ro.disconnect();
}, []);
const areaW = Math.max(200, w - TL_GUTTER);
let viewStart = simTime - TL_LEAD * TL_SPAN;
viewStart = Math.max(domain.start, Math.min(viewStart, domain.end - TL_SPAN));
const viewEnd = viewStart + TL_SPAN;
const X = (t) => TL_GUTTER + ((t - viewStart) / TL_SPAN) * areaW;
const clampT = (t) => Math.max(viewStart, Math.min(viewEnd, t));
const sats = env.sats;
const rows = [];
let y = TL_AXIS;
let lastDomain = null;
sats.forEach((s) => {
if (s.domain !== lastDomain) { rows.push({ type: "group", label: s.domain, y }); y += 16; lastDomain = s.domain; }
rows.push({ type: "sat", sat: s, y });
y += TL_ROW;
});
const totalH = y + 6;
// axis ticks (hourly)
const ticks = [];
const hour = 3600e3;
let t0 = Math.ceil(viewStart / hour) * hour;
for (let t = t0; t <= viewEnd; t += hour) ticks.push(t);
function ptFromEvent(e) {
const r = wrapRef.current.getBoundingClientRect();
const x = e.clientX - r.left;
const frac = (x - TL_GUTTER) / areaW;
return viewStart + frac * TL_SPAN;
}
function down(e) {
if (e.clientX - wrapRef.current.getBoundingClientRect().left < TL_GUTTER) return;
dragRef.current = true; onScrub(ptFromEvent(e));
}
function move(e) { if (dragRef.current) onScrub(ptFromEvent(e)); }
function up() { dragRef.current = false; }
useEffectTL(() => {
window.addEventListener("mousemove", move);
window.addEventListener("mouseup", up);
return () => { window.removeEventListener("mousemove", move); window.removeEventListener("mouseup", up); };
});
const nowX = X(Date.now());
const headX = X(simTime);
return (
<div className="tl-wrap" ref={wrapRef} onMouseDown={down}>
<svg width={w} height={totalH} style={{ display: "block" }}>
<defs>
<pattern id="hatch" width="6" height="6" patternTransform="rotate(45)" patternUnits="userSpaceOnUse">
<rect width="6" height="6" fill="rgba(217,104,95,0.12)" />
<line x1="0" y1="0" x2="0" y2="6" stroke="rgba(217,104,95,0.6)" strokeWidth="2" />
</pattern>
</defs>
{/* axis */}
<line x1={TL_GUTTER} y1={TL_AXIS} x2={w} y2={TL_AXIS} stroke="rgba(255,255,255,0.08)" />
{ticks.map((t, i) => {
const x = X(t); const d = new Date(t);
const hh = String(d.getHours()).padStart(2, "0");
const isDay = d.getHours() === 0;
return (
<g key={i}>
<line x1={x} y1={TL_AXIS - 5} x2={x} y2={totalH} stroke="rgba(255,255,255,0.035)" />
<text x={x + 3} y={TL_AXIS - 9} className="tl-tick">
{isDay ? `${String(d.getDate()).padStart(2, "0")}.${String(d.getMonth() + 1).padStart(2, "0")}` : `${hh}:00`}
</text>
</g>
);
})}
{/* rows */}
{rows.map((r, i) => {
if (r.type === "group") {
return <text key={i} x={10} y={r.y + 11} className="tl-group">{r.label}</text>;
}
const s = r.sat;
const tr = sched[s.id] || {};
const dim = focusId && focusId !== s.id;
const cy = r.y + TL_ROW / 2;
const band = (iv, key, fill, h) => {
const x1 = X(Math.max(iv.start, viewStart)), x2 = X(Math.min(iv.end, viewEnd));
if (x2 <= TL_GUTTER || x1 >= w || x2 - x1 < 0.6) return null;
return <rect key={key} x={x1} y={cy - h / 2} width={Math.max(1.5, x2 - x1)} height={h}
fill={fill} rx="1" opacity={dim ? 0.3 : 1} />;
};
return (
<g key={i} onClick={() => onFocus(focusId === s.id ? null : s.id)} style={{ cursor: "pointer" }}>
<rect x={0} y={r.y} width={w} height={TL_ROW} fill={focusId === s.id ? "rgba(95,179,163,0.06)" : "transparent"} />
<line x1={TL_GUTTER} y1={r.y + TL_ROW} x2={w} y2={r.y + TL_ROW} stroke="rgba(255,255,255,0.025)" />
<circle cx={14} cy={cy} r={3} fill={s.color} opacity={dim ? 0.4 : 1} />
<text x={24} y={cy + 3.5} className="tl-name" opacity={dim ? 0.45 : 1}>{s.name}</text>
{/* baseline */}
<line x1={TL_GUTTER} y1={cy} x2={w} y2={cy} stroke="rgba(255,255,255,0.04)" />
{/* shadow (under) */}
{(tr.shadow || []).map((iv, k) => band(iv, "sh" + k, TL_COL.shadow, TL_ROW - 8))}
{/* link (связь с КА — окна радиовидимости, фоновая полоса) */}
{(tr.link || []).map((iv, k) => band(iv, "lk" + k, TL_COL.link, TL_ROW - 10))}
{/* downlink (реальный сброс из плана) */}
{(tr.downlink || []).map((iv, k) => band(iv, "dl" + k, TL_COL.downlink, 8))}
{/* capture */}
{(tr.capture || []).map((iv, k) => band(iv, "cp" + k, TL_COL.capture, 10))}
{/* prohibit (hatched, full height) */}
{(tr.prohibit || []).map((iv, k) => {
const x1 = X(Math.max(iv.start, viewStart)), x2 = X(Math.min(iv.end, viewEnd));
if (x2 <= TL_GUTTER || x1 >= w || x2 - x1 < 0.6) return null;
return <rect key={"pr" + k} x={x1} y={r.y + 2} width={Math.max(2, x2 - x1)} height={TL_ROW - 4}
fill="url(#hatch)" stroke="rgba(217,104,95,0.5)" strokeWidth="0.8" opacity={dim ? 0.4 : 1} />;
})}
</g>
);
})}
{/* now marker (real wall clock) */}
{nowX > TL_GUTTER && nowX < w && (
<g>
<line x1={nowX} y1={TL_AXIS - 5} x2={nowX} y2={totalH} stroke="rgba(95,179,163,0.35)" strokeDasharray="2 3" />
<text x={nowX + 3} y={totalH - 3} className="tl-nowlbl">СЕЙЧАС</text>
</g>
)}
{/* playhead */}
<g>
<line x1={headX} y1={TL_AXIS - 6} x2={headX} y2={totalH} stroke={live ? "#5fb3a3" : "#d99a4e"} strokeWidth="1.5" />
<polygon points={`${headX - 5},${TL_AXIS - 6} ${headX + 5},${TL_AXIS - 6} ${headX},${TL_AXIS + 1}`} fill={live ? "#5fb3a3" : "#d99a4e"} />
</g>
</svg>
</div>
);
}
export default Timeline;
File diff suppressed because one or more lines are too long
@@ -0,0 +1,184 @@
/* Fonts — remove this @import if your app already loads IBM Plex */
@import url("https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap");
/* Переменные и резеты дашборда оскоплены на .cdash, чтобы не течь в остальное
приложение (см. INTEGRATION.md §6). В частности, --line здесь свой и не должен
переопределять --line из styles/theme.css на уровне всего приложения. */
.cdash {
--bg0:#0a0c0b; --bg1:#0e1211; --panel:#111514; --ins:#0a0e0d;
--line:rgba(255,255,255,0.06); --line2:rgba(255,255,255,0.11);
--hi:#e7ece9; --mid:#95a19d; --dim:#5b6662; --faint:#3c4644;
--teal:#5fb3a3; --teal-bg:rgba(95,179,163,0.13); --teal-br:rgba(95,179,163,0.35);
--amber:#d99a4e; --blue:#5b9bd5; --red:#d9685f; --green:#5cae7e; --violet:#a78bdb;
color:var(--hi); font-family:'IBM Plex Sans',system-ui,sans-serif; font-size:13px;
-webkit-font-smoothing:antialiased;
}
.cdash *{box-sizing:border-box;}
.mono,.clock-time,.sr-norad,.map-cursor,.tl-tick,.fc-meta{font-family:'IBM Plex Mono',monospace;}
.cdash button{font-family:inherit; cursor:pointer; color:inherit;}
.cdash ::-webkit-scrollbar{width:8px;height:8px;}
.cdash ::-webkit-scrollbar-thumb{background:rgba(255,255,255,0.07);border-radius:4px;}
.cdash ::-webkit-scrollbar-track{background:transparent;}
/* Встроено вкладкой в shell с собственной шапкой: высота от родителя, не 100vh
(см. INTEGRATION.md §4). display:contents у обёртки вкладки делает .app прямым
элементом grid-строки shell (minmax(0,1fr)). */
.app{height:100%; display:flex; flex-direction:column; overflow:hidden;}
/* ---------- header ---------- */
.hdr{
height:54px; flex:none; display:flex; align-items:center;
border-bottom:1px solid var(--line); background:linear-gradient(var(--bg1),var(--bg0));
padding:0 16px; gap:18px; position:relative; z-index:5;
}
.hdr-brand{display:flex; align-items:center; gap:11px; min-width:248px;}
.brand-mark{width:20px;height:20px;border-radius:50%;border:1.5px solid var(--teal);
box-shadow:0 0 10px rgba(95,179,163,0.4) inset, 0 0 6px rgba(95,179,163,0.3);}
.brand-1{font-weight:600; font-size:13px; letter-spacing:.04em; white-space:nowrap;}
.brand-2{font-family:'IBM Plex Mono',monospace; font-size:9px; color:var(--dim); letter-spacing:.12em; margin-top:1px;}
.hdr-transport{flex:1; display:flex; align-items:center; justify-content:center; gap:7px;}
.t-btn{background:var(--bg1); border:1px solid var(--line2); color:var(--mid);
height:30px; min-width:34px; padding:0 11px; border-radius:6px; font-size:11.5px; letter-spacing:.04em;
transition:.12s;}
.t-btn:hover{border-color:var(--teal-br); color:var(--hi);}
.t-play{font-size:12px; color:var(--teal); border-color:var(--teal-br); background:var(--teal-bg); width:42px;}
.t-play.on{background:rgba(95,179,163,0.2);}
.t-now{font-size:10.5px; letter-spacing:.1em;}
.t-now.live{background:rgba(92,174,126,0.16); border-color:rgba(92,174,126,0.5); color:var(--green);}
.t-sep{width:1px; height:20px; background:var(--line2); margin:0 4px;}
.speed-seg{display:flex; border:1px solid var(--line2); border-radius:6px; overflow:hidden; background:var(--bg1);}
.speed-seg .sp{background:transparent; border:0; color:var(--dim); height:28px; padding:0 11px; font-size:11px;
font-family:'IBM Plex Mono',monospace; border-right:1px solid var(--line); transition:.12s;}
.speed-seg .sp:last-child{border-right:0;}
.speed-seg .sp:hover{color:var(--mid);}
.speed-seg .sp.on{background:var(--teal-bg); color:var(--teal);}
.hdr-clock{display:flex; flex-direction:column; align-items:flex-end; min-width:240px; gap:1px;}
.live-badge{display:flex; align-items:center; gap:5px; font-size:9.5px; letter-spacing:.16em; color:var(--amber);
font-family:'IBM Plex Mono',monospace;}
.live-badge .live-dot{width:6px;height:6px;border-radius:50%;background:var(--amber);}
.live-badge.on{color:var(--green);}
.live-badge.on .live-dot{background:var(--green); box-shadow:0 0 6px var(--green); animation:pulse 1.4s infinite;}
@keyframes pulse{0%,100%{opacity:1;}50%{opacity:.35;}}
.clock-main{display:flex; align-items:baseline; gap:6px;}
.clock-time{font-size:22px; font-weight:500; letter-spacing:.02em; line-height:1;}
.clock-utc{font-size:9px; color:var(--dim); letter-spacing:.1em;}
.clock-date{font-size:10px; color:var(--mid); font-family:'IBM Plex Mono',monospace; letter-spacing:.06em;}
/* ---------- body ---------- */
.body{flex:1; display:flex; min-height:0;}
.side{width:328px; flex:none; border-right:1px solid var(--line); background:var(--bg1);
display:flex; flex-direction:column; overflow:hidden;}
.main{flex:1; display:flex; flex-direction:column; min-width:0;}
.sec-label{font-family:'IBM Plex Mono',monospace; font-size:9.5px; letter-spacing:.16em; color:var(--dim);
padding:13px 16px 8px; display:flex; align-items:center; gap:8px;}
.sec-label.tight{padding-top:6px;}
.sec-label .count{background:var(--bg0); border:1px solid var(--line2); border-radius:8px; padding:0 6px;
font-size:9px; color:var(--mid);}
/* summary */
.summary{padding:0 14px 6px;}
.summary-tiles{display:grid; grid-template-columns:repeat(4,1fr); gap:6px;}
.s-tile{background:var(--ins); border:1px solid var(--line); border-radius:7px; padding:9px 4px 7px; text-align:center;}
.s-num{font-family:'IBM Plex Mono',monospace; font-size:21px; font-weight:600; line-height:1;}
.s-lbl{font-size:8px; color:var(--dim); letter-spacing:.1em; margin-top:5px;}
.summary-foot{display:flex; align-items:center; gap:13px; padding:9px 2px 2px; font-size:10px;
color:var(--mid); font-family:'IBM Plex Mono',monospace;}
.summary-foot .dot,.r-domain .dot{width:7px;height:7px;border-radius:2px;display:inline-block;margin-right:5px;vertical-align:middle;}
.summary-foot .muted{margin-left:auto; color:var(--dim);}
/* search */
.side-search{padding:4px 14px 2px;}
.side-search input{width:100%; background:var(--ins); border:1px solid var(--line); border-radius:6px;
color:var(--hi); padding:7px 10px; font-size:11.5px; outline:none; font-family:'IBM Plex Mono',monospace;}
.side-search input::placeholder{color:var(--faint);}
.side-search input:focus{border-color:var(--teal-br);}
/* roster */
.roster{flex:1; min-height:0; overflow-y:auto; padding:0 8px 8px;}
.r-domain{font-family:'IBM Plex Mono',monospace; font-size:10px; letter-spacing:.14em; color:var(--mid);
padding:11px 8px 5px; display:flex; align-items:center;}
.r-group{font-size:9.5px; color:var(--faint); letter-spacing:.08em; padding:3px 8px 3px 20px; text-transform:uppercase;}
.sat-row{display:flex; gap:9px; padding:7px 8px 7px 10px; border-radius:7px; cursor:pointer;
align-items:center; transition:.1s; border:1px solid transparent;}
.sat-row:hover{background:rgba(255,255,255,0.025);}
.sat-row.focus{background:var(--teal-bg); border-color:var(--teal-br);}
.sr-payload{width:3px; align-self:stretch; border-radius:2px; flex:none;}
.sr-main{flex:1; min-width:0;}
.sr-top{display:flex; justify-content:space-between; align-items:baseline; gap:8px;}
.sr-name{font-size:12.5px; font-weight:500; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;}
.sr-status{font-size:9px; letter-spacing:.08em; font-family:'IBM Plex Mono',monospace; flex:none; display:flex; align-items:center; gap:3px;}
.sr-status .ecl{opacity:.85;}
.sr-bot{display:flex; align-items:center; gap:8px; margin-top:4px;}
.sr-norad{font-size:9px; color:var(--dim); flex:none;}
.mem-bar{flex:1; height:4px; background:rgba(255,255,255,0.07); border-radius:2px; overflow:hidden;}
.mem-bar span{display:block; height:100%; border-radius:2px; transition:width .2s;}
.sr-mem{font-size:9.5px; font-family:'IBM Plex Mono',monospace; flex:none; width:30px; text-align:right;}
/* activity */
.activity{flex:none; max-height:34%; display:flex; flex-direction:column; border-top:1px solid var(--line); overflow:hidden;}
.act-list{overflow-y:auto; padding:0 8px 8px;}
.act-empty{color:var(--faint); font-size:11px; padding:6px 10px; font-family:'IBM Plex Mono',monospace;}
.act-row{display:flex; gap:8px; padding:6px 8px; border-radius:6px; cursor:pointer; align-items:center;}
.act-row:hover{background:rgba(255,255,255,0.025);}
.act-row.focus{background:var(--teal-bg);}
.act-bar{width:3px; align-self:stretch; border-radius:2px; flex:none;}
.act-body{flex:1; min-width:0;}
.act-top{display:flex; gap:7px; align-items:baseline;}
.act-type{font-size:9.5px; letter-spacing:.1em; font-family:'IBM Plex Mono',monospace;}
.act-tgt{font-size:11px; color:var(--hi); white-space:nowrap; overflow:hidden; text-overflow:ellipsis;}
.act-sub{display:flex; justify-content:space-between; font-size:9.5px; color:var(--dim); margin-top:2px; font-family:'IBM Plex Mono',monospace;}
.act-rem{color:var(--mid);}
/* ---------- map ---------- */
.map-area{flex:1; position:relative; min-height:0; overflow:hidden; background:var(--ins);}
.map-wrap{position:absolute; inset:0;}
.map-wrap canvas{display:block;}
.map-caption{position:absolute; top:12px; left:12px; z-index:3;}
.cap-hint{font-family:'IBM Plex Mono',monospace; font-size:9.5px; letter-spacing:.12em; color:var(--faint);
background:rgba(10,14,13,0.6); border:1px solid var(--line); padding:6px 10px; border-radius:6px; white-space:nowrap;}
.focus-card{display:flex; align-items:stretch; gap:11px; background:rgba(11,16,15,0.92); backdrop-filter:blur(6px);
border:1px solid var(--teal-br); border-radius:8px; padding:10px 13px; min-width:330px;}
.fc-pay{width:3px; border-radius:2px; flex:none;}
.fc-info{flex:1;}
.fc-name{font-size:14px; font-weight:600; display:flex; justify-content:space-between; align-items:center;}
.fc-x{background:none; border:0; color:var(--dim); font-size:12px; padding:0 2px;}
.fc-x:hover{color:var(--hi);}
.fc-meta{font-size:9.5px; color:var(--mid); margin-top:4px; display:flex; gap:6px; align-items:center; letter-spacing:.04em;}
.fc-dot{color:var(--faint);}
.fc-pos{text-align:right; font-family:'IBM Plex Mono',monospace; font-size:10px; color:var(--mid); display:flex; flex-direction:column; justify-content:center; gap:4px;}
.fc-mem b{color:var(--hi);}
.map-legend{position:absolute; bottom:12px; left:12px; z-index:3; background:rgba(10,14,13,0.55);
border:1px solid var(--line); border-radius:7px; padding:8px 11px; display:flex; flex-direction:column; gap:5px;}
.ml-row{display:flex; align-items:center; gap:8px; font-family:'IBM Plex Mono',monospace; font-size:9px;
letter-spacing:.08em; color:var(--mid); white-space:nowrap;}
.ml-tri{width:0;height:0;border-left:4px solid transparent;border-right:4px solid transparent;border-bottom:7px solid #cdd6d3;}
.ml-sw{width:14px; height:3px; border-radius:2px; background:var(--teal);}
.ml-sw.amber{background:var(--amber);}
.ml-sw.blue{background:var(--blue);}
.ml-sw.blue.dash{background:repeating-linear-gradient(90deg,var(--blue) 0 4px,transparent 4px 7px);}
.ml-sw.night{background:rgba(90,120,170,0.5); height:9px; width:14px; border-radius:2px;}
.map-cursor{position:absolute; bottom:10px; right:14px; z-index:3; font-family:'IBM Plex Mono',monospace;
font-size:10px; color:var(--mid); background:rgba(10,14,13,0.6); padding:3px 8px; border-radius:5px; border:1px solid var(--line);}
.map-tip{position:absolute; z-index:4; pointer-events:none; font-family:'IBM Plex Mono',monospace; font-size:10px;
background:rgba(11,16,15,0.95); border:1px solid var(--line2); padding:3px 7px; border-radius:5px; color:var(--hi); white-space:nowrap;}
/* ---------- timeline ---------- */
.tl-area{height:248px; flex:none; border-top:1px solid var(--line); background:var(--bg1); display:flex; flex-direction:column;}
.tl-head{height:34px; flex:none; display:flex; align-items:center; gap:18px; padding:0 16px; border-bottom:1px solid var(--line);}
.tl-title{font-family:'IBM Plex Mono',monospace; font-size:10px; letter-spacing:.16em; color:var(--mid);}
.tl-legend{display:flex; gap:15px; flex:1;}
.leg-item{display:flex; align-items:center; gap:6px; font-family:'IBM Plex Mono',monospace; font-size:9px;
letter-spacing:.08em; color:var(--dim);}
.leg-sw{width:13px; height:8px; border-radius:2px; display:inline-block;}
.leg-sw.hatch{background:repeating-linear-gradient(45deg,rgba(217,104,95,0.7) 0 2px,rgba(217,104,95,0.12) 2px 5px); border:1px solid rgba(217,104,95,0.5);}
.tl-hint{font-family:'IBM Plex Mono',monospace; font-size:9.5px; color:var(--faint); letter-spacing:.06em;}
.tl-wrap{flex:1; overflow-y:auto; overflow-x:hidden; user-select:none; cursor:crosshair;}
.tl-tick{fill:var(--dim); font-size:9px; font-family:'IBM Plex Mono',monospace;}
.tl-group{fill:var(--faint); font-size:9px; letter-spacing:.14em; font-family:'IBM Plex Mono',monospace;}
.tl-name{fill:var(--mid); font-size:10.5px;}
.tl-nowlbl{fill:rgba(95,179,163,0.6); font-size:8px; letter-spacing:.12em; font-family:'IBM Plex Mono',monospace;}
@@ -0,0 +1,73 @@
/* ============================================================
data.js — constellation model (ESM)
Группировка: ОПТИКА + ЛОКАЦИЯ, с группами КА.
См. INTEGRATION.md — эти массивы предполагается заменить данными из API.
============================================================ */
export const COL = {
optic: "#56c2c8", // оптика — cyan
radar: "#a78bdb", // локация (РСА) — violet
};
// --- Satellites -------------------------------------------------
// orbit: latAmp(deg amplitude of sub-sat latitude), period(min),
// phase(0..1 of period), lon0(deg start longitude)
// mem0: initial onboard memory fill %
const sats = [
// ============ ОПТИКА ============
{ id: "res3", name: "Ресурс-П №3", norad: 41386, payload: "optic", domain: "ОПТИКА", group: "Ресурс-П",
orbit: { latAmp: 81, period: 94.2, phase: 0.02, lon0: 12 }, mem0: 38 },
{ id: "res4", name: "Ресурс-П №4", norad: 41387, payload: "optic", domain: "ОПТИКА", group: "Ресурс-П",
orbit: { latAmp: 81, period: 94.6, phase: 0.34, lon0: 155 }, mem0: 64 },
{ id: "res5", name: "Ресурс-П №5", norad: 41388, payload: "optic", domain: "ОПТИКА", group: "Ресурс-П",
orbit: { latAmp: 81, period: 94.4, phase: 0.67, lon0: -98 }, mem0: 21 },
{ id: "kan6", name: "Канопус-В №6", norad: 43657, payload: "optic", domain: "ОПТИКА", group: "Канопус-В",
orbit: { latAmp: 78, period: 96.1, phase: 0.12, lon0: 64 }, mem0: 52 },
{ id: "kan7", name: "Канопус-В №7", norad: 43658, payload: "optic", domain: "ОПТИКА", group: "Канопус-В",
orbit: { latAmp: 78, period: 96.4, phase: 0.55, lon0: -150 }, mem0: 79 },
// ============ ЛОКАЦИЯ (РСА) ============
{ id: "kon1", name: "Кондор-ФКА №1", norad: 57350, payload: "radar", domain: "ЛОКАЦИЯ", group: "Кондор-ФКА",
orbit: { latAmp: 74, period: 92.7, phase: 0.20, lon0: 33 }, mem0: 45 },
{ id: "kon2", name: "Кондор-ФКА №2", norad: 62138, payload: "radar", domain: "ЛОКАЦИЯ", group: "Кондор-ФКА",
orbit: { latAmp: 74, period: 92.9, phase: 0.62, lon0: 178 }, mem0: 17 },
{ id: "kon3", name: "Кондор-ФКА №3", norad: 62139, payload: "radar", domain: "ЛОКАЦИЯ", group: "Кондор-ФКА",
orbit: { latAmp: 74, period: 92.5, phase: 0.88, lon0: -70 }, mem0: 70 },
{ id: "obz1", name: "Обзор-Р №1", norad: 60001, payload: "radar", domain: "ЛОКАЦИЯ", group: "Обзор-Р",
orbit: { latAmp: 67, period: 97.8, phase: 0.30, lon0: 100 }, mem0: 33 },
{ id: "obz2", name: "Обзор-Р №2", norad: 60002, payload: "radar", domain: "ЛОКАЦИЯ", group: "Обзор-Р",
orbit: { latAmp: 67, period: 98.1, phase: 0.74, lon0: -25 }, mem0: 58 },
];
sats.forEach((s) => { s.color = COL[s.payload]; });
// --- Ground stations -------------------------------------------
const stations = [
{ id: "msk", name: "Москва", lon: 37.6, lat: 55.7 },
{ id: "mur", name: "Мурманск", lon: 33.1, lat: 68.9 },
{ id: "ana", name: "Анадырь", lon: 177.5, lat: 64.7 },
{ id: "kha", name: "Хабаровск", lon: 135.1, lat: 48.5 },
{ id: "kgd", name: "Калининград", lon: 20.5, lat: 54.7 },
{ id: "svb", name: "Шпицберген", lon: 15.6, lat: 78.2 },
{ id: "ant", name: "Прогресс", lon: 76.4, lat: -69.4 },
];
// --- Targets / ROIs (заявки на съёмку) -------------------------
// rect helper: center + half-width/height in degrees
function rect(lon, lat, hw, hh) {
return [
[lon - hw, lat - hh], [lon + hw, lat - hh],
[lon + hw, lat + hh], [lon - hw, lat + hh],
];
}
const rois = [
{ id: "gib", name: "Гибралтар", payload: "optic", poly: rect(-5.4, 36.0, 3.2, 2.4) },
{ id: "blk", name: "Чёрное море", payload: "optic", poly: rect(34.0, 43.5, 6.0, 3.0) },
{ id: "jpn", name: "Японские о-ва", payload: "radar", poly: rect(140.5, 37.5, 3.4, 6.5) },
{ id: "kam", name: "Камчатка", payload: "radar", poly: rect(159.0, 56.0, 4.5, 5.0) },
{ id: "cal", name: "Калифорния", payload: "optic", poly: rect(-119.5, 36.5, 3.5, 4.5) },
{ id: "syr", name: "Сирия", payload: "optic", poly: rect(38.5, 35.0, 3.0, 2.6) },
{ id: "sah", name: "о.Сахалин", payload: "radar", poly: rect(143.0, 50.5, 2.2, 4.8) },
{ id: "kur", name: "Курилы", payload: "radar", poly: rect(151.0, 46.0, 2.0, 3.6) },
];
export { sats as SATELLITES, stations as STATIONS, rois as ROIS };
@@ -0,0 +1,10 @@
/* index.js — public entry for the dashboard module */
export { default } from "./ConstellationDashboard.jsx";
export { default as ConstellationDashboard } from "./ConstellationDashboard.jsx";
// granular exports (in case you want to recompose the layout)
export { default as MapView } from "./MapView.jsx";
export { default as Timeline } from "./Timeline.jsx";
export { Header, SidePanel, STATUS_META, fmtDur } from "./Panels.jsx";
export * as SIM from "./sim.js";
export { SATELLITES, STATIONS, ROIS, COL } from "./data.js";
@@ -0,0 +1,424 @@
/* ============================================================
realData.ts — реальный источник данных для дашборда «Группировка».
Заменяет встроенную симуляцию (data.js/sim.js) данными из API по контрактам
INTEGRATION.md §5:
• состав — платформы НСИ (tgu-planning), станции, заявки (ROI);
• положение — реальная трасса flight-line (сэмплер позиции по времени);
• сброс — зоны радиовидимости (ЗРВ) из баллистики;
• съёмка — пролёт подспутниковой точки над полигоном заявки;
• тень — астрономическая модель из sim.js.
Возвращает { env, domain, sched } в том же виде, что buildEnv() прототипа,
так что ConstellationDashboard рендерит реальные данные без изменений разметки.
============================================================ */
import { fetchPlatforms, fetchFlightLine, fetchPlans, type FlightLinePoint } from "../../api/tguApi";
import { fetchStations, type StationDto } from "../../api/stationsApi";
import { fetchRequestsForMap } from "../../api/requestApi";
import { fetchSatelliteRva } from "../../api/ballisticsApi";
import { fetchPlanModes, type PlanMode } from "../tgu-editor/editorApi";
import type { TguPlatform, TguPlan } from "../../model/tguTypes";
import type { RequestMapItem } from "../../model/requestTypes";
import { isOperationalPlatform } from "../tgu-planning/tguTimelineMapper";
import { parseWktPolygon } from "../tgu-map-2d/geometry/wktParser";
import { parseLocal } from "../../utils/localTime";
// sim.js/data.js — JS-модули прототипа (allowJs); типы any, нам достаточно.
import { buildSchedule, wrapLon, pointInPoly } from "./sim.js";
import { COL } from "./data.js";
/** Полный объём бортового накопителя, Мбайт (500 ГБ). Согласовано как модельная ёмкость. */
const ONBOARD_CAPACITY_MB = 500 * 1024;
type Payload = "optic" | "radar";
export interface ConstellationSat {
id: string;
name: string;
norad: number;
payload: Payload;
domain: "ОПТИКА" | "ЛОКАЦИЯ";
group: string;
color: string;
mem0: number;
}
export interface ConstellationStation {
id: string;
name: string;
lon: number;
lat: number;
}
export interface ConstellationRoi {
id: string;
name: string;
payload: Payload;
poly: [number, number][];
}
export interface Interval {
start: number;
end: number;
label?: string;
/** Контур съёмки (для подсветки на карте), [lon,lat][]. Только у интервалов capture. */
poly?: [number, number][];
/** Координаты станции приёма (для линии сброса на карте). Только у интервалов downlink. */
lon?: number;
lat?: number;
}
export interface SchedTracks {
capture: Interval[];
downlink: Interval[];
/** Связь с КА — окна радиовидимости (ЗРВ). Раньше показывались как «сброс». */
link: Interval[];
prohibit: Interval[];
shadow: Interval[];
}
export interface ConstellationEnv {
epochMs: number;
sats: ConstellationSat[];
stations: ConstellationStation[];
rois: ConstellationRoi[];
prohibits: Record<string, Interval[]>;
/** Подспутниковая точка по реальной трассе; null — нет данных на это время. */
subSat: (sat: ConstellationSat, tMs: number) => { lon: number; lat: number } | null;
/** Готовое расписание (съёмка/сброс/связь/тень) — из него statusAt берёт состояние. */
sched?: Record<string, SchedTracks>;
/** Заполнение бортового накопителя на момент tMs, % от ёмкости. Из объёмов маршрутов плана. */
memAt?: (sat: ConstellationSat, tMs: number) => number;
}
export interface ConstellationConfig {
env: ConstellationEnv;
domain: { start: number; end: number };
sched: Record<string, SchedTracks>;
}
// --- классификация полезной нагрузки по имени КА (из НСИ) -------------------
// Поля payload у платформы нет — определяем по имени/миссии (радиолокация vs оптика).
const RADAR_KEYS = [
"кондор", "kondor", "обзор", "obzor", "sar", "рса", "radar", "локац",
"аркон", "arkon", "стрела", "strela", "пион", "neitron", "нейтрон"
];
function classifyPayload(name: string, mission: string | null | undefined): Payload {
const s = `${name} ${mission ?? ""}`.toLowerCase();
return RADAR_KEYS.some((k) => s.includes(k)) ? "radar" : "optic";
}
// Человеческие названия известных миссий НСИ → подгруппа в списке/таймлайне.
const MISSION_RU: Record<string, string> = {
RESURS_P: "Ресурс-П",
KANOPUS_V: "Канопус-В",
KONDOR_FKA: "Кондор-ФКА",
OBZOR_R: "Обзор-Р",
TERRA_AQUA: "Terra/Aqua"
};
function prettifyMission(mission: string): string {
return mission
.toLowerCase()
.split("_")
.map((w) => (w ? w[0].toUpperCase() + w.slice(1) : w))
.join(" ");
}
function deriveGroup(name: string, mission: string | null | undefined): string {
if (mission) return MISSION_RU[mission] ?? prettifyMission(mission);
// нет миссии — отрезаем хвост «No.3» / «№3» / завершающий номер
const base = name.replace(/\s*(No\.?|№)\s*\d+.*$/i, "").trim();
return base || name;
}
// --- сэмплер позиции по трассе flight-line ---------------------------------
type Sample = { t: number; lon: number; lat: number };
function buildSampler(points: FlightLinePoint[]): (t: number) => { lon: number; lat: number } | null {
const arr: Sample[] = points
.map((p) => ({ t: parseLocal(p.time), lon: wrapLon(p.long), lat: p.lat }))
.filter((s) => Number.isFinite(s.t) && Number.isFinite(s.lon) && Number.isFinite(s.lat))
.sort((a, b) => a.t - b.t);
if (arr.length === 0) return () => null;
const first = arr[0];
const last = arr[arr.length - 1];
return (t: number) => {
if (t <= first.t) return { lon: first.lon, lat: first.lat };
if (t >= last.t) return { lon: last.lon, lat: last.lat };
let lo = 0;
let hi = arr.length - 1;
while (hi - lo > 1) {
const mid = (lo + hi) >> 1;
if (arr[mid].t <= t) lo = mid;
else hi = mid;
}
const a = arr[lo];
const b = arr[hi];
const f = (t - a.t) / (b.t - a.t || 1);
const lat = a.lat + (b.lat - a.lat) * f;
// долготу интерполируем по кратчайшей дельте, чтобы не прыгать через антимеридиан
let dlon = b.lon - a.lon;
if (dlon > 180) dlon -= 360;
else if (dlon < -180) dlon += 360;
return { lon: wrapLon(a.lon + dlon * f), lat };
};
}
// --- ROI из заявок ----------------------------------------------------------
const DEAD_REQUEST = new Set(["DELETED", "EXPIRED"]);
function roiFromRequest(item: RequestMapItem): ConstellationRoi | null {
if (DEAD_REQUEST.has(item.status)) return null;
const rings = parseWktPolygon(item.geometry);
const ring = rings[0];
if (!ring || ring.length < 3) return null;
const poly: [number, number][] = ring.map((pt) => [wrapLon(pt.lon), pt.lat]);
const payload: Payload = item.surveyType === "RSA" ? "radar" : "optic";
return { id: item.id, name: item.name || item.id, payload, poly };
}
// --- порядок КА: домен (ОПТИКА → ЛОКАЦИЯ) → группа → имя --------------------
// Список и таймлайн группируют по соседству, поэтому порядок важен.
function compareSats(a: ConstellationSat, b: ConstellationSat): number {
if (a.domain !== b.domain) return a.domain === "ОПТИКА" ? -1 : 1;
if (a.group !== b.group) return a.group.localeCompare(b.group, "ru");
return a.name.localeCompare(b.name, "ru");
}
// --- съёмка/сброс из реального плана -----------------------------------------
type StationGeo = { name: string; lon: number; lat: number };
interface PlanTracks {
capture: Interval[];
downlink: Interval[];
/** Точки изменения заполнения борта: +volumeMb на конце съёмки, −volumeMb на конце сброса. */
memEvents: { t: number; delta: number }[];
}
// Историю тянем так, чтобы покрыть начало горизонта (now-6ч): дни назад + запас.
function planHistoryDays(domainStart: number): number {
const back = Math.ceil(Math.max(0, Date.now() - domainStart) / 86400e3);
return Math.max(1, back + 1);
}
// Имя заявки, над которой идёт съёмка (по центру контура) — для подписи на таймлайне/карте.
function captureLabel(lon: number, lat: number, rois: ConstellationRoi[]): string {
for (const r of rois) if (pointInPoly(lon, lat, r.poly)) return r.name;
return "Съёмка";
}
// Режимы плана (SURVEY/DROP) → интервалы съёмки/сброса + дельты заполнения борта.
function buildPlanTracks(
modes: PlanMode[],
rois: ConstellationRoi[],
stationByNumber: Map<number, StationGeo>
): PlanTracks {
const capture: Interval[] = [];
const downlink: Interval[] = [];
const memEvents: { t: number; delta: number }[] = [];
for (const m of modes) {
const start = m.timeStart ? parseLocal(m.timeStart) : NaN;
if (!Number.isFinite(start)) continue;
const end = start + (m.duration ?? 0) * 1000;
const vol = m.volumeMb ?? 0;
if (m.type === "SURVEY") {
const rings = m.contourWkt ? parseWktPolygon(m.contourWkt) : [];
const poly = rings[0]?.map((pt) => [wrapLon(pt.lon), pt.lat] as [number, number]);
capture.push({ start, end, label: captureLabel(m.longitude, m.lat, rois), poly });
memEvents.push({ t: end, delta: vol });
} else if (m.type === "DROP") {
const st = m.station != null ? stationByNumber.get(Number(m.station)) : undefined;
downlink.push({
start,
end,
label: st?.name ?? (m.station != null ? `ст. ${m.station}` : "Сброс"),
lon: st?.lon,
lat: st?.lat
});
memEvents.push({ t: end, delta: -vol });
}
}
capture.sort((a, b) => a.start - b.start);
downlink.sort((a, b) => a.start - b.start);
memEvents.sort((a, b) => a.t - b.t);
return { capture, downlink, memEvents };
}
// Планы из pcp-tgu, пересекающие горизонт, → их режимы по КА (spacecraftId == NORAD).
async function loadPlanModesBySat(
satIds: Set<string>,
domainStart: number,
domainEnd: number
): Promise<Map<string, PlanMode[]>> {
const byId = new Map<string, PlanMode[]>();
let plans: TguPlan[];
try {
plans = await fetchPlans(planHistoryDays(domainStart));
} catch {
return byId; // нет планов — съёмка/сброс просто не покажутся
}
const relevant = plans.filter((p) => {
if (!satIds.has(p.spacecraftId)) return false;
const s = parseLocal(p.startTime);
const e = parseLocal(p.endTime);
return Number.isFinite(s) && Number.isFinite(e) && e >= domainStart && s <= domainEnd;
});
const loaded = await Promise.all(
relevant.map((p) =>
fetchPlanModes(p.planId)
.then((m) => ({ sid: p.spacecraftId, m }))
.catch(() => ({ sid: p.spacecraftId, m: [] as PlanMode[] }))
)
);
for (const { sid, m } of loaded) {
const arr = byId.get(sid) ?? [];
arr.push(...m);
byId.set(sid, arr);
}
return byId;
}
// Заполнение борта на момент t: накопленный объём маршрутов (старт 0), % от ёмкости.
function memorySampler(memEvents: { t: number; delta: number }[]): (t: number) => number {
if (memEvents.length === 0) return () => 0;
return (t: number) => {
let mb = 0;
for (const e of memEvents) {
if (e.t > t) break;
mb += e.delta;
}
const pct = (Math.max(0, Math.min(ONBOARD_CAPACITY_MB, mb)) / ONBOARD_CAPACITY_MB) * 100;
return pct;
};
}
/**
* Грузит реальный состав и геометрию группировки и собирает конфиг дашборда.
* Горизонт по умолчанию now-6ч … now+18ч (как в прототипе). КА без трассы
* (например, не эксплуатируемые) на карту не попадают.
*/
export async function loadConstellationEnv(): Promise<ConstellationConfig> {
const now = Date.now();
const domain = { start: now - 6 * 3600e3, end: now + 18 * 3600e3 };
const [platforms, stationDtos, requestItems] = await Promise.all([
fetchPlatforms(),
fetchStations(),
fetchRequestsForMap({})
.then((r) => r.items)
.catch(() => [] as RequestMapItem[])
]);
const stations: ConstellationStation[] = (stationDtos as StationDto[]).map((s) => ({
id: s.id ?? String(s.number),
name: s.name,
lon: s.position.long,
lat: s.position.lat
}));
const stationByNumber = new Map<number, StationGeo>(
(stationDtos as StationDto[]).map((s) => [
s.number,
{ name: s.name, lon: s.position.long, lat: s.position.lat }
])
);
const rois: ConstellationRoi[] = requestItems
.map(roiFromRequest)
.filter((r): r is ConstellationRoi => r !== null);
// Эксплуатируемые платформы с NORAD — кандидаты в группировку.
const candidates = (platforms as TguPlatform[]).filter(
(p) => isOperationalPlatform(p) && p.noradId != null
);
// Трассу и ЗРВ тянем по каждому КА параллельно; сбой по одному непрозрачен.
const perSat = await Promise.all(
candidates.map(async (p) => {
const norad = Number(p.noradId);
const [line, rva] = await Promise.all([
fetchFlightLine(String(norad), domain.start, domain.end).catch(() => [] as FlightLinePoint[]),
fetchSatelliteRva(norad, domain.start, domain.end).catch(() => [])
]);
return { p, norad, line, rva };
})
);
const sats: ConstellationSat[] = [];
const samplers: Record<string, (t: number) => { lon: number; lat: number } | null> = {};
const rvaBySat: Record<string, typeof perSat[number]["rva"]> = {};
for (const { p, norad, line, rva } of perSat) {
if (line.length === 0) continue; // нет трассы — на карте не разместить
const id = String(norad);
const name = p.name ?? id;
const payload = classifyPayload(name, p.mission);
sats.push({
id,
name,
norad,
payload,
domain: payload === "radar" ? "ЛОКАЦИЯ" : "ОПТИКА",
group: deriveGroup(name, p.mission),
color: COL[payload],
mem0: (norad % 60) + 15 // памяти борта нет в телеметрии — модельная стартовая
});
samplers[id] = buildSampler(line);
rvaBySat[id] = rva;
}
sats.sort(compareSats);
// Запреты работы (ВНЗ/питание) — источника в проекте нет: оставляем пустыми (заготовка).
const prohibits: Record<string, Interval[]> = Object.fromEntries(sats.map((s) => [s.id, []]));
const env: ConstellationEnv = {
epochMs: now,
sats,
stations,
rois,
prohibits,
subSat: (sat, t) => (samplers[sat.id] ? samplers[sat.id](t) : null)
};
// Съёмку и сброс берём из реального плана; тень считаем геометрически по трассе.
const satIds = new Set(sats.map((s) => s.id));
const planModesBySat = await loadPlanModesBySat(satIds, domain.start, domain.end);
// buildSchedule даёт нам тень (shadow) по реальным позициям; съёмку/сброс из него игнорируем.
const sched = buildSchedule(sats, env, domain.start, domain.end, 0.5) as ConstellationConfig["sched"];
const memSamplers: Record<string, (t: number) => number> = {};
for (const s of sats) {
const tracks = sched[s.id];
// Съёмка и сброс — из режимов плана (SURVEY/DROP).
const plan = buildPlanTracks(planModesBySat.get(s.id) ?? [], rois, stationByNumber);
tracks.capture = plan.capture;
tracks.downlink = plan.downlink;
memSamplers[s.id] = memorySampler(plan.memEvents);
// «Связь с КА» — окна радиовидимости (ЗРВ). Раньше показывались как «сброс».
const rva = rvaBySat[s.id];
tracks.link = (rva ?? [])
.map((z) => {
const st = stationByNumber.get(z.stationId);
return {
start: parseLocal(z.onStart.time),
end: parseLocal(z.onStop.time),
label: st?.name ?? `ст. ${z.stationId}`,
lon: st?.lon,
lat: st?.lat
};
})
.filter((iv) => Number.isFinite(iv.start) && Number.isFinite(iv.end) && iv.end > iv.start)
.sort((a, b) => a.start - b.start);
}
// Расписание и заполнение борта — на env, чтобы statusAt/панель читали авторитетные данные.
env.sched = sched;
env.memAt = (sat, t) => (memSamplers[sat.id] ? memSamplers[sat.id](t) : 0);
return { env, domain, sched };
}
@@ -0,0 +1,183 @@
/* ============================================================
sim.js — geometry + schedule (ESM)
Чистая геометрия подспутниковой точки, тень, расписание событий.
См. INTEGRATION.md — subSat()/statusAt() можно заменить на реальную эфемериду/API.
============================================================ */
const D2R = Math.PI / 180, R2D = 180 / Math.PI;
const TWO_PI = Math.PI * 2;
const SIDEREAL_MIN = 1436.07; // earth rotation period (min)
function wrapLon(lon) {
lon = ((lon + 180) % 360 + 360) % 360 - 180;
return lon;
}
// sub-satellite point at sim time t(ms), given epoch ms.
function subSat(sat, tMs, epochMs) {
const m = (tMs - epochMs) / 60000; // minutes since epoch
const o = sat.orbit;
const M = o.phase * TWO_PI + TWO_PI * (m / o.period);
const lat = o.latAmp * Math.sin(M);
// one sine wave per orbit across full longitude, drifting west via earth rotation
const lon = wrapLon(o.lon0 + 360 * (m / o.period) - 360 * (m / SIDEREAL_MIN));
return { lon, lat };
}
// subsolar point (approx, declination ignored -> equatorial)
function subSolar(tMs) {
const secOfDay = (tMs / 1000) % 86400;
const lon = wrapLon(180 - (secOfDay / 86400) * 360);
// small seasonal declination wobble for life
const dayFrac = (tMs / 86400000) % 365.25;
const lat = 23.4 * Math.sin((dayFrac / 365.25) * TWO_PI - 1.4);
return { lon, lat };
}
// angular (great-circle) distance in degrees
function angDist(lon1, lat1, lon2, lat2) {
const φ1 = lat1 * D2R, φ2 = lat2 * D2R;
const = (lat2 - lat1) * D2R;
const = (lon2 - lon1) * D2R;
const a = Math.sin( / 2) ** 2 + Math.cos(φ1) * Math.cos(φ2) * Math.sin( / 2) ** 2;
return 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)) * R2D;
}
function inShadow(lon, lat, tMs) {
const ss = subSolar(tMs);
return angDist(lon, lat, ss.lon, ss.lat) > 90;
}
// point in polygon (ray casting), poly = [[lon,lat],...]
function pointInPoly(lon, lat, poly) {
let inside = false;
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
const xi = poly[i][0], yi = poly[i][1];
const xj = poly[j][0], yj = poly[j][1];
const intersect = (yi > lat) !== (yj > lat) &&
lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi;
if (intersect) inside = !inside;
}
return inside;
}
const STATION_RANGE = 22; // deg ground range for downlink visibility
// which ROI is the sat over (matching payload), null if none
function overROI(sat, p, rois) {
for (const r of rois) {
if (r.payload !== sat.payload) continue;
if (pointInPoly(p.lon, p.lat, r.poly)) return r;
}
return null;
}
// best (nearest in-range) station, null if none
function nearStation(p, stations) {
let best = null, bd = STATION_RANGE;
for (const st of stations) {
const d = angDist(p.lon, p.lat, st.lon, st.lat);
if (d < bd) { bd = d; best = st; }
}
return best;
}
// --- prohibition windows (интервалы запрета) per sat ----------
function buildProhibits(sats, epochMs) {
const H = 3600e3;
const out = {};
sats.forEach((s, i) => {
const list = [];
// deterministic-ish spread
const a0 = (i * 1.9) % 22 - 4; // hours from epoch
list.push({ start: epochMs + a0 * H, end: epochMs + (a0 + 0.7 + (i % 3) * 0.15) * H, reason: "ВНЗ" });
if (i % 2 === 0) {
const a1 = a0 + 8 + (i % 4);
list.push({ start: epochMs + a1 * H, end: epochMs + (a1 + 0.9) * H, reason: "ОГРАН. ПИТАНИЕ" });
}
out[s.id] = list;
});
return out;
}
function inProhibit(list, tMs) {
if (!list) return null;
for (const w of list) if (tMs >= w.start && tMs < w.end) return w;
return null;
}
// sub-satellite point: реальный сэмплер из env.subSat (эфемерида/flight-line),
// иначе встроенная синусоидальная симуляция. См. INTEGRATION.md §5.2.
function subSatOf(sat, tMs, env) {
return env.subSat ? env.subSat(sat, tMs) : subSat(sat, tMs, env.epochMs);
}
// interval containing t in a sorted/unsorted list, else null
function intervalAt(list, tMs) {
if (!list) return null;
for (const iv of list) if (tMs >= iv.start && tMs < iv.end) return iv;
return null;
}
// live status of a sat at time t (priority order)
// returns {state, roi, station, eclipse, prohibit}
// С реальным расписанием (env.sched) состояние берём из готовых интервалов плана
// (съёмка/сброс/запрет); иначе — встроенная геометрическая симуляция прототипа.
function statusAt(sat, tMs, env) {
const p = subSatOf(sat, tMs, env);
const eclipse = inShadow(p.lon, p.lat, tMs);
if (env.sched) {
const tr = env.sched[sat.id] || {};
const prohibit = intervalAt(tr.prohibit, tMs);
if (prohibit) return { state: "prohibit", p, prohibit: { reason: prohibit.label }, eclipse };
const cap = intervalAt(tr.capture, tMs);
if (cap) return { state: "capture", p, roi: { name: cap.label, poly: cap.poly }, eclipse };
const dl = intervalAt(tr.downlink, tMs);
if (dl) return { state: "downlink", p, station: { name: dl.label, lon: dl.lon, lat: dl.lat }, eclipse };
return { state: "operational", p, eclipse };
}
const prohibit = inProhibit(env.prohibits[sat.id], tMs);
if (prohibit) return { state: "prohibit", p, prohibit, eclipse };
const roi = overROI(sat, p, env.rois);
if (roi) return { state: "capture", p, roi, eclipse };
const st = nearStation(p, env.stations);
if (st) return { state: "downlink", p, station: st, eclipse };
return { state: "operational", p, eclipse };
}
// --- precompute event intervals over a domain for the timeline -
function buildSchedule(sats, env, domainStart, domainEnd, stepMin) {
const step = stepMin * 60000;
const sched = {};
for (const s of sats) {
const tracks = { capture: [], downlink: [], prohibit: [], shadow: [] };
// prohibits are explicit
(env.prohibits[s.id] || []).forEach((w) =>
tracks.prohibit.push({ start: w.start, end: w.end, label: w.reason }));
let cur = { capture: null, downlink: null, shadow: null };
for (let t = domainStart; t <= domainEnd; t += step) {
const p = subSatOf(s, t, env);
const proh = inProhibit(env.prohibits[s.id], t);
const shadow = inShadow(p.lon, p.lat, t);
const roi = !proh ? overROI(s, p, env.rois) : null;
const st = !proh && !roi ? nearStation(p, env.stations) : null;
// shadow band
if (shadow) { if (!cur.shadow) cur.shadow = { start: t, end: t }; else cur.shadow.end = t; }
else if (cur.shadow) { tracks.shadow.push(cur.shadow); cur.shadow = null; }
// capture band
if (roi) { if (!cur.capture) cur.capture = { start: t, end: t, label: roi.name }; else cur.capture.end = t; }
else if (cur.capture) { tracks.capture.push(cur.capture); cur.capture = null; }
// downlink band
if (st) { if (!cur.downlink) cur.downlink = { start: t, end: t, label: st.name }; else cur.downlink.end = t; }
else if (cur.downlink) { tracks.downlink.push(cur.downlink); cur.downlink = null; }
}
if (cur.shadow) tracks.shadow.push(cur.shadow);
if (cur.capture) tracks.capture.push(cur.capture);
if (cur.downlink) tracks.downlink.push(cur.downlink);
sched[s.id] = tracks;
}
return sched;
}
export {
D2R, R2D, wrapLon, subSat, subSolar, angDist, inShadow,
pointInPoly, overROI, nearStation, buildProhibits, inProhibit,
statusAt, intervalAt, buildSchedule, STATION_RANGE,
};
@@ -0,0 +1,9 @@
/* util.js — small shared helpers (ESM) */
// "#rrggbb" + alpha -> "rgba(...)"; passes through non-hex strings unchanged
export function hexA(hex, a) {
if (typeof hex !== "string" || hex[0] !== "#") return hex;
const n = parseInt(hex.slice(1), 16);
const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;
return `rgba(${r},${g},${b},${a})`;
}
@@ -0,0 +1,331 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import {
fetchComplexPlanRunModes,
fetchComplexPlanRuns,
runComplexPlan,
MAX_COMPLEX_PLAN_RANGE_DAYS,
MAX_COMPLEX_PLAN_RANGE_MS,
type ComplexPlanMode,
type ComplexPlanRunSummary
} from "../../api/complexPlanApi";
import { fetchRequestsForMap } from "../../api/requestApi";
import type { RequestMapItem } from "../../model/requestTypes";
import type { TguPlatformUi, TimelineRange } from "../../model/timelineTypes";
import { DateTime24Field } from "../../components/timeFields";
import { parseWktPolygon } from "../tgu-map-2d/geometry/wktParser";
import { buildTgu2DMapScene } from "../tgu-map-2d/Tgu2DMapLayers";
import type { Tgu2DMapLayersState } from "../tgu-map-2d/model/mapLayerTypes";
import type { MapPolygon, Tgu2DMapScene } from "../tgu-map-2d/model/mapTypes";
import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView";
import { formatLocalDateTime, parseLocal, toLocalInputValue, toLocalIso } from "../../utils/localTime";
import { formatVolume } from "../../utils/dataVolume";
type TguComplexPlanTabProps = {
range: TimelineRange;
/** Все эксплуатируемые КА вкладки «Планы» — по ним считаем комплексный план. */
platforms: TguPlatformUi[];
};
export function TguComplexPlanTab({ range, platforms }: TguComplexPlanTabProps) {
const [fromValue, setFromValue] = useState(() => toLocalInputValue(range.fromMs));
const [toValue, setToValue] = useState(() => toLocalInputValue(range.toMs));
const [running, setRunning] = useState(false);
const [modes, setModes] = useState<ComplexPlanMode[]>([]);
const [error, setError] = useState<string>();
const [notice, setNotice] = useState<string>();
// История прогонов и выбранный для просмотра прогон.
const [runs, setRuns] = useState<ComplexPlanRunSummary[]>([]);
const [runsError, setRunsError] = useState<string>();
const [selectedRunId, setSelectedRunId] = useState<number>();
const [loadingRun, setLoadingRun] = useState(false);
// Наложение заявок поверх контуров комплексного плана.
const [showRequests, setShowRequests] = useState(false);
const [requestItems, setRequestItems] = useState<RequestMapItem[]>([]);
const [requestsLoading, setRequestsLoading] = useState(false);
const [requestsError, setRequestsError] = useState<string>();
// NORAD-id всех эксплуатируемых КА «Планов» (spacecraftId == norad-id, см. tguTimelineMapper).
const satelliteIds = useMemo(
() =>
Array.from(
new Set(
platforms
.map((platform) => Number(platform.spacecraftId))
.filter((id) => Number.isFinite(id))
)
),
[platforms]
);
const parsedRange = useMemo(
() => ({ fromMs: parseLocal(fromValue), toMs: parseLocal(toValue) }),
[fromValue, toValue]
);
const rangeInvalid =
!Number.isFinite(parsedRange.fromMs) ||
!Number.isFinite(parsedRange.toMs) ||
parsedRange.fromMs >= parsedRange.toMs;
const rangeTooLong =
!rangeInvalid && parsedRange.toMs - parsedRange.fromMs > MAX_COMPLEX_PLAN_RANGE_MS;
const selectedRun = useMemo(
() => runs.find((run) => run.runId === selectedRunId),
[runs, selectedRunId]
);
// Интервал, по которому грузим заявки: показанный прогон (если выбран) либо поля «С/По».
const activeInterval = useMemo(() => {
if (selectedRun) {
return { fromMs: parseLocal(selectedRun.intervalStart), toMs: parseLocal(selectedRun.intervalEnd) };
}
return parsedRange;
}, [selectedRun, parsedRange]);
const modePolygons = useMemo(() => buildModePolygons(modes), [modes]);
const requestPolygons = useMemo(
() => (showRequests ? buildTgu2DMapScene({ range, platforms, requestItems }).polygons : []),
[showRequests, requestItems, range, platforms]
);
const scene = useMemo<Tgu2DMapScene>(
() => ({ polygons: [...modePolygons, ...requestPolygons], lines: [], markers: [] }),
[modePolygons, requestPolygons]
);
const layers = useMemo<Tgu2DMapLayersState>(
() => ({
tracks: false,
swath: false,
planWorks: false,
complexPlan: true,
stations: false,
spacecraftMarkers: false,
requests: showRequests
}),
[showRequests]
);
const loadRuns = useCallback(async (): Promise<ComplexPlanRunSummary[]> => {
setRunsError(undefined);
try {
const items = await fetchComplexPlanRuns();
setRuns(items);
return items;
} catch (caught) {
setRunsError(caught instanceof Error ? caught.message : "Не удалось загрузить историю расчётов.");
return [];
}
}, []);
const loadRunModes = useCallback(async (runId: number) => {
setSelectedRunId(runId);
setLoadingRun(true);
setError(undefined);
try {
const runModes = await fetchComplexPlanRunModes(runId);
setModes(runModes);
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Не удалось загрузить результат расчёта.");
} finally {
setLoadingRun(false);
}
}, []);
// На входе во вкладку подтягиваем историю и сразу показываем последний построенный план.
useEffect(() => {
void (async () => {
const items = await loadRuns();
const latest = items.find((run) => run.status === "COMPLETED" && run.calculatedModesCount > 0) ?? items[0];
if (latest) void loadRunModes(latest.runId);
})();
}, [loadRuns, loadRunModes]);
// Заявки за активный интервал (по тумблеру) — для сопоставления контуров плана с заявками.
useEffect(() => {
if (!showRequests) return;
const { fromMs, toMs } = activeInterval;
if (!Number.isFinite(fromMs) || !Number.isFinite(toMs) || fromMs >= toMs) return;
let cancelled = false;
setRequestsLoading(true);
setRequestsError(undefined);
fetchRequestsForMap({ endFrom: toLocalIso(fromMs), beginTo: toLocalIso(toMs) })
.then((result) => {
if (!cancelled) setRequestItems(result.items);
})
.catch((caught) => {
if (!cancelled) setRequestsError(caught instanceof Error ? caught.message : "Не удалось загрузить заявки.");
})
.finally(() => {
if (!cancelled) setRequestsLoading(false);
});
return () => {
cancelled = true;
};
}, [showRequests, activeInterval]);
const runCalculation = async () => {
if (running || rangeInvalid || rangeTooLong || satelliteIds.length === 0) return;
setRunning(true);
setError(undefined);
setNotice(undefined);
try {
const runResult = await runComplexPlan({
fromMs: parsedRange.fromMs,
toMs: parsedRange.toMs,
satelliteIds
});
await loadRuns();
await loadRunModes(runResult.runId);
setNotice(
runResult.calculatedModesCount > 0
? `Расчёт завершён: режимов ${runResult.calculatedModesCount}, КА затронуто ${runResult.affectedSatellitesCount}, охват ${formatPercent(runResult.coveredAreaPercent)}.`
: "Расчёт завершён, но включений не построено: на интервал нет покрытия заявками."
);
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Не удалось рассчитать комплексный план.");
} finally {
setRunning(false);
}
};
const canRun = !running && !rangeInvalid && !rangeTooLong && satelliteIds.length > 0;
const busy = running || loadingRun;
const overlayMessage = busy
? running
? "Идёт расчёт…"
: "Загрузка плана…"
: rangeInvalid
? "Некорректный интервал: «С» должно быть раньше «По»."
: modes.length === 0
? "Задайте интервал и нажмите «Рассчитать» либо выберите построенный план из истории."
: undefined;
const displayRun = selectedRun;
const contoursCount = modePolygons.length;
// Суммарный объём режимов run’а (МБ) — съёмка по скорости накопления, сброс по скорости канала.
const totalVolumeMb = modes.reduce((sum, mode) => sum + (mode.volumeMb ?? 0), 0);
return (
<main className="tgu-map-workspace">
<div className="tgu-map-toolbar">
<div>
<div className="tgu-map-toolbar__title">Комплексный план</div>
<div className="tgu-map-toolbar__subtitle">
Расчёт по {satelliteIds.length} КА вкладки «Планы»
</div>
</div>
<label className="tgu-field">
<span>С</span>
<DateTime24Field value={fromValue} onChange={setFromValue} />
</label>
<label className="tgu-field">
<span>По</span>
<DateTime24Field value={toValue} onChange={setToValue} />
</label>
<button className="tgu-button tgu-button--primary" type="button" onClick={runCalculation} disabled={!canRun}>
{running ? "Расчёт…" : "Рассчитать"}
</button>
<label className="tgu-field">
<span>Построенные планы</span>
<select
className="tgu-select"
value={selectedRunId ?? ""}
disabled={runs.length === 0 || busy}
onChange={(event) => {
const value = Number(event.target.value);
if (Number.isFinite(value)) void loadRunModes(value);
}}
>
{runs.length === 0 && <option value="">нет расчётов</option>}
{runs.map((run) => (
<option key={run.runId} value={run.runId}>
{formatRunOption(run)}
</option>
))}
</select>
</label>
<button
className={`tgu-button${showRequests ? " is-active" : ""}`}
type="button"
onClick={() => setShowRequests((value) => !value)}
>
{showRequests ? "Скрыть заявки" : "Показать заявки"}
</button>
<div className="tgu-map-toolbar__spacer" style={{ flex: 1 }} />
{displayRun && (
<div className="tgu-map-toolbar__subtitle">
run #{displayRun.runId} · режимов {displayRun.calculatedModesCount} · охват {formatPercent(displayRun.coveredAreaPercent)} · объём {formatVolume(totalVolumeMb)}
{displayRun.snapshotIds.length > 0 ? ` · snapshot ${displayRun.snapshotIds.join(", ")}` : ""} · на карте {contoursCount} контуров
{showRequests ? ` · заявок ${requestItems.length}` : ""}
</div>
)}
</div>
<section className="tgu-map-stage">
{overlayMessage && <div className="tgu-map-overlay">{overlayMessage}</div>}
<Tgu2DMapView
scene={scene}
layers={layers}
redrawToken={modes.length + requestPolygons.length}
onObjectSelect={() => undefined}
/>
</section>
<div className="tgu-map-errors">
{satelliteIds.length === 0 && (
<div className="tgu-alert tgu-alert--warning">
Нет эксплуатируемых КА на вкладке «Планы» расчёт недоступен.
</div>
)}
{rangeTooLong && (
<div className="tgu-alert tgu-alert--warning">
Интервал расчёта не может превышать {MAX_COMPLEX_PLAN_RANGE_DAYS} суток сократите диапазон «С/По».
</div>
)}
{requestsLoading && <div className="tgu-alert">Загрузка заявок</div>}
{requestsError && <div className="tgu-alert tgu-alert--danger">{requestsError}</div>}
{runsError && <div className="tgu-alert tgu-alert--danger">{runsError}</div>}
{notice && <div className="tgu-alert">{notice}</div>}
{error && <div className="tgu-alert tgu-alert--danger">{error}</div>}
</div>
</main>
);
}
/** Контуры режимов (survey) → полигоны слоя комплексного плана. Сбросы без контура пропускаем. */
function buildModePolygons(modes: ComplexPlanMode[]): MapPolygon[] {
const polygons: MapPolygon[] = [];
let zIndex = 0;
for (const mode of modes) {
if (!mode.contourWkt) continue;
const rings = parseWktPolygon(mode.contourWkt);
for (const ring of rings) {
if (ring.length < 3) continue;
polygons.push({
id: `complex-mode:${mode.id}:${zIndex}`,
name: `КА ${mode.satelliteId} · виток ${mode.revolution}`,
kind: "complexPlan",
layer: "complexPlan",
points: ring,
spacecraftId: String(mode.satelliteId),
modeId: mode.id,
zIndex: zIndex++
});
}
}
return polygons;
}
/** Подпись прогона в выпадающем списке истории: интервал, статус и счётчик режимов. */
function formatRunOption(run: ComplexPlanRunSummary): string {
const interval = `${formatLocalDateTime(parseLocal(run.intervalStart))}${formatLocalDateTime(parseLocal(run.intervalEnd))}`;
const status = run.status === "COMPLETED" ? "" : ` · ${run.status}`;
return `#${run.runId} · ${interval} · режимов ${run.calculatedModesCount}${status}`;
}
function formatPercent(value: number | null | undefined): string {
if (value == null || !Number.isFinite(value)) return "—";
return `${value.toFixed(1)}%`;
}
@@ -0,0 +1,830 @@
import type { ReactNode } from "react";
import { formatDate, formatDateTime, formatDateTimeSeconds, formatDuration, formatTime, formatVolume, kindColor, kindLabel } from "./editorPresentation";
import { statusStyle } from "../tgu-planning/tguStatus";
import { formatLocalDateTime, parseLocal, toLocalIso } from "../../utils/localTime";
import { Time24Field } from "../../components/timeFields";
import type { RvaZone } from "../../api/ballisticsApi";
import type { SurveyRoute } from "../../api/missionPlanningApi";
import type { Tgu2DMapSelection } from "../tgu-map-2d/model/mapSelectionTypes";
import type { RequestMapItem } from "../../model/requestTypes";
import type {
EditorGroundStation,
EditorMode,
EditorTarget,
EditorVisibilityWindow,
EditorWork,
EditorWorkPatch
} from "./model/editorTypes";
export type DownlinkSelection = {
station: EditorGroundStation;
pass: EditorVisibilityWindow;
};
export type DownlinkStationOption = {
station: EditorGroundStation;
passes: EditorVisibilityWindow[];
};
type WorkInspectorProps = {
work: EditorWork;
modes: EditorMode[];
stations: EditorGroundStation[];
/** Название аппарата редактора — для блока параметров включения. */
spacecraftLabel?: string;
/** Метка съёмки по id режима — для перечня сбрасываемых маршрутов у сброса. */
surveyModeLabel?: (surveyModeId: number) => string;
conflicts: string[];
/** Включение существующего плана — показываем только информацию, без правок. */
readOnly?: boolean;
/** Разрешить удаление даже у read-only включения плана (есть черновик — режим живёт в нём). */
canDelete?: boolean;
/** План редактируемый — показываем кнопку «Редактировать» (открывает окно построения). */
canEdit?: boolean;
/** Открыть включение в окне построения для правки параметров. */
onEdit: (workId: string) => void;
onDelete: (workId: string) => void;
onClose: () => void;
};
type ShootPanelProps = {
target?: EditorTarget;
passes: EditorVisibilityWindow[];
loading?: boolean;
error?: string;
selectedPass?: EditorVisibilityWindow;
modeId?: string;
modes: EditorMode[];
/** Окно строящегося маршрута (локальное, мс). undefined пока не выбран проход. */
routeWindow?: { startMs: number; endMs: number };
/** Текущий крен (gamma) маршрута, градусы. */
routeRoll?: number;
/** Окно видимости выбранного прохода — для подписи длительности. */
passStartMs?: number;
passEndMs?: number;
/** Допустимый диапазон времени окна маршрута (±30 мин от окна видимости), мс. */
routeMinMs?: number;
routeMaxMs?: number;
routeLoading?: boolean;
routeError?: string;
route?: SurveyRoute;
/** Режим правки существующей съёмки — меняет заголовок и подпись кнопки. */
editing?: boolean;
/** Активен режим прицеливания: карта в режиме клика по точке (перекрестие), иначе — «рука». */
picking?: boolean;
/** Включить режим прицеливания (указать/сменить опорную точку одним кликом по карте). */
onPickTarget: () => void;
/** Почему нельзя добавить включение в этот план (заложен/стартовал) — гасит «Добавить». */
addBlockedReason?: string;
onRouteStartChange: (startMs: number) => void;
onRouteEndChange: (endMs: number) => void;
/** Сдвиг окна целиком вперёд/назад (мс), длительность сохраняется. */
onRouteShift: (deltaMs: number) => void;
/** Изменение крена влево/вправо (градусы, дельта от кнопок). */
onRouteRollChange: (deltaDeg: number) => void;
/** Установка крена напрямую (ручной ввод), градусы. */
onRouteRollSet: (deg: number) => void;
onSelectPass: (pass?: EditorVisibilityWindow) => void;
onModeChange: (modeId: string) => void;
onAdd: () => void;
onClose: () => void;
};
/** Минимальная длительность маршрута, мс (нижняя граница окна). */
const MIN_ROUTE_DURATION_MS = 5_000;
type DownlinkPanelProps = {
stationOptions: DownlinkStationOption[];
selected?: DownlinkSelection;
onSelect: (selection?: DownlinkSelection) => void;
onAdd: () => void;
onClose: () => void;
};
/** Маршрут съёмки — кандидат на сброс в выбранной зоне (заканчивается до начала зоны). */
export type DropCandidateSurvey = {
id: number;
label: string;
startMs: number;
endMs: number;
};
type DropPanelProps = {
zones: RvaZone[];
loading?: boolean;
error?: string;
selectedZone?: RvaZone;
onSelectZone: (zone?: RvaZone) => void;
/** Название станции по её номеру (из каталога станций). */
stationName: (stationId: number) => string;
/** Время начала зоны в мс (локальное) — для подписи. */
zoneStartMs: (zone: RvaZone) => number;
zoneStopMs: (zone: RvaZone) => number;
/** Съёмки-кандидаты для выбранной зоны (end < начало зоны). */
candidates: DropCandidateSurvey[];
selectedSurveyIds: Set<number>;
onToggleSurvey: (id: number) => void;
saving?: boolean;
saveError?: string;
/** Режим правки существующего сброса — меняет заголовок и подпись кнопки. */
editing?: boolean;
/** Почему нельзя добавить включение в этот план (заложен/стартовал) — гасит «Добавить». */
addBlockedReason?: string;
onAdd: () => void;
onClose: () => void;
};
export function WorkInspector({
work,
modes,
stations,
spacecraftLabel,
surveyModeLabel,
conflicts,
readOnly,
canDelete,
canEdit,
onEdit,
onDelete,
onClose
}: WorkInspectorProps) {
const color = kindColor(work.kind);
return (
<div className="tgu-editor-panel">
<PanelHead title={kindLabel(work.kind)} onClose={onClose} />
<div className="tgu-editor-panel__id">{work.id}</div>
{conflicts.length > 0 && (
<div className="tgu-alert tgu-alert--warning">
{conflicts.map((conflict) => (
<div key={conflict}>{conflict}</div>
))}
</div>
)}
<section className="tgu-editor-panel__section">
<div className="tgu-editor-eyebrow">Время</div>
<dl className="tgu-map-request-details__grid">
<dt>Начало</dt>
<dd className="mono">{formatDateTimeSeconds(work.startMs)}</dd>
<dt>Конец</dt>
<dd className="mono">{formatDateTimeSeconds(work.endMs)}</dd>
<dt>Длительность</dt>
<dd className="mono">{formatDuration(work.endMs - work.startMs)}</dd>
<dt>Объём</dt>
<dd className="mono">{formatVolume(work.volumeMb)}</dd>
</dl>
</section>
<section className="tgu-editor-panel__section">
<div className="tgu-editor-eyebrow">Параметры включения</div>
<dl className="tgu-map-request-details__grid">
<dt>Аппарат</dt>
<dd>{spacecraftLabel ?? "—"}</dd>
<dt>Виток</dt>
<dd className="mono">{work.revolution ?? "—"}</dd>
{work.kind === "shoot" && (
<>
<dt>Крен</dt>
<dd className="mono">{work.rollDeg != null ? `${work.rollDeg.toFixed(1)}°` : "—"}</dd>
<dt>Угол захвата</dt>
<dd className="mono">{work.captureAngleDeg != null ? `${work.captureAngleDeg.toFixed(1)}°` : "—"}</dd>
<dt>Центр контура</dt>
<dd className="mono">
{work.centerLat != null && work.centerLon != null
? `${work.centerLat.toFixed(2)}, ${work.centerLon.toFixed(2)}`
: "—"}
</dd>
</>
)}
{work.kind === "downlink" && (
<>
<dt>Станция</dt>
<dd>{stations.find((station) => station.id === work.stationId)?.name ?? work.stationId ?? "—"}</dd>
</>
)}
</dl>
</section>
{work.kind === "shoot" && (
<section className="tgu-editor-panel__section">
<div className="tgu-editor-eyebrow">Задания под маршрутом</div>
<div className="tgu-editor-panel__hint">Привязка заданий к маршруту появится позже.</div>
</section>
)}
{work.kind === "downlink" && (
<section className="tgu-editor-panel__section">
<div className="tgu-editor-eyebrow">Сбрасываемые съёмки</div>
{work.surveyIds && work.surveyIds.length > 0 ? (
<ul className="tgu-editor-panel__list">
{work.surveyIds.map((surveyId) => (
<li key={surveyId}>{surveyModeLabel ? surveyModeLabel(surveyId) : `Маршрут #${surveyId}`}</li>
))}
</ul>
) : (
<div className="tgu-editor-panel__hint">Маршруты съёмки к сбросу не привязаны.</div>
)}
</section>
)}
{work.kind === "shoot" && (
<section className="tgu-editor-panel__section">
<div className="tgu-editor-eyebrow">Режим аппаратуры</div>
<div className="tgu-editor-panel__hint">{modes.find((mode) => mode.id === work.modeId)?.label ?? "—"}</div>
{work.targetName && <div className="tgu-editor-panel__hint">цель: {work.targetName}</div>}
</section>
)}
{(canEdit || !readOnly || canDelete) && (
<div className="tgu-editor-panel__actions">
{canEdit && (
<button className="tgu-button" type="button" onClick={() => onEdit(work.id)}>
Редактировать
</button>
)}
{(!readOnly || canDelete) && (
<button className="tgu-button tgu-button--reject" type="button" onClick={() => onDelete(work.id)}>
Удалить
</button>
)}
</div>
)}
<span className="tgu-editor-panel__color" style={{ background: color }} aria-hidden="true" />
</div>
);
}
export function ShootPanel({
target,
passes,
loading,
error,
selectedPass,
modeId,
modes,
routeWindow,
routeRoll,
passStartMs,
passEndMs,
routeMinMs,
routeMaxMs,
routeLoading,
routeError,
route,
editing,
picking,
onPickTarget,
addBlockedReason,
onRouteStartChange,
onRouteEndChange,
onRouteShift,
onRouteRollChange,
onRouteRollSet,
onSelectPass,
onModeChange,
onAdd,
onClose
}: ShootPanelProps) {
return (
<div className="tgu-editor-panel">
<PanelHead title={editing ? "Редактирование съёмки" : "Новая съёмка"} onClose={onClose} />
<section className="tgu-editor-panel__section">
<div className="tgu-editor-panel__section-head">
<div className="tgu-editor-eyebrow">1 · опорная точка прицеливания</div>
{target && !picking && (
<button className="tgu-link-button" type="button" onClick={onPickTarget}>
сменить
</button>
)}
</div>
{target ? (
<>
<div className="tgu-editor-target">
<i />
<span>{target.name}</span>
<small>
{target.lat.toFixed(2)}, {target.lon.toFixed(2)}
</small>
</div>
{picking && (
<div className="tgu-editor-panel__hint">
Кликайте по карте, чтобы переставить точку. Выберите проход ниже, когда точка верна.
</div>
)}
</>
) : picking ? (
<div className="tgu-editor-panel__placeholder">Кликните точку на карте слева, чтобы задать опорную точку прицеливания.</div>
) : (
<button className="tgu-button" type="button" onClick={onPickTarget}>
Указать точку на карте
</button>
)}
</section>
{target && (
<section className="tgu-editor-panel__section">
<div className="tgu-editor-eyebrow">2 · окно видимости</div>
{loading ? (
<div className="tgu-editor-panel__hint">Запрос параметров видимости</div>
) : error ? (
<div className="tgu-alert tgu-alert--danger tgu-alert--compact">{error}</div>
) : passes.length === 0 ? (
<div className="tgu-editor-panel__hint">В окне планирования проходов над целью нет.</div>
) : (
<div className="tgu-editor-pass-list">
{passes.map((pass) => {
const selected = selectedPass?.startMs === pass.startMs && selectedPass.endMs === pass.endMs;
return (
<button
className={selected ? "is-selected" : ""}
key={`${pass.startMs}-${pass.endMs}`}
type="button"
onClick={() => onSelectPass(pass)}
>
<span>
{formatDate(pass.startMs)} {formatTime(pass.startMs)}
{pass.revolution != null && ` · виток ${pass.revolution}`}
</span>
<small>{formatVisibilityParams(pass)}</small>
</button>
);
})}
</div>
)}
</section>
)}
{target && selectedPass && (
<section className="tgu-editor-panel__section">
<div className="tgu-editor-eyebrow">3 · режим съёмки</div>
<div className="tgu-editor-chip-row">
{modes.map((mode) => (
<button className={modeId === mode.id ? "is-selected" : ""} key={mode.id} type="button" onClick={() => onModeChange(mode.id)}>
{mode.label}
</button>
))}
</div>
</section>
)}
{target && selectedPass && routeWindow && (
<section className="tgu-editor-panel__section">
<div className="tgu-editor-eyebrow">4 · окно маршрута</div>
<RouteTimeStepper
label="начало"
value={routeWindow.startMs}
min={routeMinMs}
max={routeWindow.endMs - MIN_ROUTE_DURATION_MS}
onChange={onRouteStartChange}
/>
<RouteTimeStepper
label="конец"
value={routeWindow.endMs}
min={routeWindow.startMs + MIN_ROUTE_DURATION_MS}
max={routeMaxMs}
onChange={onRouteEndChange}
/>
<div className="tgu-editor-panel__hint">
длительность: {formatDuration(routeWindow.endMs - routeWindow.startMs)}
{passStartMs != null && passEndMs != null && ` · окно видимости: ${formatDuration(passEndMs - passStartMs)}`}
</div>
<div className="tgu-editor-time-stepper">
<span>сдвиг назад · вперёд </span>
<div className="tgu-editor-time-stepper__steps">
<button type="button" onClick={() => onRouteShift(-30_000)}>30с</button>
<button type="button" onClick={() => onRouteShift(-5_000)}>5с</button>
<button type="button" onClick={() => onRouteShift(5_000)}>+5с</button>
<button type="button" onClick={() => onRouteShift(30_000)}>+30с</button>
</div>
</div>
<div className="tgu-editor-time-stepper">
<span>крен влево · вправо </span>
<div>
<button type="button" onClick={() => onRouteRollChange(-5)}>5°</button>
<button type="button" onClick={() => onRouteRollChange(-1)}>1°</button>
<input
type="number"
step={0.5}
value={routeRoll ?? 0}
onChange={(event) => {
const deg = Number.parseFloat(event.target.value);
// Крен — не точнее одного знака после запятой.
if (Number.isFinite(deg)) onRouteRollSet(Math.round(deg * 10) / 10);
}}
/>
<button type="button" onClick={() => onRouteRollChange(1)}>+1°</button>
<button type="button" onClick={() => onRouteRollChange(5)}>+5°</button>
</div>
</div>
{routeLoading ? (
<div className="tgu-editor-panel__hint">Построение маршрута</div>
) : routeError ? (
<div className="tgu-alert tgu-alert--danger tgu-alert--compact">{routeError}</div>
) : route ? (
<div className="tgu-editor-panel__hint">
Маршрут построен · виток {route.revolution} · крен {route.roll.toFixed(1)}° · захват {route.captureAngle.toFixed(1)}° · объём {formatVolume(route.volumeMb)}
<br />
центр {route.lat.toFixed(2)}, {route.longitude.toFixed(2)} · контур показан на карте
</div>
) : null}
</section>
)}
{addBlockedReason && <div className="tgu-alert tgu-alert--warning tgu-alert--compact">{addBlockedReason}</div>}
<button
className="tgu-button tgu-button--primary"
type="button"
onClick={onAdd}
disabled={!target || !selectedPass || !modeId || !!addBlockedReason}
>
{editing ? "Сохранить съёмку" : "+ Добавить съёмку в план"}
</button>
</div>
);
}
export function DownlinkPanel({ stationOptions, selected, onSelect, onAdd, onClose }: DownlinkPanelProps) {
return (
<div className="tgu-editor-panel">
<PanelHead title="Новый сброс на НКПОР" onClose={onClose} />
<div className="tgu-editor-panel__hint">Выберите станцию и сеанс связи для добавления сброса в черновик.</div>
<div className="tgu-editor-station-list">
{stationOptions.map((option) => (
<div className="tgu-editor-station" key={option.station.id}>
<div className="tgu-editor-station__head">
<span>{option.station.name}</span>
<small>{option.passes.length} сеанс.</small>
</div>
{option.passes.length === 0 ? (
<div className="tgu-editor-panel__hint">нет сеансов в текущем окне</div>
) : (
option.passes.slice(0, 5).map((pass) => {
const isSelected = selected?.station.id === option.station.id && selected.pass.startMs === pass.startMs;
return (
<button
className={isSelected ? "is-selected" : ""}
key={`${option.station.id}-${pass.startMs}`}
type="button"
onClick={() => onSelect({ station: option.station, pass })}
>
<span>{formatDateTime(pass.startMs)}-{formatTime(pass.endMs)}</span>
<small>{formatDuration(pass.endMs - pass.startMs)}</small>
</button>
);
})
)}
</div>
))}
</div>
<button className="tgu-button tgu-button--primary" type="button" onClick={onAdd} disabled={!selected}>
+ Добавить сброс в план
</button>
</div>
);
}
export function DropPanel({
zones,
loading,
error,
selectedZone,
onSelectZone,
stationName,
zoneStartMs,
zoneStopMs,
candidates,
selectedSurveyIds,
onToggleSurvey,
saving,
saveError,
editing,
addBlockedReason,
onAdd,
onClose
}: DropPanelProps) {
const canAdd = !!selectedZone && selectedSurveyIds.size > 0 && !saving && !addBlockedReason;
return (
<div className="tgu-editor-panel">
<PanelHead title={editing ? "Редактирование сброса" : "Новый сброс по ЗРВ"} onClose={onClose} />
<div className="tgu-editor-panel__hint">
Выберите зону радиовидимости станции, затем маршруты съёмки, которые успевают завершиться до начала зоны.
</div>
{loading && <div className="tgu-editor-panel__hint">Загрузка зон радиовидимости</div>}
{error && <div className="tgu-alert tgu-alert--danger">{error}</div>}
{!loading && !error && zones.length === 0 && (
<div className="tgu-editor-panel__hint">Нет зон радиовидимости в текущем окне.</div>
)}
{zones.length > 0 && (
<div className="tgu-editor-station-list">
{zones.map((zone) => {
const startMs = zoneStartMs(zone);
const stopMs = zoneStopMs(zone);
const isSelected =
!!selectedZone &&
selectedZone.stationId === zone.stationId &&
selectedZone.revolution === zone.revolution &&
selectedZone.onStart.time === zone.onStart.time;
return (
<button
className={isSelected ? "is-selected" : ""}
key={`${zone.stationId}-${zone.revolution}-${zone.onStart.time}`}
type="button"
onClick={() => onSelectZone(isSelected ? undefined : zone)}
>
<span>{stationName(zone.stationId)} · виток {zone.revolution}</span>
<small>
{formatDateTime(startMs)}{formatTime(stopMs)} · {formatDuration(stopMs - startMs)}
</small>
</button>
);
})}
</div>
)}
{selectedZone && (
<section className="tgu-editor-panel__section">
<div className="tgu-editor-eyebrow">Съёмки для сброса</div>
{candidates.length === 0 ? (
<div className="tgu-editor-panel__hint">Нет съёмок, завершающихся до начала выбранной зоны.</div>
) : (
<div className="tgu-editor-station-list">
{candidates.map((survey) => {
const checked = selectedSurveyIds.has(survey.id);
return (
<button
className={checked ? "is-selected" : ""}
key={survey.id}
type="button"
onClick={() => onToggleSurvey(survey.id)}
>
<span>{checked ? "☑" : "☐"} {survey.label}</span>
<small>
{formatDateTime(survey.startMs)}{formatTime(survey.endMs)}
</small>
</button>
);
})}
</div>
)}
</section>
)}
{addBlockedReason && <div className="tgu-alert tgu-alert--warning tgu-alert--compact">{addBlockedReason}</div>}
{saveError && <div className="tgu-alert tgu-alert--danger">{saveError}</div>}
<button className="tgu-button tgu-button--primary" type="button" onClick={onAdd} disabled={!canAdd}>
{saving ? "Сохранение…" : editing ? "Сохранить сброс" : "+ Добавить сброс в план"}
</button>
</div>
);
}
type MapInfoPanelProps = {
selection: Tgu2DMapSelection;
requestItems: RequestMapItem[];
onClose: () => void;
};
export function MapInfoPanel({ selection, requestItems, onClose }: MapInfoPanelProps) {
const title = mapInfoTitle(selection);
const requestItem =
selection.type === "polygon" && selection.layer === "requests" && selection.requestId
? requestItems.find((r) => r.id === selection.requestId)
: undefined;
return (
<div className="tgu-editor-panel">
<PanelHead title={title} onClose={onClose} />
{selection.type === "marker" && selection.layer === "stations" && (
<section className="tgu-editor-panel__section">
<div className="tgu-editor-eyebrow">{selection.name}</div>
<dl className="tgu-map-request-details__grid">
<dt>Номер</dt>
<dd className="mono">{selection.stationNumber ?? "—"}</dd>
<dt>Широта</dt>
<dd className="mono">{selection.lat != null ? `${selection.lat.toFixed(4)}°` : "—"}</dd>
<dt>Долгота</dt>
<dd className="mono">{selection.lon != null ? `${selection.lon.toFixed(4)}°` : "—"}</dd>
<dt>Высота, м</dt>
<dd className="mono">{selection.altitude != null ? selection.altitude.toFixed(1) : "—"}</dd>
<dt>Угол места мин, °</dt>
<dd className="mono">{selection.elevationMin != null ? selection.elevationMin.toFixed(1) : "—"}</dd>
<dt>Угол места макс, °</dt>
<dd className="mono">{selection.elevationMax != null ? selection.elevationMax.toFixed(1) : "—"}</dd>
</dl>
</section>
)}
{selection.type === "polygon" && selection.layer === "requests" && (
requestItem ? (
<section className="tgu-editor-panel__section">
<dl className="tgu-map-request-details__grid">
<dt>ID</dt>
<dd className="mono">{requestItem.id}</dd>
<dt>Название</dt>
<dd>{requestItem.name || "—"}</dd>
<dt>Статус</dt>
<dd>{requestItem.status}</dd>
<dt>Тип съёмки</dt>
<dd>{surveyTypeLabel(requestItem.surveyType)}</dd>
<dt>Начало</dt>
<dd className="mono">{formatIso(requestItem.beginDateTime)}</dd>
<dt>Конец</dt>
<dd className="mono">{formatIso(requestItem.endDateTime)}</dd>
<dt>Важность</dt>
<dd className="mono">{requestItem.importance}</dd>
<dt>Покрытие</dt>
<dd className="mono">{requestItem.coveragePercent != null ? `${requestItem.coveragePercent.toFixed(1)} %` : "—"}</dd>
<dt>КПП</dt>
<dd className="mono">{requestItem.kpp?.join(", ") || "—"}</dd>
<dt>Приоритет передачи</dt>
<dd>{requestItem.highPriorityTransmit ? "Да" : "Нет"}</dd>
</dl>
</section>
) : (
<div className="tgu-editor-panel__hint">{selection.name || selection.id}</div>
)
)}
{selection.type === "polygon" && selection.layer === "planWorks" && (
<section className="tgu-editor-panel__section">
<div className="tgu-editor-eyebrow">{selection.name}</div>
{selection.status && (
<span className="tgu-map-status-pill">
<i style={{ backgroundColor: statusStyle(selection.status).color }} />
{statusStyle(selection.status).label}
</span>
)}
</section>
)}
</div>
);
}
type MapPickPanelProps = {
/** Объекты под точкой клика на карте. */
hits: Tgu2DMapSelection[];
/** id раскрытого объекта (показывается его детальная карточка). */
activeId?: string;
onToggle: (selection: Tgu2DMapSelection) => void;
onClose: () => void;
/** Детальная карточка объекта (инспектор включения или карточка объекта карты). */
renderDetail: (selection: Tgu2DMapSelection) => ReactNode;
};
/**
* Список объектов под точкой клика на карте: оператор раскрывает нужный, и под ним показывается
* его детальная карточка (renderDetail). Заменяет всплывающее меню выбора у карты.
*/
export function MapPickPanel({ hits, activeId, onToggle, onClose, renderDetail }: MapPickPanelProps) {
return (
<div className="tgu-editor-panel">
<PanelHead title={`Объекты под курсором · ${hits.length}`} onClose={onClose} />
<div className="tgu-editor-panel__hint">Кликните объект в списке, чтобы раскрыть его данные.</div>
<div className="tgu-editor-pick">
{hits.map((hit, index) => {
const open = hit.id === activeId;
return (
<div className={`tgu-editor-pick__item${open ? " is-open" : ""}`} key={`${hit.id}:${index}`}>
<button type="button" className="tgu-editor-pick__row" onClick={() => onToggle(hit)}>
<i className={`tgu-editor-pick__swatch tgu-editor-pick__swatch--${selectionSwatch(hit)}`} />
<span className="tgu-editor-pick__title">{hit.name || selectionSubtitle(hit)}</span>
<small className="tgu-editor-pick__sub">{selectionSubtitle(hit)}</small>
<span className="tgu-editor-pick__chevron" aria-hidden="true">{open ? "▾" : "▸"}</span>
</button>
{open && <div className="tgu-editor-pick__detail">{renderDetail(hit)}</div>}
</div>
);
})}
</div>
</div>
);
}
/** Класс цветовой метки объекта в списке выбора. */
function selectionSwatch(selection: Tgu2DMapSelection): string {
if (selection.type === "marker") return "station";
if (selection.type === "line") return "drop";
return selection.layer;
}
/** Подпись типа объекта в списке выбора. */
function selectionSubtitle(selection: Tgu2DMapSelection): string {
if (selection.type === "marker") {
return selection.layer === "stations" ? "Станция" : "Маркер КА";
}
if (selection.type === "line") {
return selection.kind === "drop" ? "Сброс на НКПОР" : "Трасса КА";
}
switch (selection.layer) {
case "requests": return "Заявка на съёмку";
case "planWorks": return "Маршрут съёмки";
case "swath": return "Полоса обзора";
case "tracks": return "Трасса КА";
default: return "Объект карты";
}
}
function mapInfoTitle(selection: Tgu2DMapSelection): string {
if (selection.type === "marker") {
return selection.layer === "stations" ? "Станция" : "Маркер КА";
}
switch (selection.layer) {
case "requests": return "Заявка на съёмку";
case "planWorks": return "Работа плана";
case "swath": return "Полоса обзора";
case "tracks": return "Трасса КА";
default: return "Объект карты";
}
}
function formatVisibilityParams(pass: EditorVisibilityWindow): string {
const parts: string[] = [formatDuration(pass.endMs - pass.startMs)];
if (pass.rollDeg != null) parts.push(`крен ${pass.rollDeg.toFixed(1)}°`);
if (pass.sunAngleDeg != null) parts.push(`${pass.sunAngleDeg.toFixed(1)}°`);
if (pass.range != null) {
const km = pass.range > 100000 ? pass.range / 1000 : pass.range;
parts.push(`${km.toFixed(0)} км`);
}
return parts.join(" · ");
}
function surveyTypeLabel(surveyType: string): string {
switch (surveyType) {
case "OPTICS": return "Оптическая";
case "RSA": return "Радиолокационная (РСА)";
case "COMBINED": return "Комбинированная";
default: return surveyType;
}
}
function formatIso(iso: string): string {
return formatLocalDateTime(parseLocal(iso));
}
function PanelHead({ title, onClose }: { title: string; onClose: () => void }) {
return (
<div className="tgu-editor-panel__head">
<h2>{title}</h2>
<button className="tgu-icon-button" type="button" onClick={onClose} title="Закрыть">
×
</button>
</div>
);
}
/**
* Степпер времени окна маршрута с секундной точностью: кнопки ±5 c / ±30 c и ручной
* ввод времени `чч:мм:сс` (локальное, без даты — дата берётся из текущего значения).
* Значение зажимается в [min, max].
*/
function RouteTimeStepper({
label,
value,
min,
max,
onChange
}: {
label: string;
value: number;
min?: number;
max?: number;
onChange: (value: number) => void;
}) {
const clamp = (next: number) => Math.max(min ?? Number.NEGATIVE_INFINITY, Math.min(max ?? Number.POSITIVE_INFINITY, next));
const change = (deltaMs: number) => onChange(clamp(value + deltaMs));
const onTimeChange = (raw: string) => {
// Ввод — только время суток (ЧЧ:ММ:СС, локальное); день оставляем прежним.
const datePart = toLocalIso(value).slice(0, 10);
const ms = parseLocal(`${datePart}T${raw}`);
if (Number.isFinite(ms)) onChange(clamp(ms));
};
return (
<label className="tgu-editor-time-stepper tgu-editor-time-stepper--seconds">
<span>{label}</span>
<Time24Field withSeconds value={toLocalIso(value).slice(11, 19)} onChange={onTimeChange} />
<div className="tgu-editor-time-stepper__steps">
<button type="button" onClick={() => change(-30_000)}>30с</button>
<button type="button" onClick={() => change(-5_000)}>5с</button>
<button type="button" onClick={() => change(5_000)}>+5с</button>
<button type="button" onClick={() => change(30_000)}>+30с</button>
</div>
</label>
);
}
@@ -0,0 +1,89 @@
import type { TguPlanUi, TguPlatformUi } from "../../model/timelineTypes";
import { formatDateTime } from "./editorPresentation";
type EditorRailProps = {
selectedSpacecraftId?: string;
selectedPlan?: TguPlanUi;
platforms: TguPlatformUi[];
surveyCount: number;
downlinkCount: number;
conflictCount: number;
contextSpacecraftIds: Set<string>;
onToggleContext: (spacecraftId: string) => void;
};
export function EditorRail({
selectedSpacecraftId,
selectedPlan,
platforms,
surveyCount,
downlinkCount,
conflictCount,
contextSpacecraftIds,
onToggleContext
}: EditorRailProps) {
const currentPlatform = platforms.find((platform) => platform.spacecraftId === selectedSpacecraftId);
const contextPlatforms = platforms.filter((platform) => platform.spacecraftId !== selectedSpacecraftId).slice(0, 40);
return (
<aside className="tgu-editor-rail">
<section className="tgu-editor-rail__section">
<div className="tgu-editor-eyebrow">Редактируется</div>
<div className="tgu-editor-rail__title">{currentPlatform?.name ?? selectedSpacecraftId ?? "КА не выбран"}</div>
<div className="tgu-editor-rail__meta">
{selectedPlan ? `${selectedPlan.planId} · КПП ${selectedPlan.kppId}` : "План не выбран"}
</div>
{selectedPlan && (
<dl className="tgu-editor-rail__plan-dates">
<dt>Начало</dt>
<dd className="mono">{formatDateTime(selectedPlan.startMs)}</dd>
<dt>Окончание</dt>
<dd className="mono">{formatDateTime(selectedPlan.endMs)}</dd>
</dl>
)}
<div className="tgu-editor-stats">
<RailStat label="Включений" value={surveyCount + downlinkCount} />
<RailStat label="Съёмка" value={surveyCount} color="var(--t-optical)" />
<RailStat label="Сброс" value={downlinkCount} color="var(--accent)" />
<RailStat label="Конфл." value={conflictCount} color={conflictCount > 0 ? "var(--now)" : undefined} />
</div>
</section>
<section className="tgu-editor-rail__section">
<div className="tgu-editor-eyebrow">Контекст других КА</div>
<div className="tgu-editor-context-list">
{contextPlatforms.length === 0 ? (
<div className="tgu-empty tgu-empty--compact">Нет других КА в выбранном интервале.</div>
) : (
contextPlatforms.map((platform) => {
const enabled = contextSpacecraftIds.has(platform.spacecraftId);
return (
<button
className={`tgu-editor-context-item ${enabled ? "is-enabled" : ""}`}
key={platform.spacecraftId}
type="button"
onClick={() => onToggleContext(platform.spacecraftId)}
>
<span className="tgu-editor-context-item__check">{enabled ? "✓" : ""}</span>
<span>{platform.name ?? platform.noradId ?? platform.spacecraftId}</span>
<small>{platform.planCount}</small>
</button>
);
})
)}
</div>
</section>
</aside>
);
}
function RailStat({ label, value, color }: { label: string; value: number; color?: string }) {
return (
<div className="tgu-editor-stat">
<span style={{ color }}>{value}</span>
<small>{label}</small>
</div>
);
}
@@ -0,0 +1,298 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { formatDate, formatDateTime, formatTick, formatTime, kindColor, kindLabel, niceTicks } from "./editorPresentation";
import type {
EditorContextLane,
EditorTimelineWindow,
EditorVisibilityTrack,
EditorWork
} from "./model/editorTypes";
import { VisibilityTracks } from "./VisibilityTracks";
type EditorTimelineProps = {
activeLabel: string;
works: EditorWork[];
selectedWorkId?: string;
window: EditorTimelineWindow;
planStartMs?: number;
planEndMs?: number;
conflicts: Record<string, string[]>;
contextLanes: EditorContextLane[];
visibilityTracks: EditorVisibilityTrack[];
onSelectWork: (workId: string) => void;
onClearSelection: () => void;
/** Масштаб колесом мыши: factor (>1 — отдалить, <1 — приблизить) и время под курсором. */
onWheelZoom?: (factor: number, anchorMs: number) => void;
/** Сдвиг окна перетаскиванием: новое значение левой границы окна (мс). */
onPanTo?: (fromMs: number) => void;
};
const LABEL_WIDTH = 168;
const AXIS_HEIGHT = 40;
/** Минимальная высота активной дорожки (два ряда: съёмка + сброс). */
const MIN_ACTIVE_HEIGHT = 84;
const VISIBILITY_HEIGHT = 24;
const OTHER_HEIGHT = 30;
export function EditorTimeline({
activeLabel,
works,
selectedWorkId,
window: timelineWindow,
planStartMs,
planEndMs,
conflicts,
contextLanes,
visibilityTracks,
onSelectWork,
onClearSelection,
onWheelZoom,
onPanTo
}: EditorTimelineProps) {
const wrapperRef = useRef<HTMLDivElement | null>(null);
const [bodyWidth, setBodyWidth] = useState(900);
const [bodyHeight, setBodyHeight] = useState(260);
const [grabbing, setGrabbing] = useState(false);
const span = Math.max(1, timelineWindow.toMs - timelineWindow.fromMs);
const pxPerMs = bodyWidth / span;
const toX = (timeMs: number) => (timeMs - timelineWindow.fromMs) * pxPerMs;
const { ticks, step: tickStep } = useMemo(
() => niceTicks(timelineWindow.fromMs, timelineWindow.toMs, pxPerMs, 72),
[pxPerMs, timelineWindow.fromMs, timelineWindow.toMs]
);
const days = useMemo(() => buildDays(timelineWindow), [timelineWindow]);
// Фиксированная высота частей ниже активной дорожки (треки видимости + контекст КА).
const belowActiveHeight =
visibilityTracks.length * VISIBILITY_HEIGHT +
(contextLanes.length > 0 ? 22 : 0) +
contextLanes.length * OTHER_HEIGHT +
8;
// Активная дорожка резиновая: занимает всё свободное место панели, поэтому при
// подъёме панели вверх ряды съёмки/сброса расширяются, а низ не пустует.
const activeHeight = Math.max(MIN_ACTIVE_HEIGHT, bodyHeight - AXIS_HEIGHT - belowActiveHeight);
const visibilityTop = AXIS_HEIGHT + activeHeight;
const dividerTop = visibilityTop + visibilityTracks.length * VISIBILITY_HEIGHT;
const contextTop = dividerTop + (contextLanes.length > 0 ? 22 : 0);
const contentHeight = AXIS_HEIGHT + activeHeight + belowActiveHeight;
const surfaceHeight = Math.max(bodyHeight, contentHeight);
useEffect(() => {
const element = wrapperRef.current;
if (!element) return;
const measure = () => {
setBodyWidth(Math.max(560, element.clientWidth - LABEL_WIDTH));
setBodyHeight(element.clientHeight);
};
const observer = new ResizeObserver(measure);
observer.observe(element);
measure();
return () => observer.disconnect();
}, []);
// Свежие значения окна/геометрии для нативного wheel-слушателя (он навешивается один раз).
const wheelStateRef = useRef({ fromMs: timelineWindow.fromMs, pxPerMs, bodyWidth, onWheelZoom });
wheelStateRef.current = { fromMs: timelineWindow.fromMs, pxPerMs, bodyWidth, onWheelZoom };
useEffect(() => {
const element = wrapperRef.current;
if (!element) return;
// Нативный слушатель с passive: false — иначе preventDefault не сработает и страница
// прокрутится вместо масштабирования таймлайна.
const handleWheel = (event: WheelEvent) => {
const state = wheelStateRef.current;
if (!state.onWheelZoom) return;
event.preventDefault();
const rect = element.getBoundingClientRect();
const x = event.clientX - rect.left - LABEL_WIDTH;
const clampedX = Math.min(Math.max(x, 0), state.bodyWidth);
const anchorMs = state.fromMs + clampedX / state.pxPerMs;
const factor = event.deltaY > 0 ? 1.1 : 0.9;
state.onWheelZoom(factor, anchorMs);
};
element.addEventListener("wheel", handleWheel, { passive: false });
return () => element.removeEventListener("wheel", handleWheel);
}, []);
// Перетаскивание окна по горизонтали (как карту). На mousedown фиксируем точку и левую
// границу окна, дальше двигаем абсолютно (без накопления дрейфа). Сдвиг > порога помечаем
// как drag, чтобы погасить последующий click (иначе выделение/сброс включения).
const dragRef = useRef<{ startX: number; startFromMs: number; pxPerMs: number; moved: boolean } | null>(null);
const suppressClickRef = useRef(false);
const handleMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
if (event.button !== 0 || !onPanTo || pxPerMs <= 0) return;
const drag = { startX: event.clientX, startFromMs: timelineWindow.fromMs, pxPerMs, moved: false };
dragRef.current = drag;
const onMove = (move: MouseEvent) => {
const deltaPx = move.clientX - drag.startX;
if (Math.abs(deltaPx) > 3) {
drag.moved = true;
setGrabbing(true);
}
// Тянем содержимое вправо → показываем более раннее время (окно едет влево).
onPanTo(drag.startFromMs - deltaPx / drag.pxPerMs);
};
const onUp = () => {
if (drag.moved) suppressClickRef.current = true;
dragRef.current = null;
setGrabbing(false);
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
};
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
};
// Капче-фаза: если только что был drag — гасим click до того, как он дойдёт до полос/фона.
const handleClickCapture = (event: React.MouseEvent<HTMLDivElement>) => {
if (suppressClickRef.current) {
suppressClickRef.current = false;
event.stopPropagation();
event.preventDefault();
}
};
return (
<div
className="tgu-editor-timeline"
ref={wrapperRef}
onClick={onClearSelection}
onClickCapture={handleClickCapture}
onMouseDown={handleMouseDown}
style={onPanTo ? { cursor: grabbing ? "grabbing" : "grab" } : undefined}
>
<div className="tgu-editor-timeline__surface" style={{ width: LABEL_WIDTH + bodyWidth, height: Math.max(220, surfaceHeight) }}>
<div className="tgu-editor-timeline__axis-label" style={{ width: LABEL_WIDTH }}>
Время
</div>
<div className="tgu-editor-timeline__axis" style={{ left: LABEL_WIDTH, width: bodyWidth }}>
{days.map((day) => (
<div className="tgu-editor-timeline__day" key={day} style={{ left: toX(day) }}>
<strong>{formatDate(day).split(" ")[0]}</strong>
<span>{formatDate(day).split(" ")[1]}</span>
</div>
))}
{ticks.map((tick) => (
<div className="tgu-editor-timeline__tick-label" key={tick} style={{ left: toX(tick) }}>
{formatTick(tick, tickStep)}
</div>
))}
</div>
<div className="tgu-editor-timeline__grid" style={{ left: LABEL_WIDTH, top: AXIS_HEIGHT, width: bodyWidth }}>
{ticks.map((tick) => (
<div key={tick} style={{ left: toX(tick) }} />
))}
</div>
{planStartMs != null && planStartMs >= timelineWindow.fromMs && planStartMs <= timelineWindow.toMs && (
<div className="tgu-editor-timeline__bound tgu-editor-timeline__bound--start" style={{ left: LABEL_WIDTH + toX(planStartMs), top: AXIS_HEIGHT }}>
<span>Начало плана</span>
</div>
)}
{planEndMs != null && planEndMs >= timelineWindow.fromMs && planEndMs <= timelineWindow.toMs && (
<div className="tgu-editor-timeline__bound tgu-editor-timeline__bound--end" style={{ left: LABEL_WIDTH + toX(planEndMs), top: AXIS_HEIGHT }}>
<span>Конец плана</span>
</div>
)}
<div className="tgu-editor-timeline__lane" style={{ top: AXIS_HEIGHT, height: activeHeight }}>
<div className="tgu-editor-timeline__lane-label tgu-editor-timeline__lane-label--split" style={{ width: LABEL_WIDTH }}>
<span>{activeLabel}</span>
<small>редактируется</small>
<em className="tgu-editor-timeline__row-hint tgu-editor-timeline__row-hint--shoot">Съёмка</em>
<em className="tgu-editor-timeline__row-hint tgu-editor-timeline__row-hint--downlink">Сброс</em>
</div>
<div className="tgu-editor-timeline__lane-body" style={{ left: LABEL_WIDTH, width: bodyWidth }}>
{works.map((work) => {
const left = toX(work.startMs);
const width = Math.max(6, toX(work.endMs) - toX(work.startMs));
const color = kindColor(work.kind);
const selected = selectedWorkId === work.id;
const hasConflict = Boolean(conflicts[work.id]?.length);
return (
<button
className={`tgu-editor-work tgu-editor-work--${work.kind} ${selected ? "is-selected" : ""} ${hasConflict ? "has-conflict" : ""}`}
type="button"
key={work.id}
style={{
left,
width,
borderLeftColor: color,
background: `color-mix(in srgb, ${color} 22%, var(--bg-1))`
}}
title={`${kindLabel(work.kind)} · ${formatDateTime(work.startMs)}-${formatTime(work.endMs)}`}
onClick={(event) => {
event.stopPropagation();
onSelectWork(work.id);
}}
>
<span className="tgu-editor-work__dot" style={{ background: color }} />
{width > 54 && <span className="tgu-editor-work__label">{work.label}</span>}
{work.isNew && <span className="tgu-editor-work__new">NEW</span>}
{width > 82 && <small>{formatTime(work.startMs)}-{formatTime(work.endMs)}</small>}
</button>
);
})}
</div>
</div>
<VisibilityTracks
tracks={visibilityTracks}
window={timelineWindow}
width={bodyWidth}
labelWidth={LABEL_WIDTH}
top={visibilityTop}
rowHeight={VISIBILITY_HEIGHT}
/>
{contextLanes.length > 0 && (
<div className="tgu-editor-context-divider" style={{ top: dividerTop, width: LABEL_WIDTH + bodyWidth }}>
Другие КА · контекст (read-only)
</div>
)}
{contextLanes.map((lane, index) => (
<div className="tgu-editor-timeline__lane" key={lane.spacecraftId} style={{ top: contextTop + index * OTHER_HEIGHT, height: OTHER_HEIGHT }}>
<div className="tgu-editor-timeline__lane-label" style={{ width: LABEL_WIDTH }}>
<span>{lane.label}</span>
</div>
<div className="tgu-editor-timeline__lane-body" style={{ left: LABEL_WIDTH, width: bodyWidth }}>
{lane.works.map((work) => {
if (work.endMs < timelineWindow.fromMs || work.startMs > timelineWindow.toMs) return null;
const left = toX(work.startMs);
const width = Math.max(3, toX(work.endMs) - toX(work.startMs));
return (
<div
className="tgu-editor-work tgu-editor-work--context"
key={work.id}
style={{ left, width, borderLeftColor: kindColor(work.kind) }}
title={`${lane.label} · ${kindLabel(work.kind)} · ${formatTime(work.startMs)}-${formatTime(work.endMs)}`}
/>
);
})}
</div>
</div>
))}
</div>
</div>
);
}
function buildDays(window: EditorTimelineWindow): number[] {
const result: number[] = [];
const start = new Date(window.fromMs);
let dayMs = new Date(start.getFullYear(), start.getMonth(), start.getDate()).getTime();
while (dayMs <= window.toMs) {
result.push(dayMs);
dayMs += 24 * 60 * 60 * 1000;
}
return result;
}
@@ -0,0 +1,115 @@
import type { TguPlanUi, TguPlatformUi } from "../../model/timelineTypes";
import { formatDuration } from "./editorPresentation";
type EditorToolbarProps = {
selectedSpacecraftId?: string;
selectedPlan?: TguPlanUi;
platforms: TguPlatformUi[];
checking: boolean;
/** Идёт расчёт/очистка всего плана — блокирует кнопки операций над планом. */
planCalcRunning: boolean;
/** Почему нельзя рассчитывать/чистить план (заложен/стартовал) — гасит кнопки. */
calcBlockedReason?: string;
conflictCount: number;
windowLabel: string;
rightMode: "none" | "inspect" | "shoot" | "downlink" | "mapInfo" | "mapPick";
onSetRightMode: (mode: "shoot" | "downlink") => void;
onCheck: () => void;
/** Рассчитать план съёмки и сброса одной операцией. */
onCalculatePlan: () => void;
/** Полностью очистить план (с подтверждением внутри обработчика). */
onClearPlan: () => void;
onZoom: (factor: number) => void;
};
export function EditorToolbar({
selectedSpacecraftId,
selectedPlan,
platforms,
checking,
planCalcRunning,
calcBlockedReason,
conflictCount,
windowLabel,
rightMode,
onSetRightMode,
onCheck,
onCalculatePlan,
onClearPlan,
onZoom
}: EditorToolbarProps) {
const platformLabel = selectedSpacecraftId
? platforms.find((platform) => platform.spacecraftId === selectedSpacecraftId)?.name ?? selectedSpacecraftId
: "КА не выбран";
return (
<div className="tgu-editor-toolbar">
<div className="tgu-editor-toolbar__identity">
<span className="tgu-editor-type-dot" aria-hidden="true" />
<div>
<div className="tgu-editor-toolbar__title">{platformLabel}</div>
<div className="tgu-editor-toolbar__meta">
{selectedPlan ? `${selectedPlan.planId} · ${selectedPlan.status}` : "Выберите план на timeline"}
</div>
</div>
</div>
<div className="tgu-editor-toolbar__group">
<button
className={`tgu-button ${rightMode === "shoot" ? "tgu-button--primary" : ""}`}
type="button"
onClick={() => onSetRightMode("shoot")}
disabled={!selectedSpacecraftId}
>
+ Съёмка
</button>
<button
className={`tgu-button ${rightMode === "downlink" ? "tgu-button--primary" : ""}`}
type="button"
onClick={() => onSetRightMode("downlink")}
disabled={!selectedSpacecraftId}
>
+ Сброс
</button>
</div>
<div className="tgu-editor-toolbar__group">
<button
className="tgu-button tgu-button--primary"
type="button"
onClick={onCalculatePlan}
disabled={!selectedPlan || planCalcRunning || Boolean(calcBlockedReason)}
title={calcBlockedReason ?? "Рассчитать план съёмки и сброса по комплексному плану (ручные маршруты сохраняются)"}
>
{planCalcRunning ? "Расчёт..." : "Рассчитать план"}
</button>
<button
className="tgu-button tgu-button--reject"
type="button"
onClick={onClearPlan}
disabled={!selectedPlan || planCalcRunning || Boolean(calcBlockedReason)}
title={calcBlockedReason ?? "Удалить все включения плана, включая построенные вручную"}
>
Очистить план
</button>
</div>
<div className="tgu-editor-toolbar__spacer" />
<div className="tgu-editor-window-control" aria-label="Масштаб окна" title="Колесо мыши над таймлайном — тоже масштаб">
<button type="button" onClick={() => onZoom(1.4)} title="Отдалить"></button>
<span>{windowLabel || formatDuration(0)}</span>
<button type="button" onClick={() => onZoom(0.7)} title="Приблизить">+</button>
</div>
<button
className={`tgu-button ${conflictCount > 0 ? "tgu-editor-button--warning" : ""}`}
type="button"
onClick={onCheck}
disabled={checking || !selectedSpacecraftId}
>
{checking ? "Проверка..." : conflictCount > 0 ? `Конфликты · ${conflictCount}` : "Проверить"}
</button>
</div>
);
}
@@ -0,0 +1,127 @@
import { useRef } from "react";
import type { OrbitPassInfo } from "../tgu-map-2d/geometry/passes";
type MapPassSelectorProps = {
passes: OrbitPassInfo[];
fromIndex: number;
toIndex: number;
onFromChange: (idx: number) => void;
onToChange: (idx: number) => void;
};
function localHM(ms: number): string {
const d = new Date(ms);
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
}
function localDM(ms: number): string {
const d = new Date(ms);
return `${String(d.getDate()).padStart(2, "0")}.${String(d.getMonth() + 1).padStart(2, "0")}`;
}
export function MapPassSelector({ passes, fromIndex, toIndex, onFromChange, onToChange }: MapPassSelectorProps) {
const trackRef = useRef<HTMLDivElement>(null);
const n = passes.length;
if (n === 0) return null;
const fromPass = passes[Math.max(0, Math.min(fromIndex - 1, n - 1))];
const toPass = passes[Math.max(0, Math.min(toIndex - 1, n - 1))];
const multiDay = localDM(passes[0].startMs) !== localDM(passes[n - 1].startMs);
const isFullRange = fromIndex === 1 && toIndex === n;
const fromRev = fromPass.revolution ?? fromIndex;
const toRev = toPass.revolution ?? toIndex;
const pct = (idx: number): number => (n <= 1 ? 0 : ((idx - 1) / (n - 1)) * 100);
const fromPct = pct(fromIndex);
const toPct = pct(toIndex);
const indexAt = (clientX: number): number => {
const rect = trackRef.current?.getBoundingClientRect();
if (!rect || n <= 1) return 1;
return Math.round(Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)) * (n - 1)) + 1;
};
const makeHandlers = (isFrom: boolean) => ({
onPointerDown(e: React.PointerEvent<HTMLDivElement>) {
e.stopPropagation();
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
},
onPointerMove(e: React.PointerEvent<HTMLDivElement>) {
if (!(e.currentTarget as HTMLDivElement).hasPointerCapture(e.pointerId)) return;
const idx = indexAt(e.clientX);
if (isFrom) {
onFromChange(Math.max(1, Math.min(idx, toIndex)));
} else {
onToChange(Math.min(n, Math.max(idx, fromIndex)));
}
},
onPointerUp(e: React.PointerEvent<HTMLDivElement>) {
(e.currentTarget as HTMLDivElement).releasePointerCapture(e.pointerId);
},
});
const onTrackPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
const idx = indexAt(e.clientX);
if (Math.abs(idx - fromIndex) <= Math.abs(idx - toIndex)) {
onFromChange(Math.max(1, Math.min(idx, toIndex)));
} else {
onToChange(Math.min(n, Math.max(idx, fromIndex)));
}
};
return (
<div className="map-pass-selector">
<div className="map-pass-selector__header">
<span className="map-pass-selector__title">Витки</span>
<span className="map-pass-selector__nums">{fromRev}{toRev}</span>
<span className="map-pass-selector__times">
{multiDay ? `${localDM(fromPass.startMs)} ` : ""}{localHM(fromPass.startMs)}
{" "}
{multiDay ? `${localDM(toPass.startMs)} ` : ""}{localHM(toPass.startMs)}
</span>
{!isFullRange && (
<button
type="button"
className="map-pass-selector__reset"
title="Все витки"
onClick={() => { onFromChange(1); onToChange(n); }}
>
Все
</button>
)}
</div>
<div className="map-pass-selector__body" onPointerDown={onTrackPointerDown}>
{/* Track line */}
<div className="map-pass-selector__track" ref={trackRef}>
<div
className="map-pass-selector__fill"
style={{ left: `${fromPct}%`, width: `${toPct - fromPct}%` }}
/>
{/* Tick marks */}
{passes.map((p) => (
<div
key={p.index}
className={`map-pass-selector__tick${p.index >= fromIndex && p.index <= toIndex ? " is-active" : ""}`}
style={{ left: `${pct(p.index)}%` }}
/>
))}
{/* From handle */}
<div
className="map-pass-selector__thumb"
style={{ left: `${fromPct}%` }}
{...makeHandlers(true)}
/>
{/* To handle */}
<div
className="map-pass-selector__thumb"
style={{ left: `${toPct}%` }}
{...makeHandlers(false)}
/>
</div>
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,58 @@
import { formatTime } from "./editorPresentation";
import type { EditorTimelineWindow, EditorVisibilityTrack } from "./model/editorTypes";
type VisibilityTracksProps = {
tracks: EditorVisibilityTrack[];
window: EditorTimelineWindow;
width: number;
labelWidth: number;
top: number;
rowHeight: number;
};
export function VisibilityTracks({ tracks, window, width, labelWidth, top, rowHeight }: VisibilityTracksProps) {
const span = Math.max(1, window.toMs - window.fromMs);
const toX = (timeMs: number) => ((timeMs - window.fromMs) / span) * width;
return (
<>
{tracks.map((track, index) => {
const rowTop = top + index * rowHeight;
return (
<div className="tgu-editor-timeline__lane" key={track.id} style={{ top: rowTop, height: rowHeight }}>
<div className="tgu-editor-timeline__lane-label" style={{ width: labelWidth }}>
<span className="tgu-editor-visibility-label">
<i style={{ background: track.color }} />
{track.label}
</span>
<small>{track.kind === "target" ? "цель · видимость" : "станция · видимость"}</small>
</div>
<div className="tgu-editor-timeline__lane-body" style={{ left: labelWidth, width }}>
{track.windows.map((visibilityWindow) => {
if (visibilityWindow.endMs < window.fromMs || visibilityWindow.startMs > window.toMs) {
return null;
}
const left = toX(Math.max(visibilityWindow.startMs, window.fromMs));
const right = toX(Math.min(visibilityWindow.endMs, window.toMs));
return (
<div
className="tgu-editor-visibility-window"
key={`${visibilityWindow.startMs}-${visibilityWindow.endMs}`}
style={{
left,
width: Math.max(2, right - left),
borderColor: track.color,
background: `color-mix(in srgb, ${track.color} 22%, transparent)`
}}
title={`${track.label}: ${formatTime(visibilityWindow.startMs)}-${formatTime(visibilityWindow.endMs)}`}
/>
);
})}
</div>
</div>
);
})}
</>
);
}
@@ -0,0 +1,40 @@
export type PlanSurveyMode = {
id: number;
planId: number;
timeStart: string | null;
revolution: number;
type: "SURVEY";
status: string;
lat: number;
longitude: number;
duration: number;
contourWkt: string | null;
roll: number;
/** Объём маршрута, МБ (скорость накопления × длительность). Считает бэк (pcp-types-lib). */
volumeMb?: number;
};
export type PlanDropMode = {
id: number;
planId: number;
timeStart: string | null;
revolution: number;
type: "DROP";
station: string | null;
duration: number;
surveys: number[];
/** Объём сброса, Мбайт (канал 1200 Мбит/с ÷ 8 × длительность). Считает бэк (pcp-types-lib). */
volumeMb?: number;
};
export type PlanMode = PlanSurveyMode | PlanDropMode;
export async function fetchPlanModes(planId: string): Promise<PlanMode[]> {
const response = await fetch(`/api/current-plans/missions/${encodeURIComponent(planId)}/modes`, {
headers: { Accept: "application/json" }
});
if (!response.ok) {
throw new Error(`Ошибка загрузки включений плана: ${response.status}`);
}
return response.json() as Promise<PlanMode[]>;
}
@@ -0,0 +1,97 @@
import { describe, expect, it } from "vitest";
import { subPoint } from "../tgu-map-2d/geometry/passes";
import { checkVisibility, mergeConflictMaps, overlaps } from "./editorConflicts";
import type { Draft } from "./model/editorTypes";
const orbit = { spacecraftId: "56756" };
const centerMs = Date.UTC(2026, 4, 29, 12, 0, 0);
const visiblePoint = subPoint(orbit, centerMs);
describe("editorConflicts", () => {
it("detects overlapping works and keeps non-overlapping works clean", () => {
const conflicts = overlaps([
{ id: "shoot-1", kind: "shoot", label: "Съёмка", startMs: 1000, endMs: 5000, origin: "plan" },
{ id: "downlink-1", kind: "downlink", label: "Сброс", startMs: 4000, endMs: 7000, origin: "plan" },
{ id: "shoot-2", kind: "shoot", label: "Съёмка", startMs: 8000, endMs: 9000, origin: "plan" }
]);
expect(conflicts).toEqual({
"shoot-1": ['Пересечение по времени с "Сброс"'],
"downlink-1": ['Пересечение по времени с "Съёмка"']
});
});
it("reports target visibility conflicts only when the selected interval is not covered", () => {
const draft: Draft = {
spacecraftId: orbit.spacecraftId,
works: [
{
id: "visible-shoot",
kind: "shoot",
label: "Съёмка",
startMs: centerMs - 10_000,
endMs: centerMs + 10_000,
targetLat: visiblePoint.lat,
targetLon: visiblePoint.lon,
origin: "added"
},
{
id: "hidden-shoot",
kind: "shoot",
label: "Съёмка",
startMs: centerMs - 10_000,
endMs: centerMs + 10_000,
targetLat: -visiblePoint.lat,
targetLon: visiblePoint.lon + 150,
origin: "added"
}
]
};
expect(checkVisibility(draft, { stations: [] })).toEqual({
"hidden-shoot": ["Съёмка вне зоны видимости цели"]
});
});
it("checks downlink station visibility and missing station coordinates", () => {
const draft: Draft = {
spacecraftId: orbit.spacecraftId,
works: [
{
id: "visible-downlink",
kind: "downlink",
label: "Сброс",
startMs: centerMs - 10_000,
endMs: centerMs + 10_000,
stationId: "KPP-1",
origin: "added"
},
{
id: "unknown-downlink",
kind: "downlink",
label: "Сброс",
startMs: centerMs - 10_000,
endMs: centerMs + 10_000,
stationId: "KPP-2",
origin: "added"
}
]
};
expect(checkVisibility(draft, {
stations: [
{ id: "KPP-1", name: "KPP-1", lat: visiblePoint.lat, lon: visiblePoint.lon },
{ id: "KPP-2", name: "KPP-2" }
]
})).toEqual({
"unknown-downlink": ["Нет координат станции для проверки видимости"]
});
});
it("merges live and visibility conflict maps", () => {
expect(mergeConflictMaps({ "work-1": ["A"] }, { "work-1": ["B"], "work-2": ["C"] })).toEqual({
"work-1": ["A", "B"],
"work-2": ["C"]
});
});
});
@@ -0,0 +1,103 @@
import {
stationPasses,
targetPasses,
type GroundPoint,
type PassOrbit
} from "../tgu-map-2d/geometry/passes";
import type { Draft, EditorCheckContext, EditorGroundStation, EditorWork } from "./model/editorTypes";
export type EditorConflictMap = Record<string, string[]>;
const DEFAULT_VISIBILITY_MARGIN_MS = 6 * 60 * 60 * 1000;
export function overlaps(works: EditorWork[]): EditorConflictMap {
const result: EditorConflictMap = {};
for (let firstIndex = 0; firstIndex < works.length; firstIndex += 1) {
for (let secondIndex = firstIndex + 1; secondIndex < works.length; secondIndex += 1) {
const first = works[firstIndex];
const second = works[secondIndex];
if (Math.max(first.startMs, second.startMs) < Math.min(first.endMs, second.endMs)) {
addConflict(result, first.id, `Пересечение по времени с "${kindLabel(second.kind)}"`);
addConflict(result, second.id, `Пересечение по времени с "${kindLabel(first.kind)}"`);
}
}
}
return result;
}
export function checkVisibility(draft: Draft, context: EditorCheckContext): EditorConflictMap {
const result: EditorConflictMap = {};
const orbit: PassOrbit = { spacecraftId: context.spacecraftId ?? draft.spacecraftId };
const marginMs = context.marginMs ?? DEFAULT_VISIBILITY_MARGIN_MS;
for (const work of draft.works) {
if (work.kind === "shoot" && work.targetLat !== undefined && work.targetLon !== undefined) {
const windows = targetPasses(orbit, { lat: work.targetLat, lon: work.targetLon }, {
fromMs: work.startMs - marginMs,
toMs: work.endMs + marginMs
});
if (!isCovered(work, windows)) {
addConflict(result, work.id, "Съёмка вне зоны видимости цели");
}
}
if (work.kind === "downlink" && work.stationId) {
const station = context.stations.find((item) => item.id === work.stationId);
const point = stationPoint(station);
if (!point) {
addConflict(result, work.id, "Нет координат станции для проверки видимости");
continue;
}
const windows = stationPasses(orbit, point, {
fromMs: work.startMs - marginMs,
toMs: work.endMs + marginMs
});
if (!isCovered(work, windows)) {
addConflict(result, work.id, "Сброс вне сеанса связи со станцией");
}
}
}
return result;
}
export function mergeConflictMaps(...maps: EditorConflictMap[]): EditorConflictMap {
const result: EditorConflictMap = {};
for (const map of maps) {
for (const [workId, messages] of Object.entries(map)) {
result[workId] = [...(result[workId] ?? []), ...messages];
}
}
return result;
}
export function conflictCount(map: EditorConflictMap): number {
return Object.keys(map).length;
}
function isCovered(work: EditorWork, windows: Array<{ startMs: number; endMs: number }>): boolean {
return windows.some((window) => work.startMs >= window.startMs - 1 && work.endMs <= window.endMs + 1);
}
function stationPoint(station?: EditorGroundStation): GroundPoint | undefined {
if (station?.lat === undefined || station.lon === undefined) {
return undefined;
}
return { lat: station.lat, lon: station.lon };
}
export function kindLabel(kind: EditorWork["kind"]): string {
switch (kind) {
case "shoot":
return "Съёмка";
case "downlink":
return "Сброс";
}
}
function addConflict(map: EditorConflictMap, workId: string, message: string): void {
map[workId] = [...(map[workId] ?? []), message];
}
@@ -0,0 +1,171 @@
import { describe, expect, it } from "vitest";
import type { TguPlanUi } from "../../model/timelineTypes";
import {
addDownlink,
addShoot,
deleteWork,
mutate,
planModesToWorks,
redo,
seedDraft,
undo,
updateWork
} from "./editorDraft";
import type { PlanMode } from "./editorApi";
import type { DraftHistory } from "./model/editorTypes";
import { parseLocal } from "../../utils/localTime";
const plan: TguPlanUi = {
planId: "plan-1",
spacecraftId: "56756",
startTime: "2026-05-29T01:00:00",
endTime: "2026-05-29T02:00:00",
kppId: "KPP-1",
status: "PLANNED",
startMs: parseLocal("2026-05-29T01:00:00"),
endMs: parseLocal("2026-05-29T02:00:00")
};
describe("editorDraft", () => {
it("seeds an empty draft when the plan has no embedded works", () => {
// Существующие включения грузятся отдельно (planModesToWorks); черновик хранит только правки.
expect(seedDraft(plan)).toEqual({
planId: "plan-1",
spacecraftId: "56756",
works: []
});
});
it("maps plan modes to read-only timeline works", () => {
const modes: PlanMode[] = [
{
id: 11,
planId: 1,
timeStart: "2026-05-29T01:10:00",
revolution: 4321,
type: "SURVEY",
status: "PLANNED",
lat: 55.7,
longitude: 37.6,
duration: 180,
contourWkt: null,
roll: 0
},
{
id: 22,
planId: 1,
timeStart: "2026-05-29T01:40:00",
revolution: 4322,
type: "DROP",
station: "102",
duration: 120,
surveys: [11]
},
{
id: 33,
planId: 1,
timeStart: null,
revolution: 4323,
type: "SURVEY",
status: "PLANNED",
lat: 0,
longitude: 0,
duration: 60,
contourWkt: null,
roll: 0
}
];
const works = planModesToWorks(modes);
// Включение без времени старта пропускается, оба валидных — read-only (origin: "plan").
expect(works).toEqual([
{
id: "survey-11",
kind: "shoot",
label: "Съёмка · виток 4321",
startMs: parseLocal("2026-05-29T01:10:00"),
endMs: parseLocal("2026-05-29T01:10:00") + 180_000,
origin: "plan",
planModeId: 11,
revolution: 4321,
rollDeg: 0,
centerLat: 55.7,
centerLon: 37.6,
targetLat: 55.7,
targetLon: 37.6
},
{
id: "drop-22",
kind: "downlink",
label: "Сброс · 102",
startMs: parseLocal("2026-05-29T01:40:00"),
endMs: parseLocal("2026-05-29T01:40:00") + 120_000,
origin: "plan",
planModeId: 22,
revolution: 4322,
stationId: "102",
surveyIds: [11]
}
]);
});
it("adds shoot and downlink works with deterministic ids", () => {
const draft = seedDraft(undefined, "56756");
const withShoot = addShoot(draft, {
id: "shoot-1",
label: "Панхром. съёмка",
modeId: "pan",
startMs: 1000,
endMs: 2000,
target: { id: "target-1", name: "Цель A", lat: 55.7, lon: 37.6 }
});
const withDownlink = addDownlink(withShoot, {
id: "downlink-1",
label: "Сброс на НКПОР",
startMs: 3000,
endMs: 4000,
station: { id: "KPP-1", name: "KPP-1" }
});
expect(withDownlink.works.map((work) => work.id)).toEqual(["shoot-1", "downlink-1"]);
expect(withDownlink.works.every((work) => work.origin === "added" && work.isNew)).toBe(true);
});
it("keeps undo and redo stacks consistent across mutations", () => {
const initial: DraftHistory = { draft: seedDraft(undefined, "56756"), past: [], future: [] };
const changed = mutate(initial, (draft) =>
addDownlink(draft, {
id: "downlink-1",
label: "Сброс",
startMs: 1000,
endMs: 2000,
station: { id: "KPP-1", name: "KPP-1" }
})
);
const undone = undo(changed);
const redone = redo(undone);
expect(changed.past).toHaveLength(1);
expect(undone.draft.works).toHaveLength(0);
expect(undone.future).toHaveLength(1);
expect(redone.draft.works).toHaveLength(1);
expect(redone.future).toHaveLength(0);
});
it("updates and deletes works without generating ids internally", () => {
const draft = addDownlink(seedDraft(plan), {
id: "downlink-1",
label: "Сброс на НКПОР",
startMs: plan.startMs,
endMs: plan.startMs + 120_000,
station: { id: "KPP-1", name: "KPP-1" }
});
const updated = updateWork(draft, "downlink-1", { label: "Сеанс", startMs: plan.startMs + 60_000 });
const deleted = deleteWork(updated, "downlink-1");
expect(updated.works[0].label).toBe("Сеанс");
expect(deleted.works.map((work) => work.id)).toEqual([]);
});
});
@@ -0,0 +1,275 @@
import type { TguPlanUi } from "../../model/timelineTypes";
import { parseLocal } from "../../utils/localTime";
import type { PlanMode } from "./editorApi";
import type {
Draft,
DraftHistory,
EditorDownlinkWork,
EditorGroundStation,
EditorShootWork,
EditorTarget,
EditorWork,
EditorWorkPatch
} from "./model/editorTypes";
const DEFAULT_HISTORY_LIMIT = 50;
export type AddShootInput = {
id: string;
startMs: number;
endMs: number;
label: string;
modeId?: string;
target: EditorTarget;
/** id сохранённого режима съёмки в БД черновика (если съёмка персистнута). */
planModeId?: number;
/** Параметры построенного маршрута (если съёмка строилась с превью). */
revolution?: number;
rollDeg?: number;
captureAngleDeg?: number;
centerLat?: number;
centerLon?: number;
/** Объём маршрута, МБ (из ответа бэка). */
volumeMb?: number;
};
export type AddDownlinkInput = {
id: string;
startMs: number;
endMs: number;
label: string;
station: EditorGroundStation;
/** id сохранённого режима сброса в БД черновика (если сброс персистнут). */
planModeId?: number;
/** Объём сброса, МБ (из ответа бэка). */
volumeMb?: number;
};
export function seedDraft(plan?: TguPlanUi, spacecraftId?: string): Draft {
if (!plan) {
return { spacecraftId, works: [] };
}
return {
planId: plan.planId,
spacecraftId: plan.spacecraftId,
// Черновик содержит только правки, внесённые в редакторе. Существующие включения
// плана грузятся отдельно (fetchPlanModes) и показываются как read-only.
works: readEmbeddedWorks(plan)
};
}
/**
* Преобразует включения плана (plan modes из pcp-tgu-service) в работы для отображения
* на таймлайне редактора. Это read-only представление фактического состава плана:
* SURVEY → съёмка, DROP → сброс на НКПОР.
*/
export function planModesToWorks(modes: PlanMode[]): EditorWork[] {
return modes.flatMap((mode): EditorWork[] => {
if (mode.timeStart == null) return [];
const startMs = parseLocal(mode.timeStart);
if (!Number.isFinite(startMs)) return [];
const endMs = startMs + Math.max(1, mode.duration) * 1000;
if (mode.type === "SURVEY") {
return [{
id: `survey-${mode.id}`,
kind: "shoot",
label: `Съёмка · виток ${mode.revolution}`,
startMs,
endMs,
origin: "plan",
planModeId: mode.id,
revolution: mode.revolution,
rollDeg: mode.roll,
centerLat: mode.lat,
centerLon: mode.longitude,
volumeMb: mode.volumeMb,
// Центр контура — он же опорная точка для проверки видимости.
targetLat: mode.lat,
targetLon: mode.longitude
}];
}
return [{
id: `drop-${mode.id}`,
kind: "downlink",
label: mode.station ? `Сброс · ${mode.station}` : "Сброс на НКПОР",
startMs,
endMs,
origin: "plan",
planModeId: mode.id,
revolution: mode.revolution,
stationId: mode.station ?? undefined,
surveyIds: mode.surveys,
volumeMb: mode.volumeMb
}];
});
}
export function mutate(
history: DraftHistory,
transform: (draft: Draft) => Draft,
historyLimit = DEFAULT_HISTORY_LIMIT
): DraftHistory {
const nextDraft = transform(history.draft);
if (nextDraft === history.draft) {
return history;
}
return {
draft: nextDraft,
past: [...history.past, history.draft].slice(-historyLimit),
future: []
};
}
export function undo(history: DraftHistory): DraftHistory {
if (history.past.length === 0) {
return history;
}
return {
draft: history.past[history.past.length - 1],
past: history.past.slice(0, -1),
future: [history.draft, ...history.future]
};
}
export function redo(history: DraftHistory): DraftHistory {
if (history.future.length === 0) {
return history;
}
return {
draft: history.future[0],
past: [...history.past, history.draft],
future: history.future.slice(1)
};
}
export function addShoot(draft: Draft, input: AddShootInput): Draft {
const work: EditorShootWork = {
id: input.id,
kind: "shoot",
label: input.label,
startMs: input.startMs,
endMs: input.endMs,
modeId: input.modeId,
targetName: input.target.name,
targetLat: input.target.lat,
targetLon: input.target.lon,
planModeId: input.planModeId,
revolution: input.revolution,
rollDeg: input.rollDeg,
captureAngleDeg: input.captureAngleDeg,
centerLat: input.centerLat,
centerLon: input.centerLon,
volumeMb: input.volumeMb,
origin: "added",
isNew: true
};
return { ...draft, works: [...draft.works, work] };
}
export function addDownlink(draft: Draft, input: AddDownlinkInput): Draft {
const work: EditorDownlinkWork = {
id: input.id,
kind: "downlink",
label: input.label,
startMs: input.startMs,
endMs: input.endMs,
stationId: input.station.id,
planModeId: input.planModeId,
volumeMb: input.volumeMb,
origin: "added",
isNew: true
};
return { ...draft, works: [...draft.works, work] };
}
export function updateWork(draft: Draft, workId: string, patch: EditorWorkPatch): Draft {
return {
...draft,
works: draft.works.map((work) => (work.id === workId ? ({ ...work, ...patch } as EditorWork) : work))
};
}
export function deleteWork(draft: Draft, workId: string): Draft {
return {
...draft,
works: draft.works.filter((work) => work.id !== workId)
};
}
function readEmbeddedWorks(plan: TguPlanUi): EditorWork[] {
const rawWorks = (plan as TguPlanUi & { works?: unknown }).works;
if (!Array.isArray(rawWorks)) {
return [];
}
return rawWorks.flatMap((value, index): EditorWork[] => {
if (!isRecord(value)) return [];
const kind = value.kind === "shoot" || value.kind === "downlink" ? value.kind : undefined;
const startMs = readTime(value, "startMs") ?? readTime(value, "start");
const endMs = readTime(value, "endMs") ?? readTime(value, "end");
if (!kind || startMs === undefined || endMs === undefined || startMs >= endMs) {
return [];
}
const id = readString(value.id) ?? `${plan.planId}:work-${index + 1}`;
const label = readString(value.label) ?? (kind === "shoot" ? "Съёмка" : "Сброс на НКПОР");
const base = {
id,
kind,
label,
startMs,
endMs,
origin: "plan" as const
};
if (kind === "shoot") {
return [{
...base,
kind,
modeId: readString(value.modeId),
targetName: readString(value.targetName),
targetLat: readNumber(value.targetLat),
targetLon: readNumber(value.targetLon)
}];
}
return [{
...base,
kind,
stationId: readString(value.stationId) ?? readString(value.kppId)
}];
});
}
function readTime(record: Record<string, unknown>, key: string): number | undefined {
const value = record[key];
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === "string") {
const parsed = parseLocal(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
return undefined;
}
function readString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value : undefined;
}
function readNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
@@ -0,0 +1,113 @@
import type { EditorWorkKind } from "./model/editorTypes";
// Объём данных — общий форматтер (используется и в комплексном плане).
export { formatVolume } from "../../utils/dataVolume";
export const EDITOR_MONTHS = ["янв", "фев", "мар", "апр", "май", "июн", "июл", "авг", "сен", "окт", "ноя", "дек"];
export const DEFAULT_EDITOR_MODES = [
{ id: "pan", label: "Панхром." },
{ id: "ms", label: "Мультиспектр." },
{ id: "stereo", label: "Стерео" },
{ id: "scan", label: "Скан" }
];
export function kindLabel(kind: EditorWorkKind): string {
switch (kind) {
case "shoot":
return "Съёмка";
case "downlink":
return "Сброс";
}
}
export function kindColor(kind: EditorWorkKind): string {
return kind === "shoot" ? "var(--t-optical)" : "var(--accent)";
}
export function formatTime(ms: number): string {
const date = new Date(ms);
return `${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
/**
* Подпись деления оси с учётом шага: при шаге меньше минуты показываем секунды
* (ЧЧ:ММ:СС), иначе — ЧЧ:ММ. Так при сильном зуме видно более мелкий масштаб времени.
*/
export function formatTick(ms: number, stepMs: number): string {
const date = new Date(ms);
if (stepMs < 60_000) {
return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
}
return `${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
export function formatDate(ms: number): string {
const date = new Date(ms);
return `${pad(date.getDate())} ${EDITOR_MONTHS[date.getMonth()]}`;
}
export function formatDateTime(ms: number): string {
return `${formatDate(ms)} ${formatTime(ms)}`;
}
/** Время с секундами и одной десятой долей: ЧЧ:ММ:СС.с (локальное). */
export function formatTimeSeconds(ms: number): string {
const date = new Date(ms);
const tenths = Math.floor((((ms % 1000) + 1000) % 1000) / 100);
return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${tenths}`;
}
/** Дата и время с десятыми долями секунды: «дд мес ЧЧ:ММ:СС.с» (локальное). */
export function formatDateTimeSeconds(ms: number): string {
return `${formatDate(ms)} ${formatTimeSeconds(ms)}`;
}
export function formatDuration(ms: number): string {
const totalSeconds = Math.max(0, Math.round(ms / 1000));
if (totalSeconds < 60) {
return `${totalSeconds} с`;
}
if (totalSeconds < 3600) {
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return seconds > 0 ? `${minutes} мин ${seconds} с` : `${minutes} мин`;
}
const hours = ms / 3_600_000;
if (hours < 24) {
return `${Number.isInteger(hours) ? hours : hours.toFixed(1)} ч`;
}
const days = Math.floor(hours / 24);
const restHours = Math.round(hours - days * 24);
return restHours > 0 ? `${days} сут ${restHours} ч` : `${days} сут`;
}
/** Деления оси времени: {@link ticks} — отметки, {@link step} — выбранный шаг (мс). */
export type TimelineTicks = { ticks: number[]; step: number };
export function niceTicks(fromMs: number, toMs: number, pxPerMs: number, minPx: number): TimelineTicks {
const minuteMs = 60_000;
const hourMs = 3_600_000;
const dayMs = 24 * hourMs;
// От секунд до недели — чем сильнее зум, тем мельче шаг, который влезает по minPx.
const steps = [
1_000, 2_000, 5_000, 10_000, 15_000, 30_000,
minuteMs, 2 * minuteMs, 5 * minuteMs, 10 * minuteMs, 15 * minuteMs, 30 * minuteMs,
hourMs, 2 * hourMs, 3 * hourMs, 6 * hourMs, 12 * hourMs, dayMs, 2 * dayMs, 7 * dayMs
];
const step = steps.find((item) => item * pxPerMs >= minPx) ?? steps[steps.length - 1];
const ticks: number[] = [];
const firstTick = Math.ceil(fromMs / step) * step;
for (let timeMs = firstTick; timeMs <= toMs; timeMs += step) {
ticks.push(timeMs);
}
return { ticks, step };
}
function pad(value: number): string {
return String(value).padStart(2, "0");
}
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import type { MissionValidation } from "../../api/missionPlanningApi";
import { validationToConflicts } from "./editorValidation";
import type { EditorWork } from "./model/editorTypes";
function shoot(id: string, planModeId?: number): EditorWork {
return {
id,
kind: "shoot",
label: "Съёмка",
startMs: 0,
endMs: 1000,
origin: "plan",
planModeId
};
}
describe("validationToConflicts", () => {
it("maps backend conflicts onto editor work ids by planModeId", () => {
const works = [shoot("survey-7", 7), shoot("survey-9", 9)];
const validation: MissionValidation = {
missionId: "m1",
conflicts: [
{ modeId: 7, rule: "TIME_OVERLAP", severity: "error", message: "Пересечение по времени", relatedModeId: 9 },
{ modeId: 7, rule: "TARGET_VISIBILITY", severity: "warning", message: "Вне зоны видимости" }
]
};
const map = validationToConflicts(validation, works);
expect(map["survey-7"]).toEqual(["Пересечение по времени", "Вне зоны видимости"]);
expect(map["survey-9"]).toBeUndefined();
});
it("drops conflicts whose modeId has no matching work", () => {
const works = [shoot("survey-7", 7)];
const validation: MissionValidation = {
missionId: "m1",
conflicts: [{ modeId: 42, rule: "TIME_OVERLAP", severity: "error", message: "x" }]
};
expect(validationToConflicts(validation, works)).toEqual({});
});
it("returns an empty map for the stub (no conflicts)", () => {
const works = [shoot("survey-7", 7)];
expect(validationToConflicts({ missionId: "m1", conflicts: [] }, works)).toEqual({});
});
});
@@ -0,0 +1,29 @@
import type { MissionValidation } from "../../api/missionPlanningApi";
import type { EditorConflictMap } from "./editorConflicts";
import type { EditorWork } from "./model/editorTypes";
/**
* Проецирует авторитетные конфликты бэкенда (валидация миссии-черновика, keyed по numeric modeId)
* на карту конфликтов редактора (keyed по workId). Включения редактора несут `planModeId` —
* идентификатор сохранённого режима в БД; по нему и сопоставляем. Конфликты на режимах, которых
* нет среди работ редактора (например, ещё не подгружены), молча отбрасываются.
*
* MVP: бэк-валидатор — заглушка и возвращает пустой список, поэтому сейчас результат пуст. Маппинг
* написан вперёд: когда правила появятся, UI начнёт подсвечивать конкретные включения без доработок.
*/
export function validationToConflicts(validation: MissionValidation, works: EditorWork[]): EditorConflictMap {
const workIdByModeId = new Map<number, string>();
for (const work of works) {
if (work.planModeId != null) {
workIdByModeId.set(work.planModeId, work.id);
}
}
const result: EditorConflictMap = {};
for (const conflict of validation.conflicts) {
const workId = workIdByModeId.get(conflict.modeId);
if (!workId) continue;
result[workId] = [...(result[workId] ?? []), conflict.message];
}
return result;
}
@@ -0,0 +1,133 @@
import type { TguPlanUi, TguPlatformUi, TimelineRange } from "../../../model/timelineTypes";
export type EditorWorkKind = "shoot" | "downlink";
export type EditorWorkOrigin = "plan" | "added";
export type EditorWorkBase = {
id: string;
kind: EditorWorkKind;
label: string;
startMs: number;
endMs: number;
origin: EditorWorkOrigin;
isNew?: boolean;
/** Объём включения, МБ (считает бэк: скорость × длительность). Есть у включений плана и сохранённых правок. */
volumeMb?: number;
};
export type EditorShootWork = EditorWorkBase & {
kind: "shoot";
modeId?: string;
targetName?: string;
targetLat?: number;
targetLon?: number;
/** Идентификатор сохранённого режима съёмки в БД (есть у включений плана). */
planModeId?: number;
/** Номер витка. */
revolution?: number;
/** Крен (gamma), град. */
rollDeg?: number;
/** Угол захвата (полуширина полосы), град. Есть только у свежепостроенного маршрута. */
captureAngleDeg?: number;
/** Центр контура съёмки, град. */
centerLat?: number;
centerLon?: number;
};
export type EditorDownlinkWork = EditorWorkBase & {
kind: "downlink";
stationId?: string;
/** Идентификатор сохранённого режима сброса в БД (есть у включений плана). */
planModeId?: number;
/** Номер витка зоны радиовидимости. */
revolution?: number;
/** Идентификаторы режимов съёмки (survey_modes), сбрасываемых в этом сеансе. */
surveyIds?: number[];
};
export type EditorWork = EditorShootWork | EditorDownlinkWork;
export type EditorWorkPatch =
Partial<Omit<EditorWorkBase, "id" | "kind">> &
Partial<Pick<EditorShootWork, "modeId" | "targetLat" | "targetLon" | "targetName">> &
Partial<Pick<EditorDownlinkWork, "stationId">>;
export type Draft = {
planId?: string;
spacecraftId?: string;
works: EditorWork[];
};
export type DraftHistory = {
draft: Draft;
past: Draft[];
future: Draft[];
};
export type EditorTarget = {
id: string;
name: string;
lat: number;
lon: number;
};
export type EditorGroundStation = {
id: string;
name: string;
lat?: number;
lon?: number;
};
export type EditorVisibilityWindow = {
startMs: number;
endMs: number;
quality: number;
/** Номер витка (если окно получено из баллистики). */
revolution?: number;
/** Крен, град. */
rollDeg?: number;
/** Угол Солнца, град. */
sunAngleDeg?: number;
/** Наклонная дальность. */
range?: number;
/** Направление витка. */
revSign?: "ASC" | "DESC";
};
export type EditorVisibilityTrack = {
id: string;
label: string;
kind: "target" | "station";
color: string;
windows: EditorVisibilityWindow[];
};
export type EditorTimelineWindow = {
fromMs: number;
toMs: number;
};
export type EditorContextLane = {
spacecraftId: string;
label: string;
works: EditorWork[];
};
export type EditorMode = {
id: string;
label: string;
};
export type EditorCheckContext = {
spacecraftId?: string;
stations: EditorGroundStation[];
marginMs?: number;
};
export type TguEditorTabProps = {
selectedSpacecraftId?: string;
selectedPlan?: TguPlanUi;
appliedRange: TimelineRange;
platforms: TguPlatformUi[];
};
@@ -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,92 @@
import type { StationDto } from "../../api/stationsApi";
import type { RequestMapItem } from "../../model/requestTypes";
import type { TguPlanUi, TguPlatformUi, TimelineRange } from "../../model/timelineTypes";
import { parseWktPolygon } from "./geometry/wktParser";
import type { Tgu2DMapLayersState } from "./model/mapLayerTypes";
import type { MapMarker, MapPolygon, Tgu2DMapScene } from "./model/mapTypes";
export type Tgu2DMapLayerAvailability = Partial<Record<keyof Tgu2DMapLayersState, string>>;
export type Tgu2DMapSceneInput = {
range: TimelineRange;
selectedPlan?: TguPlanUi;
platforms: TguPlatformUi[];
hiddenSpacecraftIds?: ReadonlySet<string>;
requestItems?: RequestMapItem[];
stationItems?: StationDto[];
};
const UNAVAILABLE = "Данные слоя недоступны в текущем API.";
export function buildTgu2DMapScene(input: Tgu2DMapSceneInput): Tgu2DMapScene {
const requestPolygons = buildRequestPolygons(input.requestItems ?? []);
const stationMarkers = buildStationMarkers(input.stationItems ?? []);
return {
polygons: requestPolygons,
lines: [],
markers: stationMarkers
};
}
function buildRequestPolygons(items: RequestMapItem[]): MapPolygon[] {
const polygons: MapPolygon[] = [];
let zIndex = 0;
for (const item of items) {
const rings = parseWktPolygon(item.geometry);
for (const ring of rings) {
if (ring.length < 3) continue;
polygons.push({
id: `request:${item.id}:${zIndex}`,
name: item.name,
kind: "request",
layer: "requests",
points: ring,
requestId: item.id,
surveyType: item.surveyType,
zIndex: zIndex++
});
}
}
return polygons;
}
export function buildStationMarkers(items: StationDto[]): MapMarker[] {
return items.map((item, index) => ({
id: item.id ?? `station:${index}`,
layer: "stations" as const,
point: { lon: item.position.long, lat: item.position.lat },
label: item.name,
zIndex: index,
stationNumber: item.number,
altitude: item.position.height,
elevationMin: item.elevationMin,
elevationMax: item.elevationMax
}));
}
export function getLayerAvailability(
layers: Tgu2DMapLayersState,
scene: Tgu2DMapScene,
stationsFetchFailed?: boolean
): 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 && stationsFetchFailed) {
availability.stations = UNAVAILABLE;
}
if (layers.spacecraftMarkers && scene.markers.filter((marker) => marker.layer === "spacecraftMarkers").length === 0) {
availability.spacecraftMarkers = UNAVAILABLE;
}
return availability;
}
@@ -0,0 +1,116 @@
import type { RequestMapItem } from "../../model/requestTypes";
import type { MapPolygon } from "./model/mapTypes";
import { formatLocalDateTime, parseLocal } from "../../utils/localTime";
export type RequestAtPoint = { polygon: MapPolygon; item: RequestMapItem };
type Props = {
requests: RequestAtPoint[];
selectedPolygonId?: string;
onHover: (polygonId: string | undefined) => void;
onSelect: (polygonId: string) => void;
onClose: () => void;
};
export function Tgu2DMapRequestsPanel({ requests, selectedPolygonId, onHover, onSelect, onClose }: Props) {
const selectedEntry = selectedPolygonId ? requests.find((r) => r.polygon.id === selectedPolygonId) : undefined;
return (
<aside className="tgu-map-requests-panel">
<div className="tgu-panel-heading">
<span>Заявки в точке ({requests.length})</span>
<button className="tgu-link-button" type="button" onClick={onClose}>
Закрыть
</button>
</div>
<div className="tgu-map-requests-list">
{requests.map(({ polygon, item }) => (
<button
key={polygon.id}
className={`tgu-map-request-row ${polygon.id === selectedPolygonId ? "is-selected" : ""}`}
type="button"
onMouseEnter={() => onHover(polygon.id)}
onMouseLeave={() => onHover(undefined)}
onClick={() => onSelect(polygon.id)}
>
<i className={`tgu-map-request-row__type req-${item.surveyType.toLowerCase()}`} />
<span className="tgu-map-request-row__body">
<span className="tgu-map-request-row__name">{item.name || item.id}</span>
<span className="tgu-map-request-row__meta">
{formatDateRange(item.beginDateTime, item.endDateTime)}
{" · "}
{item.surveyType}
{" · "}
{item.status}
</span>
</span>
</button>
))}
</div>
{selectedEntry && (
<div className="tgu-map-request-details">
<div className="tgu-map-panel__label">Детали заявки</div>
<dl className="tgu-map-request-details__grid">
<dt>ID</dt>
<dd className="mono">{selectedEntry.item.id}</dd>
<dt>Название</dt>
<dd>{selectedEntry.item.name || "—"}</dd>
<dt>Статус</dt>
<dd>{selectedEntry.item.status}</dd>
<dt>Тип съёмки</dt>
<dd>{selectedEntry.item.surveyType}</dd>
<dt>Начало</dt>
<dd className="mono">{formatDateTime(selectedEntry.item.beginDateTime)}</dd>
<dt>Конец</dt>
<dd className="mono">{formatDateTime(selectedEntry.item.endDateTime)}</dd>
<dt>Важность</dt>
<dd className="mono">{selectedEntry.item.importance}</dd>
<dt>Покрытие</dt>
<dd className="mono">{selectedEntry.item.coveragePercent != null ? `${selectedEntry.item.coveragePercent.toFixed(1)} %` : "—"}</dd>
<dt>КПП</dt>
<dd className="mono">{selectedEntry.item.kpp?.join(", ") || "—"}</dd>
<dt>Приоритет передачи</dt>
<dd>{selectedEntry.item.highPriorityTransmit ? "Да" : "Нет"}</dd>
<dt>Создано</dt>
<dd className="mono">{formatDateTime(selectedEntry.item.createdAt)}</dd>
<dt>Изменено</dt>
<dd className="mono">{formatDateTime(selectedEntry.item.updatedAt)}</dd>
</dl>
</div>
)}
</aside>
);
}
function formatDateTime(value: string): string {
return formatLocalDateTime(parseLocal(value));
}
function formatDateRange(begin: string, end: string): string {
const b = new Date(parseLocal(begin));
const e = new Date(parseLocal(end));
const sameDay =
b.getFullYear() === e.getFullYear() &&
b.getMonth() === e.getMonth() &&
b.getDate() === e.getDate();
const dateOpts: Intl.DateTimeFormatOptions = { day: "2-digit", month: "2-digit" };
const timeOpts: Intl.DateTimeFormatOptions = { hour: "2-digit", minute: "2-digit" };
const beginStr = `${b.toLocaleDateString("ru-RU", dateOpts)} ${b.toLocaleTimeString("ru-RU", timeOpts)}`;
const endStr = sameDay
? b.toLocaleTimeString("ru-RU", timeOpts)
: `${e.toLocaleDateString("ru-RU", dateOpts)} ${e.toLocaleTimeString("ru-RU", timeOpts)}`;
return `${beginStr} ${endStr}`;
}
@@ -0,0 +1,202 @@
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";
import type { RequestsState } from "./Tgu2DMapTab";
import { parseLocal } from "../../utils/localTime";
import { DateTime24Field } from "../../components/timeFields";
const DAY_MS = 24 * 60 * 60 * 1000;
type Tgu2DMapSidebarProps = {
platforms: TguPlatformUi[];
selectedPlanId?: string;
hiddenIds: ReadonlySet<string>;
layers: Tgu2DMapLayersState;
search: string;
layerAvailability: Tgu2DMapLayerAvailability;
requestsState: RequestsState;
onSearchChange: (value: string) => void;
onToggleSpacecraft: (id: string) => void;
onShowAll: () => void;
onToggleLayer: (key: Tgu2DMapLayerKey) => void;
onRequestsFromChange: (value: string) => void;
onRequestsToChange: (value: string) => void;
onRequestsApply: () => void;
onRequestsQuickRange: (fromMs: number, toMs: number) => void;
};
export function Tgu2DMapSidebar({
platforms,
selectedPlanId,
hiddenIds,
layers,
search,
layerAvailability,
requestsState,
onSearchChange,
onToggleSpacecraft,
onShowAll,
onToggleLayer,
onRequestsFromChange,
onRequestsToChange,
onRequestsApply,
onRequestsQuickRange
}: Tgu2DMapSidebarProps) {
const filteredPlatforms = filterPlatforms(platforms, search);
const hiddenCount = hiddenIds.size;
const quickToday = () => {
const now = new Date();
const fromMs = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
onRequestsQuickRange(fromMs, fromMs + DAY_MS);
};
const quick7days = () => {
const fromMs = Date.now();
onRequestsQuickRange(fromMs, fromMs + 7 * DAY_MS);
};
const quick30days = () => {
const fromMs = Date.now();
onRequestsQuickRange(fromMs, fromMs + 30 * DAY_MS);
};
const requestsFromMs = parseLocal(requestsState.fromValue);
const requestsToMs = parseLocal(requestsState.toValue);
const requestsRangeInvalid =
!Number.isFinite(requestsFromMs) ||
!Number.isFinite(requestsToMs) ||
requestsFromMs >= requestsToMs;
return (
<aside className="tgu-map-panel">
<div className="tgu-panel-heading">
<span>Карта</span>
{hiddenCount > 0 && (
<button className="tgu-link-button" type="button" onClick={onShowAll}>
Показать все ({hiddenCount} скрыто)
</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-map-requests-filter">
<label className="tgu-field">
<span>С</span>
<DateTime24Field value={requestsState.fromValue} onChange={onRequestsFromChange} />
</label>
<label className="tgu-field">
<span>По</span>
<DateTime24Field value={requestsState.toValue} onChange={onRequestsToChange} />
</label>
<div className="tgu-map-requests-filter__actions">
<button
className="tgu-button tgu-button--primary tgu-button--sm"
type="button"
onClick={onRequestsApply}
disabled={requestsState.loading || requestsRangeInvalid}
>
{requestsState.loading ? "Загрузка..." : "Загрузить"}
</button>
<button className="tgu-button tgu-button--sm" type="button" onClick={quickToday} disabled={requestsState.loading}>
Сегодня
</button>
<button className="tgu-button tgu-button--sm" type="button" onClick={quick7days} disabled={requestsState.loading}>
7 дней
</button>
<button className="tgu-button tgu-button--sm" type="button" onClick={quick30days} disabled={requestsState.loading}>
30 дней
</button>
</div>
{requestsState.error && (
<div className="tgu-alert tgu-alert--danger tgu-alert--compact">{requestsState.error}</div>
)}
{requestsState.loading && requestsState.totalItems > 0 && (
<div className="tgu-map-requests-filter__status">
Загрузка: {requestsState.loadedCount} / {requestsState.totalItems}...
</div>
)}
{requestsState.loaded && !requestsState.error && (
<div className="tgu-map-requests-filter__status">
{requestsState.items.length === 0
? "Заявок не найдено."
: `Загружено: ${requestsState.items.length} из ${requestsState.totalItems}`}
{requestsState.truncated && (
<span className="tgu-map-requests-filter__truncated"> (показаны первые 5000 уточните интервал)</span>
)}
</div>
)}
{requestsState.loaded && requestsState.items.length > 0 && (
<div className="tgu-map-requests-legend">
<span><i className="req-optics" />OPTICS</span>
<span><i className="req-rsa" />RSA</span>
<span><i className="req-combined" />COMBINED</span>
</div>
)}
</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 visible = !hiddenIds.has(platform.spacecraftId);
return (
<label
className={`tgu-map-spacecraft ${visible ? "" : "is-hidden"}`}
key={platform.spacecraftId}
>
<input
type="checkbox"
checked={visible}
onChange={() => onToggleSpacecraft(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>
</label>
);
})}
{filteredPlatforms.length === 0 && <div className="tgu-empty tgu-empty--compact">Нет КА по текущему поиску.</div>}
</div>
</div>
</aside>
);
}
@@ -0,0 +1,43 @@
import type { Tgu2DMapSelection } from "./model/mapSelectionTypes";
type StationSelection = Extract<Tgu2DMapSelection, { type: "marker" }> & { layer: "stations" };
type Props = {
selection: StationSelection;
onClose: () => void;
};
export function Tgu2DMapStationPanel({ selection, onClose }: Props) {
return (
<aside className="tgu-map-requests-panel">
<div className="tgu-panel-heading">
<span>Станция</span>
<button className="tgu-link-button" type="button" onClick={onClose}>
Закрыть
</button>
</div>
<div className="tgu-map-request-details" style={{ flex: 1 }}>
<div className="tgu-map-panel__label">{selection.name}</div>
<dl className="tgu-map-request-details__grid">
<dt>Номер</dt>
<dd className="mono">{selection.stationNumber ?? "—"}</dd>
<dt>Широта</dt>
<dd className="mono">{selection.lat != null ? `${selection.lat.toFixed(4)}°` : "—"}</dd>
<dt>Долгота</dt>
<dd className="mono">{selection.lon != null ? `${selection.lon.toFixed(4)}°` : "—"}</dd>
<dt>Высота, м</dt>
<dd className="mono">{selection.altitude != null ? selection.altitude.toFixed(1) : "—"}</dd>
<dt>Угол места мин, °</dt>
<dd className="mono">{selection.elevationMin != null ? selection.elevationMin.toFixed(1) : "—"}</dd>
<dt>Угол места макс, °</dt>
<dd className="mono">{selection.elevationMax != null ? selection.elevationMax.toFixed(1) : "—"}</dd>
</dl>
</div>
</aside>
);
}
@@ -0,0 +1,324 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { fetchRequestsForMap } from "../../api/requestApi";
import { fetchStations, type StationDto } from "../../api/stationsApi";
import type { RequestMapItem } from "../../model/requestTypes";
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 type { MapPolygon } from "./model/mapTypes";
import { buildTgu2DMapScene, getLayerAvailability } from "./Tgu2DMapLayers";
import { Tgu2DMapDetails } from "./Tgu2DMapDetails";
import { Tgu2DMapRequestsPanel, type RequestAtPoint } from "./Tgu2DMapRequestsPanel";
import { Tgu2DMapSidebar } from "./Tgu2DMapSidebar";
import { Tgu2DMapStationPanel } from "./Tgu2DMapStationPanel";
import { Tgu2DMapToolbar } from "./Tgu2DMapToolbar";
import { Tgu2DMapView } from "./Tgu2DMapView";
import { getTgu2DMapIntervalState, TGU_2D_MAP_INTERVAL_WARNING } from "./tgu2DMapInterval";
import { parseLocal, toLocalInputValue, toLocalIso } from "../../utils/localTime";
type Tgu2DMapTabProps = {
range: TimelineRange;
invalidRange: boolean;
selectedPlan?: TguPlanUi;
platforms: TguPlatformUi[];
};
export type RequestsState = {
fromValue: string;
toValue: string;
items: RequestMapItem[];
loading: boolean;
loadedCount: number;
error?: string;
totalItems: number;
truncated: boolean;
loaded: boolean;
};
function buildInitialRequestsState(range: TimelineRange): RequestsState {
return {
fromValue: toLocalInputValue(range.fromMs),
toValue: toLocalInputValue(range.toMs),
items: [],
loading: false,
loadedCount: 0,
totalItems: 0,
truncated: false,
loaded: false
};
}
export function Tgu2DMapTab({
range,
invalidRange,
selectedPlan,
platforms
}: 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 [hiddenIds, setHiddenIds] = useState<ReadonlySet<string>>(new Set());
const [requestsState, setRequestsState] = useState<RequestsState>(() => buildInitialRequestsState(range));
const [stations, setStations] = useState<StationDto[]>([]);
const [stationsFetchFailed, setStationsFetchFailed] = useState(false);
const [requestsAtPoint, setRequestsAtPoint] = useState<RequestAtPoint[]>([]);
const [selectedRequestPolygonId, setSelectedRequestPolygonId] = useState<string | undefined>();
const [hoveredRequestPolygonId, setHoveredRequestPolygonId] = useState<string | undefined>();
const [stationPanel, setStationPanel] = useState<(Tgu2DMapSelection & { type: "marker"; layer: "stations" }) | undefined>();
useEffect(() => {
fetchStations()
.then((items) => { setStations(items); setStationsFetchFailed(false); })
.catch(() => setStationsFetchFailed(true));
}, []);
const mapInterval = getTgu2DMapIntervalState(range, selectedPlan);
const mapInvalidRange = invalidRange || mapInterval.tooLarge;
const loadRequests = useCallback(async (fromValue: string, toValue: string) => {
const fromMs = parseLocal(fromValue);
const toMs = parseLocal(toValue);
if (!Number.isFinite(fromMs) || !Number.isFinite(toMs) || fromMs >= toMs) return;
setRequestsState((current) => ({ ...current, loading: true, error: undefined, loadedCount: 0 }));
try {
const result = await fetchRequestsForMap(
{
endFrom: toLocalIso(fromMs),
beginTo: toLocalIso(toMs)
},
(loaded, total) => {
setRequestsState((current) => ({ ...current, loadedCount: loaded, totalItems: total }));
}
);
setRequestsState((current) => ({
...current,
items: result.items,
loadedCount: result.items.length,
totalItems: result.totalItems,
truncated: result.truncated,
loading: false,
loaded: true
}));
} catch (error) {
setRequestsState((current) => ({
...current,
loading: false,
error: error instanceof Error ? error.message : "Не удалось загрузить заявки."
}));
}
}, []);
const applyRequestsRange = useCallback(() => {
void loadRequests(requestsState.fromValue, requestsState.toValue);
}, [loadRequests, requestsState.fromValue, requestsState.toValue]);
const setRequestsFrom = useCallback((fromValue: string) => {
setRequestsState((current) => ({ ...current, fromValue }));
}, []);
const setRequestsTo = useCallback((toValue: string) => {
setRequestsState((current) => ({ ...current, toValue }));
}, []);
const setRequestsQuickRange = useCallback((fromMs: number, toMs: number) => {
const fromValue = toLocalInputValue(fromMs);
const toValue = toLocalInputValue(toMs);
setRequestsState((current) => ({ ...current, fromValue, toValue }));
void loadRequests(fromValue, toValue);
}, [loadRequests]);
useEffect(() => {
setRequestsState(buildInitialRequestsState(range));
}, [range]);
const toggleSpacecraft = useCallback((id: string) => {
setHiddenIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
const showAll = useCallback(() => setHiddenIds(new Set()), []);
const planPlatform = useMemo(
() => selectedPlan ? platforms.find((p) => p.spacecraftId === selectedPlan.spacecraftId) : undefined,
[platforms, selectedPlan]
);
const satelliteLabel = selectedPlan
? platformLabel(planPlatform, selectedPlan.spacecraftId)
: hiddenIds.size > 0
? `Скрыто: ${hiddenIds.size} КА`
: "Все КА";
const intervalLabel = `${formatDate(mapInterval.range.fromMs)} - ${formatDate(mapInterval.range.toMs)}`;
const scene = useMemo(
() => buildTgu2DMapScene({
range: mapInterval.range,
selectedPlan,
platforms,
hiddenSpacecraftIds: hiddenIds,
requestItems: layers.requests ? requestsState.items : [],
stationItems: layers.stations ? stations : []
}),
[mapInterval.range, platforms, selectedPlan, hiddenIds, requestsState.items, layers.requests, stations, layers.stations]
);
const layerAvailability = useMemo(
() => getLayerAvailability(layers, scene, stationsFetchFailed),
[layers, scene, stationsFetchFailed]
);
const overlayMessage = invalidRange
? "Некорректный интервал: from должен быть меньше to."
: mapInterval.tooLarge
? TGU_2D_MAP_INTERVAL_WARNING
: undefined;
const toggleLayer = (key: Tgu2DMapLayerKey) => {
setLayers((current) => ({ ...current, [key]: !current[key] }));
};
const handleObjectSelect = useCallback((selection?: Tgu2DMapSelection) => {
if (selection?.type === "marker" && selection.layer === "stations") {
setStationPanel(selection as Tgu2DMapSelection & { type: "marker"; layer: "stations" });
setRequestsAtPoint([]);
setSelectedRequestPolygonId(undefined);
setHoveredRequestPolygonId(undefined);
setSelectedObject(undefined);
} else {
setStationPanel(undefined);
setSelectedObject(selection);
}
}, []);
const handleRequestsAtPoint = useCallback(
(polygons: MapPolygon[]) => {
if (polygons.length === 0) {
setRequestsAtPoint([]);
setSelectedRequestPolygonId(undefined);
setHoveredRequestPolygonId(undefined);
return;
}
const joined = polygons.flatMap((polygon) => {
const item = requestsState.items.find((r) => r.id === polygon.requestId);
return item ? [{ polygon, item }] : [];
});
setRequestsAtPoint(joined);
setSelectedRequestPolygonId(undefined);
setHoveredRequestPolygonId(undefined);
setStationPanel(undefined);
},
[requestsState.items]
);
const closeRequestsPanel = useCallback(() => {
setRequestsAtPoint([]);
setSelectedRequestPolygonId(undefined);
setHoveredRequestPolygonId(undefined);
}, []);
const closeStationPanel = useCallback(() => {
setStationPanel(undefined);
}, []);
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${(requestsAtPoint.length > 0 || stationPanel != null) ? " has-requests-panel" : ""}`}>
<Tgu2DMapSidebar
platforms={platforms}
selectedPlanId={selectedPlan?.planId}
hiddenIds={hiddenIds}
layers={layers}
search={search}
layerAvailability={layerAvailability}
requestsState={requestsState}
onSearchChange={setSearch}
onToggleSpacecraft={toggleSpacecraft}
onShowAll={showAll}
onToggleLayer={toggleLayer}
onRequestsFromChange={setRequestsFrom}
onRequestsToChange={setRequestsTo}
onRequestsApply={applyRequestsRange}
onRequestsQuickRange={setRequestsQuickRange}
/>
<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={handleObjectSelect}
onRequestsAtPoint={handleRequestsAtPoint}
hoveredRequestPolygonId={hoveredRequestPolygonId}
selectedRequestPolygonId={selectedRequestPolygonId}
/>
</section>
{requestsAtPoint.length > 0 && (
<Tgu2DMapRequestsPanel
requests={requestsAtPoint}
selectedPolygonId={selectedRequestPolygonId}
onHover={setHoveredRequestPolygonId}
onSelect={setSelectedRequestPolygonId}
onClose={closeRequestsPanel}
/>
)}
{stationPanel != null && (
<Tgu2DMapStationPanel selection={stationPanel} onClose={closeStationPanel} />
)}
</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 "complexPlan":
return "Комплексный план";
case "stations":
return "Станции";
case "spacecraftMarkers":
return "Маркеры КА";
case "requests":
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">Карта</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,762 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { drawBaseMap } from "./canvas/drawBaseMap";
import { drawComplexPlan } from "./canvas/drawComplexPlan";
import { drawPlanWorks } from "./canvas/drawPlanWorks";
import { drawRequests } from "./canvas/drawRequests";
import { drawStations } from "./canvas/drawStations";
import { drawSwath } from "./canvas/drawSwath";
import { drawTiles } from "./canvas/drawTiles";
import { drawTracks } from "./canvas/drawTracks";
import { createMapProjection, normalizeLon } from "./geometry/mapProjection";
import { createPolygonSpatialIndex, hitTestAllPolygons, hitTestPolygons, queryPolygonsByBBox } from "./geometry/spatialIndex";
import type { Tgu2DMapLayersState } from "./model/mapLayerTypes";
import type { GeoPoint, MapLine, MapMarker, 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;
onRequestsAtPoint?: (polygons: MapPolygon[]) => void;
hoveredRequestPolygonId?: string;
selectedRequestPolygonId?: string;
/** Внешняя подсветка объекта (контур съёмки или линия сброса) по его id — «как будто кликнули на карте». */
selectedObjectId?: string;
/** Точка, на которую центрировать карту; перецентрирование срабатывает при смене focusToken. */
focusPoint?: GeoPoint;
focusToken?: number;
/**
* Включает выбор объекта под курсором: если в точке клика лежит несколько объектов
* (например, заявка и маршрут съёмки), их список отдаётся через onObjectsAtPoint вместо
* «молчаливого» выбора верхнего по z-индексу. Также делает кликабельными линии сброса.
*/
enableObjectChooser?: boolean;
/** Несколько объектов под точкой клика — список передаётся наверх (выбор делает оператор справа). */
onObjectsAtPoint?: (selections: Tgu2DMapSelection[]) => void;
pickMode?: boolean;
onPickPoint?: (point: { lat: number; lon: number }) => void;
targets?: Tgu2DMapTarget[];
pickTarget?: Tgu2DMapTarget;
selectedTargetId?: string;
onTargetSelect?: (id: string) => void;
drawMode?: boolean;
draftPolygon?: GeoPoint[];
onDrawAddPoint?: (point: GeoPoint) => void;
};
export type Tgu2DMapTarget = {
id: string;
name: string;
lat: number;
lon: number;
};
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,
onRequestsAtPoint,
hoveredRequestPolygonId,
selectedRequestPolygonId,
selectedObjectId,
focusPoint,
focusToken,
enableObjectChooser = false,
onObjectsAtPoint,
pickMode = false,
onPickPoint,
targets = [],
pickTarget,
selectedTargetId,
onTargetSelect,
drawMode = false,
draftPolygon = [],
onDrawAddPoint
}: Tgu2DMapViewProps) {
const wrapperRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const dragRef = useRef<DragState | undefined>(undefined);
const hasDraggedRef = useRef(false);
const hoverRef = useRef<MapPolygon | undefined>(undefined);
const hoverMarkerRef = useRef<MapMarker | undefined>(undefined);
const selectedMarkerRef = useRef<MapMarker | undefined>(undefined);
const [size, setSize] = useState<MapSize>({ width: 1000, height: 560 });
const [view, setView] = useState<MapViewState>(INITIAL_VIEW);
const [tileVersion, setTileVersion] = useState(0);
const handleTileLoad = useCallback(() => setTileVersion((v) => v + 1), []);
const [tooltip, setTooltip] = useState<TooltipState>();
// Гео-координаты под курсором — показываем по центру внизу карты.
const [cursorGeo, setCursorGeo] = useState<GeoPoint>();
const selectedPolygonRef = useRef<MapPolygon | undefined>(undefined);
const selectedLineRef = useRef<MapLine | undefined>(undefined);
const activePolygons = useMemo(
() => scene.polygons.filter((polygon) => layers[polygon.layer]),
[scene.polygons, layers]
);
const polygonIndex = useMemo(() => createPolygonSpatialIndex(activePolygons), [activePolygons]);
const activeMarkers = useMemo(
() => scene.markers.filter((marker) => layers[marker.layer]),
[scene.markers, layers]
);
const projection = useMemo(() => createMapProjection(view, size), [view, size]);
const visiblePolygons = useMemo(() => {
// When the canvas is wider than the world tile, the entire globe is visible —
// bbox filtering would produce a nonsensical range, so skip it.
// When the viewport crosses the antimeridian, topLeft.lon ends up east of
// bottomRight.lon — same situation, show everything.
const topLeft = projection.unproject({ x: 0, y: 0 });
const bottomRight = projection.unproject({ x: size.width, y: size.height });
if (size.width >= projection.worldSize || topLeft.lon >= bottomRight.lon) {
return activePolygons;
}
return queryPolygonsByBBox(polygonIndex, {
minLon: topLeft.lon,
maxLon: bottomRight.lon,
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();
}, []);
// Перецентрирование по внешней команде (клик по включению в редакторе): центрируем карту
// на объекте и подтягиваем зум, чтобы он был хорошо виден. Триггер — смена focusToken,
// поэтому повторный клик по тому же включению снова возвращает карту к объекту.
useEffect(() => {
if (!focusPoint || focusToken === undefined) return;
setView((current) => ({
centerLon: focusPoint.lon,
centerLat: Math.max(-82, Math.min(82, focusPoint.lat)),
zoom: Math.max(current.zoom, 4.5)
}));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [focusToken]);
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);
drawTiles(context, view, projection, size, handleTileLoad);
drawBaseMap(context, projection);
if (layers.requests) {
drawRequests(context, visiblePolygons.filter((polygon) => polygon.layer === "requests"), projection, size);
}
if (layers.swath) {
drawSwath(context, visiblePolygons.filter((polygon) => polygon.layer === "swath"), projection, size);
}
if (layers.complexPlan) {
drawComplexPlan(context, visiblePolygons.filter((polygon) => polygon.layer === "complexPlan"), 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);
}, [handleTileLoad, layers, projection, redrawToken, scene.lines, scene.markers, size, tileVersion, view, 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);
setCursorGeo(projection.unproject(screenPoint));
const marker = hitTestMarkers(activeMarkers, screenPoint, projection);
const polygon = marker ? undefined : hitTestPolygons(polygonIndex, projection.unproject(screenPoint));
if (marker?.id === hoverMarkerRef.current?.id && polygon?.id === hoverRef.current?.id) return;
hoverMarkerRef.current = marker;
hoverRef.current = polygon;
if (!marker && !polygon) {
setTooltip(undefined);
return;
}
setTooltip({
x: screenPoint.x + 12,
y: screenPoint.y + 12,
title: marker ? marker.label : polygon!.name,
subtitle: marker ? "Станция" : layerTitle(polygon!.layer)
});
},
[activeMarkers, polygonIndex, projection, screenPointFromEvent]
);
const onPointerDown = (event: React.PointerEvent) => {
const screenPoint = screenPointFromEvent(event);
hasDraggedRef.current = false;
dragRef.current = {
startX: screenPoint.x,
startY: screenPoint.y,
centerAtStart: { lon: view.centerLon, lat: view.centerLat }
};
wrapperRef.current?.setPointerCapture(event.pointerId);
};
const DRAG_THRESHOLD = 4;
const onPointerMove = (event: React.PointerEvent) => {
const drag = dragRef.current;
if (!drag) {
updateHover(event);
return;
}
const screenPoint = screenPointFromEvent(event);
setCursorGeo(projection.unproject(screenPoint));
const dx = screenPoint.x - drag.startX;
const dy = screenPoint.y - drag.startY;
if (!hasDraggedRef.current && Math.sqrt(dx * dx + dy * dy) > DRAG_THRESHOLD) {
hasDraggedRef.current = true;
}
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);
};
// Применить выбор конкретного объекта (из меню выбора либо при единственном попадании):
// выставляем внутреннюю подсветку и сообщаем наверх через onObjectSelect.
const applyEditorHit = useCallback((hit: EditorHit) => {
selectedPolygonRef.current = hit.kind === "polygon" ? hit.polygon : undefined;
selectedMarkerRef.current = hit.kind === "marker" ? hit.marker : undefined;
selectedLineRef.current = hit.kind === "line" ? hit.line : undefined;
onObjectSelect(hit.selection);
}, [onObjectSelect]);
// Клик в редакторе: собираем ВСЕ интерактивные объекты под точкой (заявки, маршруты съёмки,
// сбросы, станции). 0 → возможно полоса обзора под низом; 1 → выбираем сразу;
// ≥2 → отдаём список наверх (оператор выбирает нужный в правой панели).
const handleEditorClick = useCallback((screenPoint: ScreenPoint) => {
const geoPoint = projection.unproject(screenPoint);
const hits: EditorHit[] = [];
const marker = hitTestMarkers(activeMarkers, screenPoint, projection);
if (marker) hits.push({ kind: "marker", marker, selection: markerToSelection(marker) });
for (const polygon of hitTestAllPolygons(polygonIndex, geoPoint)) {
if (polygon.layer !== "requests" && polygon.layer !== "planWorks") continue;
hits.push({ kind: "polygon", polygon, selection: polygonToSelection(polygon) });
}
const dropLines = scene.lines.filter((line) => line.kind === "drop" && layers[line.layer]);
for (const line of hitTestLines(dropLines, screenPoint, projection)) {
hits.push({ kind: "line", line, selection: lineToSelection(line) });
}
if (hits.length >= 2 && onObjectsAtPoint) {
// Несколько объектов — внутреннюю подсветку снимаем (её ведёт правая панель через selectedObjectId).
selectedPolygonRef.current = undefined;
selectedMarkerRef.current = undefined;
selectedLineRef.current = undefined;
onObjectsAtPoint(hits.map((hit) => hit.selection));
return;
}
if (hits.length >= 1) {
applyEditorHit(hits[0]);
return;
}
// Ничего «интерактивного» — выбираем верхний прочий полигон (напр. полосу обзора) либо снимаем выбор.
const fallback = hitTestPolygons(polygonIndex, geoPoint);
selectedPolygonRef.current = fallback;
selectedMarkerRef.current = undefined;
selectedLineRef.current = undefined;
onObjectSelect(fallback ? polygonToSelection(fallback) : undefined);
}, [activeMarkers, applyEditorHit, layers, onObjectSelect, onObjectsAtPoint, polygonIndex, projection, scene.lines]);
const onClick = (event: React.MouseEvent) => {
if (hasDraggedRef.current) return;
const screenPoint = screenPointFromEvent(event);
if (drawMode) {
onDrawAddPoint?.(projection.unproject(screenPoint));
return;
}
if (pickMode) {
onPickPoint?.(projection.unproject(screenPoint));
onObjectSelect(undefined);
return;
}
if (enableObjectChooser) {
handleEditorClick(screenPoint);
return;
}
const marker = hitTestMarkers(activeMarkers, screenPoint, projection);
if (marker) {
selectedMarkerRef.current = marker;
selectedPolygonRef.current = undefined;
onRequestsAtPoint?.([]);
onObjectSelect({
type: "marker",
id: marker.id,
name: marker.label,
layer: marker.layer,
stationNumber: marker.stationNumber,
lat: marker.point.lat,
lon: marker.point.lon,
altitude: marker.altitude,
elevationMin: marker.elevationMin,
elevationMax: marker.elevationMax
});
return;
}
const geoPoint = projection.unproject(screenPoint);
const polygon = hitTestPolygons(polygonIndex, geoPoint);
if (polygon?.layer === "requests" && onRequestsAtPoint) {
const allRequests = hitTestAllPolygons(polygonIndex, geoPoint).filter((p) => p.layer === "requests");
onRequestsAtPoint(allRequests);
selectedPolygonRef.current = undefined;
selectedMarkerRef.current = undefined;
onObjectSelect(undefined);
return;
}
onRequestsAtPoint?.([]);
selectedPolygonRef.current = polygon;
selectedMarkerRef.current = undefined;
if (!polygon) {
onObjectSelect(undefined);
return;
}
onObjectSelect({
type: "polygon",
id: polygon.id,
name: polygon.name,
layer: polygon.layer,
planId: polygon.planId,
spacecraftId: polygon.spacecraftId,
requestId: polygon.requestId,
surveyType: polygon.surveyType,
status: polygon.status,
modeId: polygon.modeId
});
};
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 selectedMarker = selectedMarkerRef.current;
const externalSelectedPolygon = useMemo(() => {
const id = selectedRequestPolygonId ?? selectedObjectId;
return id ? activePolygons.find((p) => p.id === id) : undefined;
}, [selectedRequestPolygonId, selectedObjectId, activePolygons]);
// Подсветка линии (сброс/трасса) по тому же selectedObjectId — линии живут вне activePolygons.
const externalSelectedLine = useMemo(
() => (selectedObjectId ? scene.lines.find((line) => line.id === selectedObjectId) : undefined),
[selectedObjectId, scene.lines]
);
// Линии под подсветкой: выбранная кликом на карте (внутренняя) и заданная извне (по selectedObjectId).
const overlayLines = uniqueLines([selectedLineRef.current, externalSelectedLine]);
const externalHoveredPolygon = useMemo(
() => hoveredRequestPolygonId ? activePolygons.find((p) => p.id === hoveredRequestPolygonId) : undefined,
[hoveredRequestPolygonId, activePolygons]
);
const overlayPolygons = uniquePolygons([externalSelectedPolygon, selectedPolygon, externalHoveredPolygon, hoverRef.current]);
const overlayMarkers = uniqueMarkers([selectedMarker, hoverMarkerRef.current]);
return (
<div
className={`tgu-2d-map ${pickMode || drawMode ? "is-picking" : ""}`}
ref={wrapperRef}
onClick={onClick}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
onPointerLeave={() => {
dragRef.current = undefined;
hoverRef.current = undefined;
hoverMarkerRef.current = undefined;
setTooltip(undefined);
setCursorGeo(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.flatMap((polygon) => {
const raw = polygon.points.map((point) => projection.project(point));
const pts = unwrapX(raw, projection.worldSize);
const cls =
polygon.id === selectedPolygon?.id || polygon.id === externalSelectedPolygon?.id
? "is-selected"
: "is-hovered";
return ([0, projection.worldSize, -projection.worldSize] as const).flatMap((dx) => {
const visible = pts.some((p) => {
const x = p.x + dx;
return x >= -64 && x <= size.width + 64 && p.y >= -64 && p.y <= size.height + 64;
});
if (!visible) return [];
return (
<polygon
key={`${polygon.id}:${dx}`}
points={pts.map((p) => `${p.x + dx},${p.y}`).join(" ")}
className={cls}
/>
);
});
})}
{overlayLines.flatMap((line) => {
if (line.points.length < 2) return [];
const pts = unwrapX(line.points.map((point) => projection.project(point)), projection.worldSize);
return ([0, projection.worldSize, -projection.worldSize] as const).flatMap((dx) => {
const visible = pts.some((p) => {
const x = p.x + dx;
return x >= -64 && x <= size.width + 64 && p.y >= -64 && p.y <= size.height + 64;
});
if (!visible) return [];
return (
<polyline
key={`line:${line.id}:${dx}`}
points={pts.map((p) => `${p.x + dx},${p.y}`).join(" ")}
className="is-selected"
fill="none"
/>
);
});
})}
{overlayMarkers.map((marker) => {
const pt = projection.project(marker.point);
const cls = marker.id === selectedMarker?.id ? "is-selected" : "is-hovered";
return <circle key={marker.id} cx={pt.x} cy={pt.y} r={10} className={cls} />;
})}
{targets.map((t) => {
const screen = projection.project(t);
const isSelected = t.id === selectedTargetId;
return (
<g
className={`tgu-2d-map__target tgu-2d-map__target--confirmed${isSelected ? " is-selected" : ""}`}
key={t.id}
transform={`translate(${screen.x} ${screen.y})`}
onClick={(e) => { e.stopPropagation(); onTargetSelect?.(t.id); }}
style={{ cursor: "pointer" }}
>
<circle r="6" />
<line x1="-10" x2="10" y1="0" y2="0" />
<line x1="0" x2="0" y1="-10" y2="10" />
<text x="10" y="-8">{t.name}</text>
</g>
);
})}
{pickTarget && (() => {
const screen = projection.project(pickTarget);
return (
<g className="tgu-2d-map__target tgu-2d-map__target--pending" key="pick" transform={`translate(${screen.x} ${screen.y})`}>
<circle r="6" />
<line x1="-10" x2="10" y1="0" y2="0" />
<line x1="0" x2="0" y1="-10" y2="10" />
</g>
);
})()}
{draftPolygon.length > 0 && (() => {
const pts = unwrapX(draftPolygon.map((point) => projection.project(point)), projection.worldSize);
const path = pts.map((p) => `${p.x},${p.y}`).join(" ");
return (
<g className="tgu-2d-map__draft">
{pts.length >= 2 && (
pts.length >= 3
? <polygon className="tgu-2d-map__draft-fill" points={path} />
: <polyline className="tgu-2d-map__draft-line" points={path} />
)}
{pts.map((p, i) => (
<circle key={i} cx={p.x} cy={p.y} r={4} className="tgu-2d-map__draft-vertex" />
))}
</g>
);
})()}
</svg>
{tooltip && (
<div className="tgu-2d-map__tooltip" style={{ left: tooltip.x, top: tooltip.y }}>
<div>{tooltip.title}</div>
<span>{tooltip.subtitle}</span>
</div>
)}
{cursorGeo && <div className="tgu-2d-map__coords">{formatLatLon(cursorGeo)}</div>}
<div className="tgu-2d-map__attribution">© <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener noreferrer">OpenStreetMap</a> contributors · © <a href="https://carto.com/attributions" target="_blank" rel="noopener noreferrer">CARTO</a></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 formatLatLon(point: GeoPoint): string {
const lat = point.lat;
const lon = normalizeLon(point.lon);
const latStr = `${Math.abs(lat).toFixed(4)}° ${lat >= 0 ? "с.ш." : "ю.ш."}`;
const lonStr = `${Math.abs(lon).toFixed(4)}° ${lon >= 0 ? "в.д." : "з.д."}`;
return `${latStr}, ${lonStr}`;
}
function layerTitle(layer: MapPolygon["layer"]): string {
switch (layer) {
case "planWorks":
return "Работы плана";
case "swath":
return "Полоса обзора";
case "complexPlan":
return "Комплексный план";
case "tracks":
return "Трасса КА";
case "requests":
return "Заявка на съёмку";
}
}
function unwrapX(points: { x: number; y: number }[], worldSize: number): { x: number; y: number }[] {
if (points.length === 0) return [];
const out: { x: number; y: number }[] = [points[0]];
let prevX = points[0].x;
for (let i = 1; i < points.length; i++) {
let x = points[i].x;
while (x - prevX > worldSize / 2) x -= worldSize;
while (prevX - x > worldSize / 2) x += worldSize;
out.push({ x, y: points[i].y });
prevX = x;
}
return out;
}
function hitTestMarkers(
markers: MapMarker[],
screenPoint: ScreenPoint,
projection: ReturnType<typeof createMapProjection>,
hitRadius = 10
): MapMarker | undefined {
let best: MapMarker | undefined;
let bestDist = hitRadius;
for (const marker of markers) {
const pt = projection.project(marker.point);
const dist = Math.sqrt((pt.x - screenPoint.x) ** 2 + (pt.y - screenPoint.y) ** 2);
if (dist <= bestDist) {
bestDist = dist;
best = marker;
}
}
return best;
}
function uniqueMarkers(markers: Array<MapMarker | undefined>): MapMarker[] {
const result: MapMarker[] = [];
const ids = new Set<string>();
for (const marker of markers) {
if (!marker || ids.has(marker.id)) continue;
ids.add(marker.id);
result.push(marker);
}
return result;
}
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;
}
function uniqueLines(lines: Array<MapLine | undefined>): MapLine[] {
const result: MapLine[] = [];
const ids = new Set<string>();
for (const line of lines) {
if (!line || ids.has(line.id)) continue;
ids.add(line.id);
result.push(line);
}
return result;
}
/** Один объект-кандидат под курсором: несёт исходный объект (для подсветки) и его выделение. */
type EditorHit =
| { kind: "marker"; marker: MapMarker; selection: Tgu2DMapSelection }
| { kind: "polygon"; polygon: MapPolygon; selection: Tgu2DMapSelection }
| { kind: "line"; line: MapLine; selection: Tgu2DMapSelection };
function markerToSelection(marker: MapMarker): Tgu2DMapSelection {
return {
type: "marker",
id: marker.id,
name: marker.label,
layer: marker.layer,
stationNumber: marker.stationNumber,
lat: marker.point.lat,
lon: marker.point.lon,
altitude: marker.altitude,
elevationMin: marker.elevationMin,
elevationMax: marker.elevationMax
};
}
function polygonToSelection(polygon: MapPolygon): Tgu2DMapSelection {
return {
type: "polygon",
id: polygon.id,
name: polygon.name,
layer: polygon.layer,
planId: polygon.planId,
spacecraftId: polygon.spacecraftId,
requestId: polygon.requestId,
surveyType: polygon.surveyType,
status: polygon.status,
modeId: polygon.modeId
};
}
function lineToSelection(line: MapLine): Tgu2DMapSelection {
return {
type: "line",
id: line.id,
name: line.name ?? "Сброс",
layer: line.layer,
kind: line.kind,
modeId: line.modeId
};
}
/** Линии, проходящие в пределах допуска (px) от точки клика, отсортированные по близости. */
function hitTestLines(
lines: MapLine[],
screenPoint: ScreenPoint,
projection: ReturnType<typeof createMapProjection>,
tolerance = 7
): MapLine[] {
const worldSize = projection.worldSize;
const hits: Array<{ line: MapLine; dist: number }> = [];
for (const line of lines) {
if (line.points.length < 2) continue;
const pts = unwrapX(line.points.map((point) => projection.project(point)), worldSize);
let best = Number.POSITIVE_INFINITY;
for (const dx of [0, worldSize, -worldSize]) {
for (let i = 1; i < pts.length; i++) {
const d = distToSegment(
screenPoint,
{ x: pts[i - 1].x + dx, y: pts[i - 1].y },
{ x: pts[i].x + dx, y: pts[i].y }
);
if (d < best) best = d;
}
}
if (best <= tolerance) hits.push({ line, dist: best });
}
return hits.sort((a, b) => a.dist - b.dist).map((h) => h.line);
}
function distToSegment(p: ScreenPoint, a: ScreenPoint, b: ScreenPoint): number {
const dx = b.x - a.x;
const dy = b.y - a.y;
const lenSq = dx * dx + dy * dy;
if (lenSq === 0) return Math.hypot(p.x - a.x, p.y - a.y);
let t = ((p.x - a.x) * dx + (p.y - a.y) * dy) / lenSq;
t = Math.max(0, Math.min(1, t));
return Math.hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy));
}
@@ -0,0 +1,26 @@
import type { MapProjection } from "../geometry/mapProjection";
// Draws the lat/lon graticule (30° grid) over OSM tiles.
export function drawBaseMap(ctx: CanvasRenderingContext2D, projection: MapProjection): void {
ctx.save();
ctx.strokeStyle = "rgba(255, 255, 255, 0.10)";
ctx.lineWidth = 0.5;
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();
}
@@ -0,0 +1,61 @@
import type { MapProjection } from "../geometry/mapProjection";
import type { MapPolygon } from "../model/mapTypes";
type Pt = { x: number; y: number };
/**
* «Разворачивает» X-координаты так, чтобы соседние точки не расходились больше чем на
* полмира иначе полигон, пересекающий антимеридиан (180°), рисуется через всю карту.
* Та же логика, что в drawSwath/drawPlanWorks фикс антимеридиана.
*/
function unwrapX(points: Pt[], worldSize: number): Pt[] {
if (points.length === 0) return [];
const out: Pt[] = [points[0]];
let prevX = points[0].x;
for (let i = 1; i < points.length; i++) {
let x = points[i].x;
while (x - prevX > worldSize / 2) x -= worldSize;
while (prevX - x > worldSize / 2) x += worldSize;
out.push({ x, y: points[i].y });
prevX = x;
}
return out;
}
/**
* Контуры комплексного плана (этап 4). Отдельный слой со своим (янтарным) цветом, чтобы
* визуально не путать с «полосой обзора» (swath) и «работами плана» (planWorks). Рисуется
* в трёх копиях мира с отсечением по видимой области как остальные полигональные слои.
*/
export function drawComplexPlan(
ctx: CanvasRenderingContext2D,
polygons: MapPolygon[],
projection: MapProjection,
viewport: { width: number; height: number }
) {
ctx.save();
ctx.fillStyle = "rgba(240, 170, 70, 0.14)";
ctx.strokeStyle = "rgba(240, 170, 70, 0.62)";
ctx.lineWidth = 1.2;
for (const polygon of polygons) {
if (polygon.points.length < 3) continue;
const raw = polygon.points.map((point) => projection.project(point));
const pts = unwrapX(raw, projection.worldSize);
for (const dx of [0, projection.worldSize, -projection.worldSize]) {
const visible = pts.some((point) => {
const x = point.x + dx;
return x >= -64 && x <= viewport.width + 64 && point.y >= -64 && point.y <= viewport.height + 64;
});
if (!visible) continue;
ctx.beginPath();
for (let i = 0; i < pts.length; i++) {
if (i === 0) ctx.moveTo(pts[i].x + dx, pts[i].y);
else ctx.lineTo(pts[i].x + dx, pts[i].y);
}
ctx.closePath();
ctx.fill();
ctx.stroke();
}
}
ctx.restore();
}
@@ -0,0 +1,70 @@
import { statusStyle } from "../../tgu-planning/tguStatus";
import type { MapProjection } from "../geometry/mapProjection";
import type { MapPolygon } from "../model/mapTypes";
type Pt = { x: number; y: number };
/**
* «Разворачивает» X-координаты так, чтобы соседние точки не расходились больше чем на
* полмира иначе полигон, пересекающий антимеридиан (180°), рисуется через всю карту.
*/
function unwrapX(points: Pt[], worldSize: number): Pt[] {
if (points.length === 0) return [];
const out: Pt[] = [points[0]];
let prevX = points[0].x;
for (let i = 1; i < points.length; i++) {
let x = points[i].x;
while (x - prevX > worldSize / 2) x -= worldSize;
while (prevX - x > worldSize / 2) x += worldSize;
out.push({ x, y: points[i].y });
prevX = x;
}
return out;
}
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 raw = polygon.points.map((point) => projection.project(point));
const pts = unwrapX(raw, projection.worldSize);
const color = statusStyle(polygon.status || "PLANNED").color;
ctx.fillStyle = hexToRgba(color, 0.2);
ctx.strokeStyle = hexToRgba(color, 0.72);
ctx.lineWidth = 1.2;
// Рисуем полигон в трёх копиях мира (текущей и соседних), чтобы он был виден
// независимо от того, какая копия попала в видимую область при зуме/панораме.
for (const dx of [0, projection.worldSize, -projection.worldSize]) {
const visible = pts.some((point) => {
const x = point.x + dx;
return x >= -32 && x <= viewport.width + 32 && point.y >= -32 && point.y <= viewport.height + 32;
});
if (!visible) continue;
ctx.beginPath();
for (let i = 0; i < pts.length; i++) {
if (i === 0) ctx.moveTo(pts[i].x + dx, pts[i].y);
else ctx.lineTo(pts[i].x + dx, pts[i].y);
}
ctx.closePath();
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,69 @@
import type { MapProjection } from "../geometry/mapProjection";
import type { MapPolygon } from "../model/mapTypes";
const SURVEY_COLORS: Record<string, string> = {
OPTICS: "#FFA040",
RSA: "#40AFFF",
COMBINED: "#C080FF"
};
const DEFAULT_COLOR = "#FFA040";
type Pt = { x: number; y: number };
function unwrapX(points: Pt[], worldSize: number): Pt[] {
if (points.length === 0) return [];
const out: Pt[] = [points[0]];
let prevX = points[0].x;
for (let i = 1; i < points.length; i++) {
let x = points[i].x;
while (x - prevX > worldSize / 2) x -= worldSize;
while (prevX - x > worldSize / 2) x += worldSize;
out.push({ x, y: points[i].y });
prevX = x;
}
return out;
}
export function drawRequests(
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 raw = polygon.points.map((p) => projection.project(p));
const pts = unwrapX(raw, projection.worldSize);
const color = SURVEY_COLORS[polygon.surveyType ?? ""] ?? DEFAULT_COLOR;
ctx.fillStyle = hexToRgba(color, 0.18);
ctx.strokeStyle = hexToRgba(color, 0.85);
ctx.lineWidth = 1.5;
ctx.setLineDash([4, 3]);
for (const dx of [0, projection.worldSize, -projection.worldSize]) {
const visible = pts.some((p) => {
const x = p.x + dx;
return x >= -64 && x <= viewport.width + 64 && p.y >= -64 && p.y <= viewport.height + 64;
});
if (!visible) continue;
ctx.beginPath();
for (let i = 0; i < pts.length; i++) {
if (i === 0) ctx.moveTo(pts[i].x + dx, pts[i].y);
else ctx.lineTo(pts[i].x + dx, pts[i].y);
}
ctx.closePath();
ctx.fill();
ctx.stroke();
}
ctx.setLineDash([]);
}
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,58 @@
import type { MapProjection } from "../geometry/mapProjection";
import type { MapPolygon } from "../model/mapTypes";
type Pt = { x: number; y: number };
function unwrapX(points: Pt[], worldSize: number): Pt[] {
if (points.length === 0) return [];
const out: Pt[] = [points[0]];
let prevX = points[0].x;
for (let i = 1; i < points.length; i++) {
let x = points[i].x;
while (x - prevX > worldSize / 2) x -= worldSize;
while (prevX - x > worldSize / 2) x += worldSize;
out.push({ x, y: points[i].y });
prevX = x;
}
return out;
}
function inView(points: Pt[], dx: number, vw: number, vh: number): boolean {
return points.some((p) => {
const x = p.x + dx;
return x >= -64 && x <= vw + 64 && p.y >= -64 && p.y <= vh + 64;
});
}
function tracePath(ctx: CanvasRenderingContext2D, points: Pt[], dx: number) {
ctx.beginPath();
for (let i = 0; i < points.length; i++) {
if (i === 0) ctx.moveTo(points[i].x + dx, points[i].y);
else ctx.lineTo(points[i].x + dx, points[i].y);
}
ctx.closePath();
}
export function drawSwath(
ctx: CanvasRenderingContext2D,
polygons: MapPolygon[],
projection: MapProjection,
viewport: { width: number; height: number }
) {
ctx.save();
ctx.fillStyle = "rgba(104, 195, 189, 0.10)";
ctx.strokeStyle = "rgba(104, 195, 189, 0.40)";
ctx.lineWidth = 1;
for (const polygon of polygons) {
if (polygon.points.length < 3) continue;
const raw = polygon.points.map((p) => projection.project(p));
const pts = unwrapX(raw, projection.worldSize);
for (const dx of [0, projection.worldSize, -projection.worldSize]) {
if (!inView(pts, dx, viewport.width, viewport.height)) continue;
tracePath(ctx, pts, dx);
ctx.fill();
ctx.stroke();
}
}
ctx.restore();
}
@@ -0,0 +1,64 @@
import { latToWorldY, lonToWorldX, worldYToLat } from "../geometry/mapProjection";
import type { MapProjection } from "../geometry/mapProjection";
import type { MapSize, MapViewState } from "../model/mapTypes";
import { getTile } from "./tileLoader";
const TILE_PX = 256;
export function drawTiles(
ctx: CanvasRenderingContext2D,
view: MapViewState,
projection: MapProjection,
size: MapSize,
onLoad: () => void
): void {
// Snap fractional zoom to nearest integer for tile selection.
// Tiles are rendered at the correct scale via screenTileW.
const z = Math.max(1, Math.min(18, Math.round(view.zoom)));
const tileCount = 2 ** z;
const worldSize_z = TILE_PX * tileCount;
// Width of one tile in screen pixels at the current (possibly fractional) zoom
const screenTileW = TILE_PX * (projection.worldSize / worldSize_z);
// Center of the viewport in tile x-coordinates (float)
const centerTileX = lonToWorldX(view.centerLon, worldSize_z) / TILE_PX;
// Center in projection world pixels (used for screen x formula below)
const centerWorldX = lonToWorldX(view.centerLon, projection.worldSize);
// X tile range: continuously-indexed (can be negative or > tileCount).
// wrappedTx = ((tx % tileCount) + tileCount) % tileCount for the actual URL.
const tilesHalf = Math.ceil(size.width / (2 * screenTileW)) + 1;
const txMin = Math.floor(centerTileX) - tilesHalf;
const txMax = Math.ceil(centerTileX) + tilesHalf;
// Y tile range: clamped to [0, tileCount-1] (no world repeat in y)
const topLat = projection.unproject({ x: size.width / 2, y: 0 }).lat;
const bottomLat = projection.unproject({ x: size.width / 2, y: size.height }).lat;
const tyMin = Math.max(0, Math.floor(latToWorldY(topLat, worldSize_z) / TILE_PX) - 1);
const tyMax = Math.min(tileCount - 1, Math.ceil(latToWorldY(bottomLat, worldSize_z) / TILE_PX) + 1);
for (let ty = tyMin; ty <= tyMax; ty++) {
// Mercator-correct tile top/bottom screen positions
const tileTopLat = worldYToLat(ty * TILE_PX, worldSize_z);
const tileBottomLat = worldYToLat((ty + 1) * TILE_PX, worldSize_z);
// lon arg is irrelevant for y — use centerLon to avoid antimeridian edge cases
const screenY = projection.project({ lon: view.centerLon, lat: tileTopLat }).y;
const screenYBottom = projection.project({ lon: view.centerLon, lat: tileBottomLat }).y;
if (screenYBottom < 0 || screenY > size.height) continue;
const screenH = screenYBottom - screenY;
for (let tx = txMin; tx <= txMax; tx++) {
// Screen x is linear in tile x (Mercator is linear in longitude)
const screenX = size.width / 2 + tx * screenTileW - centerWorldX;
if (screenX + screenTileW < 0 || screenX > size.width) continue;
const wrappedTx = ((tx % tileCount) + tileCount) % tileCount;
const img = getTile(z, wrappedTx, ty, onLoad);
if (!img) continue;
ctx.drawImage(img, screenX, screenY, screenTileW, screenH);
}
}
}
@@ -0,0 +1,39 @@
import type { MapProjection } from "../geometry/mapProjection";
import type { MapLine } from "../model/mapTypes";
const TRACK_STYLE = { stroke: "rgba(104, 195, 189, 0.76)", width: 1.2 };
// Сброс — толстая линия поверх трассы, на отрезке от начала до конца сеанса.
// Цвет приглушённо-малиновый: отдельный тёплый оттенок, контрастный к бирюзовым
// виткам трассы (не теряется среди них) и не совпадающий с цветами заявок/маршрутов,
// но без кричащей яркости, чтобы не мешать просмотру контуров съёмки.
const DROP_STYLE = { stroke: "rgba(214, 96, 138, 0.82)", width: 5 };
export function drawTracks(ctx: CanvasRenderingContext2D, lines: MapLine[], projection: MapProjection) {
ctx.save();
ctx.lineCap = "round";
ctx.lineJoin = "round";
const wrapThreshold = projection.worldSize / 2;
// Сбросы рисуем после трасс, чтобы их яркая линия легла поверх.
const ordered = [...lines].sort((a, b) => Number(a.kind === "drop") - Number(b.kind === "drop"));
for (const line of ordered) {
if (line.points.length < 2) continue;
const style = line.kind === "drop" ? DROP_STYLE : TRACK_STYLE;
ctx.strokeStyle = style.stroke;
ctx.lineWidth = style.width;
ctx.beginPath();
let prevX: number | undefined;
for (const point of line.points) {
const screen = projection.project(point);
if (prevX === undefined || Math.abs(screen.x - prevX) > wrapThreshold) {
ctx.moveTo(screen.x, screen.y);
} else {
ctx.lineTo(screen.x, screen.y);
}
prevX = screen.x;
}
ctx.stroke();
}
ctx.restore();
}
@@ -0,0 +1,45 @@
// Module-level tile image cache — shared across all map instances and renders.
// Uses CartoDB dark basemap tiles (CC BY 3.0, no API key required for low traffic).
const SUBDOMAINS = "abcd";
const MAX_CACHE_SIZE = 512;
type CacheEntry = HTMLImageElement | null; // null = loading
const imageCache = new Map<string, CacheEntry>();
function tileUrl(z: number, x: number, y: number): string {
const s = SUBDOMAINS[(x + y) % SUBDOMAINS.length];
return `https://${s}.basemaps.cartocdn.com/dark_nolabels/${z}/${x}/${y}.png`;
}
export function getTile(
z: number,
x: number,
y: number,
onLoad: () => void
): HTMLImageElement | undefined {
const key = `${z}/${x}/${y}`;
const cached = imageCache.get(key);
if (cached instanceof HTMLImageElement) return cached;
if (cached === null) return undefined; // already loading
if (imageCache.size >= MAX_CACHE_SIZE) {
const firstKey = imageCache.keys().next().value;
if (firstKey !== undefined) imageCache.delete(firstKey);
}
imageCache.set(key, null);
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => {
imageCache.set(key, img);
onLoad();
};
img.onerror = () => {
imageCache.delete(key); // allow retry on next render
};
img.src = tileUrl(z, x, y);
return undefined;
}
@@ -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,34 @@
import { describe, expect, it } from "vitest";
import { groundTrack, nearestStation, stationPasses, subPoint, targetPasses } from "./passes";
const orbit = { spacecraftId: "56756" };
const centerMs = Date.UTC(2026, 4, 29, 12, 0, 0);
describe("passes", () => {
it("finds target and station visibility around the sub-satellite point", () => {
const point = subPoint(orbit, centerMs);
const range = { fromMs: centerMs - 15 * 60 * 1000, toMs: centerMs + 15 * 60 * 1000 };
expect(targetPasses(orbit, point, range).length).toBeGreaterThan(0);
expect(stationPasses(orbit, point, range).length).toBeGreaterThan(0);
});
it("selects the nearest station by great-circle distance", () => {
const point = { lat: 55.75, lon: 37.62 };
expect(nearestStation(point, [
{ id: "far", lat: -30, lon: -120 },
{ id: "near", lat: 55.8, lon: 37.7 }
])?.id).toBe("near");
});
it("builds ground track segments in the requested range", () => {
const segments = groundTrack(orbit, {
fromMs: centerMs,
toMs: centerMs + 90 * 60 * 1000
});
expect(segments.length).toBeGreaterThan(0);
expect(segments.every((segment) => segment.length > 1)).toBe(true);
});
});
@@ -0,0 +1,225 @@
import { normalizeLon } from "./mapProjection";
const DEG = Math.PI / 180;
const RAD = 180 / Math.PI;
const EARTH_RADIUS_KM = 6371;
const EARTH_MU_KM3_S2 = 398600.4418;
const SIDEREAL_DAY_SECONDS = 86164;
const DEFAULT_ALTITUDE_KM = 550;
const DEFAULT_TARGET_THRESHOLD_DEG = 6.5;
const TARGET_SCAN_STEP_MS = 20 * 1000;
const STATION_SCAN_STEP_MS = 30 * 1000;
const MIN_WINDOW_MS = 40 * 1000;
export type GroundPoint = {
lat: number;
lon: number;
};
export type GroundStationPoint = GroundPoint & {
id: string;
};
export type PassOrbit = {
spacecraftId?: string;
altitudeKm?: number;
};
export type PassRange = {
fromMs: number;
toMs: number;
};
export type PassWindow = {
startMs: number;
endMs: number;
quality: number;
};
export function subPoint(orbit: PassOrbit, timeMs: number): GroundPoint {
const params = orbitParams(orbit);
const dtSeconds = timeMs / 1000;
const argument = params.phaseRad + 2 * Math.PI * (dtSeconds / params.periodSeconds);
const lat = Math.asin(Math.sin(params.inclinationRad) * Math.sin(argument)) * RAD;
const relativeLon = Math.atan2(Math.cos(params.inclinationRad) * Math.sin(argument), Math.cos(argument));
const lon = normalizeLon((params.nodeRad + relativeLon) * RAD - (360 / SIDEREAL_DAY_SECONDS) * dtSeconds);
return { lat, lon };
}
export function targetPasses(orbit: PassOrbit, target: GroundPoint, range: PassRange): PassWindow[] {
return scanWindows(orbit, target, range, DEFAULT_TARGET_THRESHOLD_DEG, TARGET_SCAN_STEP_MS);
}
export function stationPasses(orbit: PassOrbit, station: GroundPoint, range: PassRange, minElevDeg = 5): PassWindow[] {
const thresholdDeg = accessAngleDeg(orbit.altitudeKm ?? DEFAULT_ALTITUDE_KM, minElevDeg);
return scanWindows(orbit, station, range, thresholdDeg, STATION_SCAN_STEP_MS);
}
export function nearestStation(point: GroundPoint, stations: GroundStationPoint[]): GroundStationPoint | undefined {
let bestStation: GroundStationPoint | undefined;
let bestDistance = Number.POSITIVE_INFINITY;
for (const station of stations) {
const distance = greatCircleDistanceDeg(point, station);
if (distance < bestDistance) {
bestDistance = distance;
bestStation = station;
}
}
return bestStation;
}
export type OrbitPassInfo = {
index: number;
startMs: number;
endMs: number;
revolution?: number;
};
export function orbitPeriodMs(orbit: PassOrbit): number {
return orbitParams(orbit).periodSeconds * 1000;
}
export function orbitPassList(orbit: PassOrbit, range: PassRange): OrbitPassInfo[] {
if (range.toMs <= range.fromMs) return [];
const periodMs = orbitPeriodMs(orbit);
const passes: OrbitPassInfo[] = [];
let t = range.fromMs;
let index = 1;
while (t < range.toMs) {
passes.push({ index, startMs: t, endMs: Math.min(t + periodMs, range.toMs) });
t += periodMs;
index++;
}
return passes;
}
export function groundTrack(orbit: PassOrbit, range: PassRange, maxPoints = 240): GroundPoint[][] {
if (range.toMs <= range.fromMs) {
return [];
}
const spanMs = range.toMs - range.fromMs;
const stepMs = Math.max(60 * 1000, Math.ceil(spanMs / Math.max(2, maxPoints)));
const segments: GroundPoint[][] = [];
let current: GroundPoint[] = [];
let previousLon: number | undefined;
for (let timeMs = range.fromMs; timeMs <= range.toMs; timeMs += stepMs) {
const point = subPoint(orbit, timeMs);
if (previousLon !== undefined && Math.abs(point.lon - previousLon) > 180) {
if (current.length > 1) {
segments.push(current);
}
current = [];
}
current.push(point);
previousLon = point.lon;
}
const endPoint = subPoint(orbit, range.toMs);
if (current.length === 0 || current[current.length - 1].lat !== endPoint.lat || current[current.length - 1].lon !== endPoint.lon) {
current.push(endPoint);
}
if (current.length > 1) {
segments.push(current);
}
return segments;
}
export function greatCircleDistanceDeg(first: GroundPoint, second: GroundPoint): number {
const firstLat = first.lat * DEG;
const secondLat = second.lat * DEG;
const deltaLon = (second.lon - first.lon) * DEG;
const cosine =
Math.sin(firstLat) * Math.sin(secondLat) +
Math.cos(firstLat) * Math.cos(secondLat) * Math.cos(deltaLon);
return Math.acos(Math.max(-1, Math.min(1, cosine))) * RAD;
}
export function accessAngleDeg(altitudeKm: number, minElevDeg: number): number {
const minElevRad = minElevDeg * DEG;
const ratio = EARTH_RADIUS_KM / (EARTH_RADIUS_KM + altitudeKm);
return (Math.acos(ratio * Math.cos(minElevRad)) - minElevRad) * RAD;
}
function scanWindows(
orbit: PassOrbit,
target: GroundPoint,
range: PassRange,
thresholdDeg: number,
stepMs: number
): PassWindow[] {
if (range.toMs <= range.fromMs) {
return [];
}
const windows: PassWindow[] = [];
let inside = false;
let startMs = range.fromMs;
let bestMargin = 0;
for (let timeMs = range.fromMs; timeMs <= range.toMs; timeMs += stepMs) {
const distance = greatCircleDistanceDeg(subPoint(orbit, timeMs), target);
const visible = distance <= thresholdDeg;
if (visible && !inside) {
inside = true;
startMs = timeMs;
bestMargin = thresholdDeg - distance;
} else if (visible) {
bestMargin = Math.max(bestMargin, thresholdDeg - distance);
} else if (inside) {
windows.push(toWindow(startMs, timeMs, bestMargin, thresholdDeg));
inside = false;
}
}
if (inside) {
windows.push(toWindow(startMs, range.toMs, bestMargin, thresholdDeg));
}
return windows.filter((window) => window.endMs - window.startMs >= MIN_WINDOW_MS);
}
function toWindow(startMs: number, endMs: number, bestMargin: number, thresholdDeg: number): PassWindow {
return {
startMs,
endMs,
quality: Math.max(0, Math.min(1, bestMargin / thresholdDeg))
};
}
function orbitParams(orbit: PassOrbit): {
altitudeKm: number;
periodSeconds: number;
inclinationRad: number;
nodeRad: number;
phaseRad: number;
} {
const altitudeKm = orbit.altitudeKm ?? DEFAULT_ALTITUDE_KM;
const semiMajorAxis = EARTH_RADIUS_KM + altitudeKm;
const periodSeconds = 2 * Math.PI * Math.sqrt((semiMajorAxis * semiMajorAxis * semiMajorAxis) / EARTH_MU_KM3_S2);
const key = orbit.spacecraftId ?? "default-spacecraft";
return {
altitudeKm,
periodSeconds,
inclinationRad: (97.4 + hash01(key, 7) * 1.6) * DEG,
nodeRad: hash01(key, 3) * 360 * DEG,
phaseRad: hash01(key, 11) * 2 * Math.PI
};
}
function hash01(value: string, salt: number): number {
let hash = 2166136261 ^ salt;
for (let index = 0; index < value.length; index += 1) {
hash ^= value.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
return ((hash >>> 0) % 100000) / 100000;
}
@@ -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,72 @@
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"]);
});
it("matches polygons whose longitudes are in a different 360° window (western hemisphere)", () => {
// Контур в долготах 0..360: западное полушарие ~234° вместо −126°.
const western: MapPolygon = {
id: "western",
name: "Western survey route",
kind: "planWork",
layer: "planWorks",
zIndex: 1,
points: [
{ lon: 233, lat: 48 },
{ lon: 235, lat: 48 },
{ lon: 235, lat: 50 },
{ lon: 233, lat: 50 }
]
};
const index = createPolygonSpatialIndex([western]);
// Видовой bbox в нормализованных долготах [−128, −124] — те же координаты, что и контур, со сдвигом −360°.
expect(
queryPolygonsByBBox(index, { minLon: -128, minLat: 47, maxLon: -124, maxLat: 51 }).map((polygon) => polygon.id)
).toEqual(["western"]);
});
});
@@ -0,0 +1,98 @@
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 hitTestAllPolygons(index: PolygonSpatialIndex, point: GeoPoint): MapPolygon[] {
const hits: MapPolygon[] = [];
for (const entry of index.entries) {
const inBbox =
bboxContainsPoint(entry.bbox, point) ||
bboxContainsPoint(entry.bbox, { lon: point.lon + 360, lat: point.lat }) ||
bboxContainsPoint(entry.bbox, { lon: point.lon - 360, lat: point.lat });
if (!inBbox) continue;
const normalized = unwrapLon(entry.polygon.points);
const hit =
pointInPolygon(point, normalized) ||
pointInPolygon({ lon: point.lon + 360, lat: point.lat }, normalized) ||
pointInPolygon({ lon: point.lon - 360, lat: point.lat }, normalized);
if (hit) hits.push(entry.polygon);
}
return hits.sort((a, b) => b.zIndex - a.zIndex);
}
export function hitTestPolygons(index: PolygonSpatialIndex, point: GeoPoint): MapPolygon | undefined {
let selected: MapPolygon | undefined;
for (const entry of index.entries) {
// Check bbox allowing ±360° shifts so antimeridian-crossing polygons are not skipped.
const inBbox =
bboxContainsPoint(entry.bbox, point) ||
bboxContainsPoint(entry.bbox, { lon: point.lon + 360, lat: point.lat }) ||
bboxContainsPoint(entry.bbox, { lon: point.lon - 360, lat: point.lat });
if (!inBbox) continue;
// Normalize polygon lons to be continuous (same idea as unwrapX for screen coords).
const normalized = unwrapLon(entry.polygon.points);
const hit =
pointInPolygon(point, normalized) ||
pointInPolygon({ lon: point.lon + 360, lat: point.lat }, normalized) ||
pointInPolygon({ lon: point.lon - 360, lat: point.lat }, normalized);
if (hit && (!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.minLat <= bbox.maxLat && entry.bbox.maxLat >= bbox.minLat)
// Долготу сверяем с учётом ±360°: контуры из бэкенда могут приходить в долготах 0..360
// (западное полушарие как ~234° вместо −126°). Без этого они выпадают из набора отрисовки,
// хотя остаются кликабельными (hit-test уже учитывает сдвиги), — контур «пропадает» на карте.
.filter((entry) =>
[0, 360, -360].some(
(shift) => entry.bbox.minLon + shift <= bbox.maxLon && entry.bbox.maxLon + shift >= bbox.minLon
)
)
.map((entry) => entry.polygon);
}
function unwrapLon(points: GeoPoint[]): GeoPoint[] {
if (points.length === 0) return points;
const out: GeoPoint[] = [points[0]];
let prevLon = points[0].lon;
for (let i = 1; i < points.length; i++) {
let lon = points[i].lon;
while (lon - prevLon > 180) lon -= 360;
while (prevLon - lon > 180) lon += 360;
out.push({ ...points[i], lon });
prevLon = lon;
}
return out;
}
@@ -0,0 +1,54 @@
import type { GeoPoint } from "../model/mapTypes";
export function parseWktPolygon(wkt: string): GeoPoint[][] {
const upper = wkt.trim().toUpperCase();
if (upper.startsWith("MULTIPOLYGON")) {
return parseMultiPolygon(wkt);
}
if (upper.startsWith("POLYGON")) {
const ring = parseSinglePolygon(wkt);
return ring ? [ring] : [];
}
return [];
}
function parseSinglePolygon(wkt: string): GeoPoint[] | null {
const match = wkt.match(/POLYGON\s*\(\s*\(([^)]+)\)/i);
if (!match) return null;
return parseRing(match[1]);
}
function parseMultiPolygon(wkt: string): GeoPoint[][] {
const rings: GeoPoint[][] = [];
const inner = wkt.replace(/MULTIPOLYGON\s*\(/i, "").replace(/\)\s*$/, "");
const polygonStrings = inner.match(/\(\s*\([^)]+\)\s*\)/g);
if (!polygonStrings) return rings;
for (const polygonStr of polygonStrings) {
const ringMatch = polygonStr.match(/\(\s*\(([^)]+)\)/);
if (ringMatch) {
const ring = parseRing(ringMatch[1]);
if (ring.length >= 3) {
rings.push(ring);
}
}
}
return rings;
}
function parseRing(coordStr: string): GeoPoint[] {
return coordStr
.split(",")
.map((pair) => {
const parts = pair.trim().split(/\s+/);
const lon = parseFloat(parts[0]);
const lat = parseFloat(parts[1]);
if (!Number.isFinite(lon) || !Number.isFinite(lat)) return null;
return { lon, lat };
})
.filter((point): point is GeoPoint => point !== null);
}
@@ -0,0 +1,45 @@
export type Tgu2DMapLayerKey =
| "tracks"
| "swath"
| "planWorks"
| "complexPlan"
| "stations"
| "spacecraftMarkers"
| "requests";
export type Tgu2DMapLayersState = Record<Tgu2DMapLayerKey, boolean>;
export type Tgu2DMapLayerDefinition = {
key: Tgu2DMapLayerKey;
label: string;
description: string;
};
export const TGU_2D_MAP_LAYERS: Tgu2DMapLayerDefinition[] = [
{
key: "planWorks",
label: "Работы плана",
description: "Маршруты съёмки из выбранного плана."
},
{
key: "stations",
label: "Станции",
description: "Наземные станции приёма."
},
{
key: "requests",
label: "Заявки на съёмку",
description: "Полигоны заявок из pcp-request-service."
}
];
export const DEFAULT_TGU_2D_MAP_LAYERS: Tgu2DMapLayersState = {
tracks: false,
swath: false,
planWorks: true,
complexPlan: false,
stations: true,
spacecraftMarkers: false,
requests: true
};
@@ -0,0 +1,40 @@
import type { TguPlanStatus } from "../../../model/tguTypes";
export type Tgu2DMapSelection =
| {
type: "polygon";
id: string;
name: string;
layer: "planWorks" | "swath" | "tracks" | "requests" | "complexPlan";
planId?: string;
spacecraftId?: string;
requestId?: string;
surveyType?: string;
status?: TguPlanStatus;
/** id режима плана (включения) для polygon-ов слоя planWorks — связь с работой таймлайна. */
modeId?: number;
}
| {
type: "line";
id: string;
name: string;
layer: "tracks";
/** Вид линии: трасса КА или сеанс сброса. */
kind?: "track" | "drop";
/** id режима плана (drop) — связь с работой таймлайна. */
modeId?: number;
}
| {
type: "marker";
id: string;
name: string;
layer: "stations" | "spacecraftMarkers";
spacecraftId?: string;
stationNumber?: number;
lat?: number;
lon?: number;
altitude?: number;
elevationMin?: number;
elevationMax?: number;
};
@@ -0,0 +1,72 @@
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" | "request" | "complexPlan";
layer: "planWorks" | "swath" | "tracks" | "requests" | "complexPlan";
points: GeoPoint[];
status?: TguPlanStatus;
planId?: string;
spacecraftId?: string;
requestId?: string;
surveyType?: string;
/** id режима плана (survey), к которому относится контур — для связи «включение ↔ объект на карте». */
modeId?: number;
zIndex: number;
};
export type MapLine = {
id: string;
/** Человекочитаемое имя (для подсказок и выбора объекта под курсором), напр. «Сброс · виток N». */
name?: string;
layer: "tracks";
points: GeoPoint[];
spacecraftId?: string;
passIndex?: number;
zIndex: number;
/** Вид линии: трасса КА (по умолчанию) или сеанс сброса — яркая толстая линия на трассе. */
kind?: "track" | "drop";
/** id режима плана (survey/drop), к которому относится объект — для связи «включение ↔ объект на карте». */
modeId?: number;
};
export type MapMarker = {
id: string;
layer: "stations" | "spacecraftMarkers";
point: GeoPoint;
label: string;
zIndex: number;
stationNumber?: number;
altitude?: number;
elevationMin?: number;
elevationMax?: number;
};
export type Tgu2DMapScene = {
polygons: MapPolygon[];
lines: MapLine[];
markers: MapMarker[];
};
@@ -0,0 +1,61 @@
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";
import { parseLocal } from "../../utils/localTime";
function plan(startTime: string, endTime: string): TguPlanUi {
return {
planId: "plan-1",
spacecraftId: "56756",
startTime,
endTime,
kppId: "KPP-1",
status: "PLANNED",
startMs: parseLocal(startTime),
endMs: parseLocal(endTime)
};
}
describe("tgu2DMapInterval", () => {
it("marks common map interval longer than 7 days as too large", () => {
const state = getTgu2DMapIntervalState({
fromMs: parseLocal("2026-05-29T00:00:00"),
toMs: parseLocal("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: parseLocal("2026-05-29T00:00:00"),
toMs: parseLocal("2026-06-10T00:00:00")
},
selectedPlan
);
expect(state).toEqual({
range: {
fromMs: selectedPlan.startMs,
toMs: selectedPlan.endMs
},
tooLarge: false
});
});
it("enables plan, stations and requests layers by default", () => {
expect(DEFAULT_TGU_2D_MAP_LAYERS).toEqual({
tracks: false,
swath: false,
planWorks: true,
complexPlan: false,
stations: true,
spacecraftMarkers: false,
requests: 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
};
}
@@ -0,0 +1,208 @@
import { useEffect, useMemo, useState } from "react";
import {
createChainPlan,
fetchZrvWindows,
type CreateChainPlanResponse
} from "../../api/tguPlanApi";
import type { TguPlanUi } from "../../model/timelineTypes";
import { TguApiError } from "../../model/tguTypes";
import { formatLocalDateTime } from "../../utils/localTime";
import { buildChainPlanRequest, futureZrvOptions, type ZrvOption } from "./createChainWindows";
type CreateChainPlanDialogProps = {
spacecraftId: string;
/** Планы для выбора базы-предыстории (берём persisted этого КА, кроме черновиков/проекции). */
plans: TguPlanUi[];
/** Выбранный (в фокусе) план — предзаполняет базу-предысторию, если он годится в базу. */
initialBasePlanId?: string;
onClose: () => void;
/** Вызвать после создания черновика — чтобы он сразу появился на таймлайне (режим «Черновики»). */
onCreated?: () => void;
/** Открыть созданный черновик в редакторе (переход на страницу редактирования). */
onEdit: (draftPlanId: string) => void;
};
/**
* Диалог «Создать план» (вкладка «Планы»): оператор берёт за основу прошлый план (или начинает
* цепочку без истории), набирает окно из двух реальных ЗРВ и создаёт черновик плана. После
* создания сразу предлагается перейти к его редактированию.
*/
export function CreateChainPlanDialog({
spacecraftId,
plans,
initialBasePlanId,
onClose,
onCreated,
onEdit
}: CreateChainPlanDialogProps) {
const [zrvOptions, setZrvOptions] = useState<ZrvOption[]>([]);
const [zrvLoading, setZrvLoading] = useState(true);
const [zrvError, setZrvError] = useState<string>();
// "" — без истории; при открытии берём план в фокусе, если он годится в базу (этот КА, не
// проекция/черновик), иначе остаёмся без истории.
const [basePlanId, setBasePlanId] = useState<string>(() =>
initialBasePlanId &&
plans.some(
(plan) =>
plan.planId === initialBasePlanId &&
plan.spacecraftId === spacecraftId &&
plan.status !== "PLANNED" &&
plan.status !== "DRAFT"
)
? initialBasePlanId
: ""
);
const [startId, setStartId] = useState<string>("");
const [endId, setEndId] = useState<string>("");
const [creating, setCreating] = useState(false);
const [error, setError] = useState<string>();
const [result, setResult] = useState<CreateChainPlanResponse>();
// Базы-предыстории: persisted планы этого КА (без проекции PLANNED и без черновиков).
const baseOptions = useMemo(
() =>
plans
.filter((plan) => plan.spacecraftId === spacecraftId && plan.status !== "PLANNED" && plan.status !== "DRAFT")
.sort((a, b) => b.startMs - a.startMs),
[plans, spacecraftId]
);
useEffect(() => {
let cancelled = false;
setZrvLoading(true);
setZrvError(undefined);
void fetchZrvWindows(spacecraftId)
.then((windows) => {
if (!cancelled) setZrvOptions(futureZrvOptions(windows, Date.now()));
})
.catch((err) => {
if (!cancelled) setZrvError(err instanceof TguApiError ? err.message : "Не удалось получить ЗРВ КА.");
})
.finally(() => {
if (!cancelled) setZrvLoading(false);
});
return () => {
cancelled = true;
};
}, [spacecraftId]);
const start = zrvOptions.find((option) => option.id === startId);
const end = zrvOptions.find((option) => option.id === endId);
const canCreate = Boolean(start && end) && !creating;
const create = async () => {
if (!start || !end) return;
setCreating(true);
setError(undefined);
try {
const request = buildChainPlanRequest(spacecraftId, basePlanId || null, start, end);
setResult(await createChainPlan(request));
onCreated?.();
} catch (err) {
setError(
err instanceof TguApiError ? err.message : err instanceof Error ? err.message : "Не удалось создать план."
);
} finally {
setCreating(false);
}
};
return (
<div className="tgu-chain-dialog__backdrop" role="dialog" aria-modal="true" aria-label="Создать план">
<div className="tgu-chain-dialog">
<div className="tgu-chain-dialog__header">
<h2>Создать план</h2>
<button className="tgu-icon-button" type="button" onClick={onClose} aria-label="Закрыть">
x
</button>
</div>
{!result ? (
<>
<label className="tgu-chain-dialog__field">
<span>Базовое состояние (предыстория)</span>
<select className="tgu-select" value={basePlanId} onChange={(e) => setBasePlanId(e.target.value)}>
<option value="">Без истории (новая цепочка с нуля)</option>
{baseOptions.map((plan) => (
<option key={plan.planId} value={plan.planId}>
{formatLocalDateTime(plan.startMs)} · {plan.kppId} · {plan.status}
</option>
))}
</select>
</label>
{zrvError && <div className="tgu-alert tgu-alert--error">{zrvError}</div>}
{zrvLoading ? (
<div className="tgu-chain-dialog__hint">Загрузка ЗРВ</div>
) : zrvOptions.length === 0 ? (
<div className="tgu-chain-dialog__hint">Нет будущих ЗРВ для этого КА.</div>
) : (
<>
<label className="tgu-chain-dialog__field">
<span>Стартовая ЗРВ (начало окна)</span>
<select className="tgu-select" value={startId} onChange={(e) => setStartId(e.target.value)}>
<option value=""> выберите </option>
{zrvOptions.map((option) => (
<option key={option.id} value={option.id}>
{formatLocalDateTime(option.startMs)} · {option.kppId}
</option>
))}
</select>
</label>
<label className="tgu-chain-dialog__field">
<span>Конечная ЗРВ (конец окна)</span>
<select className="tgu-select" value={endId} onChange={(e) => setEndId(e.target.value)}>
<option value=""> выберите </option>
{zrvOptions
.filter((option) => !start || option.endMs > start.startMs)
.map((option) => (
<option key={option.id} value={option.id}>
{formatLocalDateTime(option.endMs)} · {option.kppId}
</option>
))}
</select>
</label>
</>
)}
{error && <div className="tgu-alert tgu-alert--error">{error}</div>}
<div className="tgu-chain-dialog__actions">
<button className="tgu-button" type="button" onClick={onClose}>
Отмена
</button>
<button className="tgu-button tgu-button--primary" type="button" onClick={create} disabled={!canCreate}>
{creating ? "Создание…" : "Создать"}
</button>
</div>
</>
) : (
<>
<div className="tgu-alert tgu-alert--success">
Черновик плана создан ({result.draftPlanId}).
</div>
<div className="tgu-chain-dialog__actions">
<button className="tgu-button" type="button" onClick={onClose}>
Закрыть
</button>
<button
className="tgu-button tgu-button--primary"
type="button"
onClick={() => onEdit(result.draftPlanId)}
>
Редактировать
</button>
</div>
<div className="tgu-chain-dialog__hint">
Откроется страница редактирования черновика.
</div>
</>
)}
</div>
</div>
);
}
@@ -0,0 +1,14 @@
import { STATUS_STYLES } from "./tguStatus";
export function TguLegend() {
return (
<div className="tgu-legend" aria-label="Легенда статусов">
{STATUS_STYLES.map((item) => (
<span className="tgu-legend__item" key={item.label} title={item.description}>
<span className={`tgu-legend__swatch ${item.className}`} />
{item.label}
</span>
))}
</div>
);
}
@@ -0,0 +1,315 @@
import type { TguPlanDecision } from "../../model/tguTypes";
import type { TguPlanUi } from "../../model/timelineTypes";
import { platformLabel } from "./tguTimelineMapper";
import { statusStyle } from "./tguStatus";
import { formatLocalDateTime, parseLocal } from "../../utils/localTime";
// Бэкенд (pcp-tgu-service) ждёт решения по плану только waiting-decision-grace-minutes
// минут после его startTime; затем IssueDuePlanWorker.tryAdvancePastUnconfirmedPlan
// «молчаливо» продолжает цепочку, оставляя план в WAITING_DECISION, но решение по нему
// больше не принимается (endpoint вернёт 409). Держим значение синхронным с
// config-repo/pcp-tgu-service.yaml → tgu.planning.waiting-decision-grace-minutes.
const DECISION_GRACE_MINUTES = 30;
/** Терминально-негативные статусы, которые можно «снять с учёта» (синхронно с бэком DismissPlanUseCase). */
const DISMISSIBLE_STATUSES = new Set(["REJECTED", "START_AMBIGUOUS", "LAYIN_FAILED", "NOT_ACCEPTED", "SUPERSEDED"]);
/** Состав включений плана: сколько съёмок и сбросов. undefined — ещё грузится / неизвестно. */
export type PlanModeCounts = {
survey: number;
drop: number;
};
type TguPlanDetailsProps = {
plan?: TguPlanUi;
/** Состав включений плана для блока «Включения» (съёмки/сбросы). */
modeCounts?: PlanModeCounts;
/** Цепочка КА на паузе после REJECTED — показываем подсказку про ручной исправленный план. */
chainPaused?: boolean;
decisionInFlight: boolean;
notice?: string;
error?: string;
onDecision: (planId: string, decision: TguPlanDecision, reason?: string) => void;
/** Действия над черновиком (показываются только для status === "DRAFT"). */
draftActionInFlight?: boolean;
onEditDraft?: (planId: string) => void;
onPromoteDraft?: (planId: string) => void;
onDeleteDraft?: (planId: string) => void;
/** Создать черновик-копию на основе этого плана (replace, окно 1:1, все включения). */
onCreateDraftFromPlan?: (planId: string) => void;
/** ТЕСТ: подтвердить закладку плана (AWAITING_LAYIN → LAID_IN). */
onConfirmLayin?: (planId: string) => void;
/** Нельзя подтвердить закладку: по КА уже заложен план со стартом в пределах ±10 минут. */
layinConflict?: boolean;
/** «Снять с учёта» терминально-негативный план (чтобы не красил индикатор КА). */
onDismissPlan?: (planId: string) => void;
/** «Вернуть на учёт» ранее снятый план. */
onRestorePlan?: (planId: string) => void;
onClose: () => void;
};
export function TguPlanDetails({
plan,
modeCounts,
chainPaused,
decisionInFlight,
notice,
error,
onDecision,
draftActionInFlight,
onEditDraft,
onPromoteDraft,
onDeleteDraft,
onCreateDraftFromPlan,
onConfirmLayin,
layinConflict,
onDismissPlan,
onRestorePlan,
onClose
}: TguPlanDetailsProps) {
if (!plan) {
return (
<aside className="tgu-details tgu-details--empty">
<div className="tgu-details__placeholder">
<span className="tgu-details__placeholder-icon" aria-hidden="true" />
<span>Выберите plan bar на timeline.</span>
</div>
</aside>
);
}
const style = statusStyle(plan.status);
const duration = formatDuration(plan.endMs - plan.startMs);
// Окно решения закрылось, если с момента старта плана прошло больше grace-периода.
const decisionWindowClosed = Date.now() > plan.startMs + DECISION_GRACE_MINUTES * 60_000;
// Закладку нельзя подтверждать, пока начало плана ещё в будущем: план закладывается на КА
// по факту наступления своего старта, а не заранее.
const layinStartInFuture = plan.startMs > Date.now();
// «Создать черновик на основе плана» (replace, окно 1:1, все включения) доступно для
// персистентных правимых баз. DRAFT уже имеет свои действия; PLANNED — виртуальная проекция,
// не база; LAID_IN — replace 1:1 запрещён (слот зафиксирован, только fork-forward).
const canCreateDraftFromPlan =
plan.status !== "DRAFT" && plan.status !== "PLANNED" && plan.status !== "LAID_IN";
const reject = () => {
const reason = window.prompt("Reason для REJECTED", "UI_TEST_REJECTED");
if (reason === null) return;
onDecision(plan.planId, "REJECTED", reason);
};
const promoteDraft = () => {
if (!window.confirm("Отправить черновик на выдачу? Он уйдёт в цепочку и станет рабочим после приёмки.")) return;
onPromoteDraft?.(plan.planId);
};
const deleteDraft = () => {
if (!window.confirm("Удалить черновик? Его нельзя будет восстановить.")) return;
onDeleteDraft?.(plan.planId);
};
const confirmLayin = () => {
if (!window.confirm("Подтвердить закладку плана? Он станет базой цепочки (LAID_IN). Вы уверены?")) return;
onConfirmLayin?.(plan.planId);
};
const dismissed = Boolean(plan.dismissedAt);
const canDismiss = DISMISSIBLE_STATUSES.has(plan.status);
const dismiss = () => {
if (!window.confirm("Снять план с учёта? Он перестанет считаться проблемой и не будет красить КА. Статус и цепочку это не меняет; можно вернуть на учёт.")) return;
onDismissPlan?.(plan.planId);
};
return (
<aside className="tgu-details">
<div className="tgu-details__header">
<div>
<div className="tgu-details__eyebrow">Выбранный план</div>
<h2>{plan.planId}</h2>
</div>
<button className="tgu-icon-button" type="button" onClick={onClose} aria-label="Закрыть детали">
x
</button>
</div>
<div className="tgu-details__pills">
<div className={`tgu-status-pill ${style.className}`}>{style.label}</div>
{dismissed && <div className="tgu-status-pill tgu-status-pill--dismissed">снят с учёта</div>}
</div>
{notice && <div className="tgu-alert tgu-alert--success">{notice}</div>}
{error && <div className="tgu-alert tgu-alert--danger">{error}</div>}
{chainPaused && (
<div className="tgu-alert tgu-alert--warning">
Цепочка на паузе: план отклонён создайте исправленный план вручную («Создать план»).
Автоматической перевыдачи отклонённого окна не будет.
</div>
)}
<dl className="tgu-details-grid">
<Detail label="planId" value={plan.planId} mono />
<Detail label="spacecraftId" value={plan.spacecraftId} mono />
<Detail label="КА" value={platformLabel(plan.platform, plan.spacecraftId)} />
<Detail label="startTime" value={formatDateTime(plan.startTime)} mono />
<Detail label="endTime" value={formatDateTime(plan.endTime)} mono />
<Detail label="duration" value={duration} mono />
<Detail label="kppId" value={plan.kppId} mono />
<Detail label="status" value={plan.status} mono />
{plan.basePlanId && <Detail label="basePlanId" value={plan.basePlanId} mono />}
</dl>
<div className="tgu-details__eyebrow">Включения</div>
<dl className="tgu-details-grid">
<Detail label="съёмок" value={modeCounts ? String(modeCounts.survey) : "…"} mono />
<Detail label="сбросов" value={modeCounts ? String(modeCounts.drop) : "…"} mono />
</dl>
{plan.status === "DRAFT" && (
<div className="tgu-details__actions tgu-details__actions--draft">
<button
className="tgu-button tgu-button--primary"
type="button"
disabled={draftActionInFlight}
onClick={() => onEditDraft?.(plan.planId)}
>
Редактировать
</button>
<button
className="tgu-button tgu-button--accept"
type="button"
disabled={draftActionInFlight}
onClick={promoteDraft}
>
Отправить
</button>
<button
className="tgu-button tgu-button--reject"
type="button"
disabled={draftActionInFlight}
onClick={deleteDraft}
>
Удалить
</button>
</div>
)}
{plan.status === "WAITING_DECISION" &&
(decisionWindowClosed ? (
<div className="tgu-alert tgu-alert--warning">
Окно решения истекло: прошло больше {DECISION_GRACE_MINUTES} мин после начала плана. Система продолжила
цепочку без подтверждения решение по этому плану больше не принимается.
</div>
) : (
<div className="tgu-details__actions">
<button
className="tgu-button tgu-button--accept"
type="button"
disabled={decisionInFlight}
onClick={() => onDecision(plan.planId, "ACCEPTED", "UI_TEST_ACCEPTED")}
>
Принять
</button>
<button className="tgu-button tgu-button--reject" type="button" disabled={decisionInFlight} onClick={reject}>
Отклонить
</button>
</div>
))}
{canCreateDraftFromPlan && onCreateDraftFromPlan && (
<div className="tgu-details__actions">
<button
className="tgu-button tgu-button--primary"
type="button"
disabled={draftActionInFlight}
title="Создать отдельный черновик-копию этого плана (то же окно, все включения) для правки и отправки"
onClick={() => onCreateDraftFromPlan(plan.planId)}
>
Создать черновик на основе плана
</button>
</div>
)}
{plan.status === "AWAITING_LAYIN" && onConfirmLayin && (
<div className="tgu-details__test-block">
<div className="tgu-details__test-label">ТЕСТОВЫЙ ФУНКЦИОНАЛ</div>
<button
className="tgu-button tgu-button--accept"
type="button"
disabled={decisionInFlight || layinConflict || layinStartInFuture}
title={
layinStartInFuture
? "Начало плана ещё не наступило — подтвердить закладку можно только после его старта"
: layinConflict
? "По КА уже заложен план со стартом в пределах ±10 минут — закладка недоступна"
: "Сымитировать событие «план заложен» (AWAITING_LAYIN → LAID_IN)"
}
onClick={confirmLayin}
>
Подтвердить закладку
</button>
{layinStartInFuture && (
<div className="tgu-details__test-hint">
Начало плана ещё в будущем подтвердить закладку можно только после наступления его старта.
</div>
)}
{!layinStartInFuture && layinConflict && (
<div className="tgu-details__test-hint">
По КА уже есть заложенный план со стартом в пределах ±10 минут подтвердить нельзя.
</div>
)}
</div>
)}
{canDismiss && (
dismissed
? onRestorePlan && (
<div className="tgu-details__actions">
<button
className="tgu-button"
type="button"
disabled={draftActionInFlight}
title="Вернуть план на учёт — снова будет считаться проблемой КА"
onClick={() => onRestorePlan(plan.planId)}
>
Вернуть на учёт
</button>
</div>
)
: onDismissPlan && (
<div className="tgu-details__actions">
<button
className="tgu-button"
type="button"
disabled={draftActionInFlight}
title="Снять отменённый/неудавшийся план с учёта, чтобы он не считался проблемой и не красил КА"
onClick={dismiss}
>
Снять с учёта
</button>
</div>
)
)}
</aside>
);
}
function Detail({ label, value, mono }: { label: string; value?: string; mono?: boolean }) {
return (
<div>
<dt>{label}</dt>
<dd className={mono ? "mono" : undefined}>{value || "-"}</dd>
</div>
);
}
function formatDateTime(value: string): string {
return formatLocalDateTime(parseLocal(value));
}
function formatDuration(ms: number): string {
const minutes = Math.max(0, Math.round(ms / 60_000));
const hours = Math.floor(minutes / 60);
const restMinutes = minutes % 60;
if (hours === 0) return `${restMinutes} мин`;
return `${hours} ч ${restMinutes} мин`;
}
@@ -0,0 +1,15 @@
import type { ReactNode } from "react";
type TguPlanningLayoutProps = {
toolbar: ReactNode;
children: ReactNode;
};
export function TguPlanningLayout({ toolbar, children }: TguPlanningLayoutProps) {
return (
<div className="tgu-app-shell">
{toolbar}
{children}
</div>
);
}
@@ -0,0 +1,623 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import {
confirmPlanLayin,
dismissPlan,
fetchPlans,
fetchPlatforms,
restorePlan,
sendPlanDecision
} from "../../api/tguApi";
import type { TguPlan, TguPlanDecision, TguPlatform } from "../../model/tguTypes";
import type { TguActiveTab, TguPlanUi, TimelineRange } from "../../model/timelineTypes";
import { TguComplexPlanTab } from "../tgu-complex-plan/TguComplexPlanTab";
import { ConstellationTab } from "../constellation/ConstellationTab";
import { TguEditorTab } from "../tgu-editor/TguEditorTab";
import { Tgu2DMapTab } from "../tgu-map-2d/Tgu2DMapTab";
import { TguRequestsTab } from "../tgu-requests/TguRequestsTab";
import {
abandonDraft,
createDraft,
fetchDraftPlans,
fetchZrvWindows,
promoteDraft,
type ZrvWindowResponse
} from "../../api/tguPlanApi";
import { fetchPlanModes } from "../tgu-editor/editorApi";
import { CreateChainPlanDialog } from "./CreateChainPlanDialog";
import { TguPlanDetails, type PlanModeCounts } from "./TguPlanDetails";
import { TguPlanningLayout } from "./TguPlanningLayout";
import { TguSidebar } from "./TguSidebar";
import { TguTimeline, type ZrvWindow } from "./TguTimeline";
import { TguToolbar } from "./TguToolbar";
import { TguZrvPanel } from "./TguZrvPanel";
import {
buildPlatformsForInterval,
buildTimelineRows,
isOperationalPlatform,
mapPlans,
operationalSpacecraftIds,
pausedSpacecraftIds
} from "./tguTimelineMapper";
import { parseLocal, toLocalInputValue } from "../../utils/localTime";
const DAY_MS = 24 * 60 * 60 * 1000;
const MINUTE_MS = 60 * 1000;
export function TguPlanningPage() {
const initialRange = useMemo(defaultRange, []);
const [fromValue, setFromValue] = useState(toLocalInputValue(initialRange.fromMs));
const [toValue, setToValue] = useState(toLocalInputValue(initialRange.toMs));
const [appliedRange, setAppliedRange] = useState<TimelineRange>(initialRange);
const [rawPlatforms, setRawPlatforms] = useState<TguPlatform[]>([]);
const [plans, setPlans] = useState<TguPlanUi[]>([]);
const [loading, setLoading] = useState(false);
const [pageError, setPageError] = useState<string>();
const [platformLoadFailed, setPlatformLoadFailed] = useState(false);
const [search, setSearch] = useState("");
const [selectedSpacecraftId, setSelectedSpacecraftId] = useState<string>();
const [selectedPlanId, setSelectedPlanId] = useState<string>();
const [activeTab, setActiveTab] = useState<TguActiveTab>("timeline");
const [createChainOpen, setCreateChainOpen] = useState(false);
// Черновики (DRAFT) — план-кандидаты, скрытые из основного read-path; грузим отдельно.
const [drafts, setDrafts] = useState<TguPlanUi[]>([]);
const [draftActionInFlight, setDraftActionInFlight] = useState(false);
// Тумблеры видимости «непринятого будущего» на схеме.
const [showDrafts, setShowDrafts] = useState(false);
const [showProjection, setShowProjection] = useState(true);
// ЗРВ выбранного КА — подсветка доступных зон на таймлайне (на какие зоны можно опереться).
const [zrvWindows, setZrvWindows] = useState<ZrvWindowResponse[]>([]);
// Выбор ЗРВ на таймлайне: пересекающиеся в точке зоны + индекс активной (панель справа).
const [zrvSelection, setZrvSelection] = useState<{ windows: ZrvWindow[]; index: number }>();
// Вкладки рендерятся в режиме keep-alive: каждую монтируем при первом открытии
// и дальше держим смонтированной (скрытой через display:none), чтобы её
// внутреннее состояние — загруженные заявки, выбор, слои, скролл — не сбрасывалось
// при переключении между вкладками.
const [visitedTabs, setVisitedTabs] = useState<ReadonlySet<TguActiveTab>>(() => new Set(["timeline"]));
const [decisionInFlight, setDecisionInFlight] = useState(false);
const [decisionNotice, setDecisionNotice] = useState<string>();
const [decisionError, setDecisionError] = useState<string>();
const parsedInputRange = useMemo(
() => ({
fromMs: parseLocal(fromValue),
toMs: parseLocal(toValue)
}),
[fromValue, toValue]
);
const invalidRange =
!Number.isFinite(parsedInputRange.fromMs) ||
!Number.isFinite(parsedInputRange.toMs) ||
parsedInputRange.fromMs >= parsedInputRange.toMs;
const loadData = useCallback(
async (range: TimelineRange) => {
setLoading(true);
setPageError(undefined);
setDecisionError(undefined);
const historyDays = historyDaysForRange(range);
const [platformResult, planResult, draftResult] = await Promise.allSettled([
fetchPlatforms(),
fetchPlans(historyDays),
fetchDraftPlans()
]);
// На морде работаем только с эксплуатируемыми КА — остальные отсекаем сразу
// на входе, чтобы они не всплывали ни в списке, ни как fallback-строки по планам.
const operationalPlatforms =
platformResult.status === "fulfilled" ? platformResult.value.filter(isOperationalPlatform) : [];
const operationalIds = operationalSpacecraftIds(operationalPlatforms);
if (platformResult.status === "rejected") {
setPlatformLoadFailed(true);
} else {
setPlatformLoadFailed(false);
setRawPlatforms(operationalPlatforms);
}
if (planResult.status === "rejected") {
setPlans([]);
setPageError(planResult.reason instanceof Error ? planResult.reason.message : "Не удалось загрузить планы.");
} else {
setPlans(mapPlans(planResult.value, operationalPlatforms).filter((plan) => operationalIds.has(plan.spacecraftId)));
}
// Черновики читаем из pcp-tgu-service отдельно; сбой непрозрачен — просто нет черновиков.
if (draftResult.status === "fulfilled") {
setDrafts(
mapPlans(draftResult.value as unknown as TguPlan[], operationalPlatforms)
.filter((plan) => operationalIds.has(plan.spacecraftId))
);
} else {
setDrafts([]);
}
setLoading(false);
},
[]
);
useEffect(() => {
void loadData(appliedRange);
}, [appliedRange, loadData]);
useEffect(() => {
setVisitedTabs((prev) => (prev.has(activeTab) ? prev : new Set(prev).add(activeTab)));
}, [activeTab]);
// ЗРВ выбранного КА для подсветки на таймлайне. Сбой непрозрачен — просто нет подсветки.
useEffect(() => {
if (!selectedSpacecraftId) {
setZrvWindows([]);
return;
}
let cancelled = false;
void fetchZrvWindows(selectedSpacecraftId)
.then((windows) => {
if (!cancelled) setZrvWindows(windows);
})
.catch(() => {
if (!cancelled) setZrvWindows([]);
});
return () => {
cancelled = true;
};
}, [selectedSpacecraftId]);
const zrvBands = useMemo(() => {
if (!selectedSpacecraftId || zrvWindows.length === 0) return undefined;
return {
spacecraftId: selectedSpacecraftId,
windows: zrvWindows.map((zrv) => ({
startMs: parseLocal(zrv.startTime),
endMs: parseLocal(zrv.endTime),
kppId: zrv.kppId
}))
};
}, [selectedSpacecraftId, zrvWindows]);
// Сбрасываем выбранную ЗРВ при смене КА (зоны перечитываются) — панель не должна показывать чужие.
useEffect(() => {
setZrvSelection(undefined);
}, [selectedSpacecraftId]);
const selectedZrvId = useMemo(() => {
if (!zrvSelection) return undefined;
const zrv = zrvSelection.windows[zrvSelection.index];
return zrv ? `${zrv.kppId}|${zrv.startMs}` : undefined;
}, [zrvSelection]);
// display:contents для активной вкладки сохраняет grid-раскладку shell
// (внутренний <main> остаётся прямым grid-элементом), display:none скрывает остальные.
const tabStyle = (tab: TguActiveTab) =>
({ display: activeTab === tab ? "contents" : "none" }) as const;
// Все планы для выбора/поиска (включая черновики), независимо от тумблеров видимости.
const allPlans = useMemo(() => [...plans, ...drafts], [plans, drafts]);
// Планы для отрисовки на таймлайне с учётом тумблеров: проекция PLANNED и черновики.
const visiblePlans = useMemo(() => {
const base = showProjection ? plans : plans.filter((plan) => plan.status !== "PLANNED");
return showDrafts ? [...base, ...drafts] : base;
}, [plans, drafts, showProjection, showDrafts]);
// КА с цепочкой на паузе после REJECTED (см. pausedSpacecraftIds): считаем по persisted-планам,
// черновики/проекция паузу не снимают. Совпадает с серверной паузой воркера выдачи.
const paused = useMemo(() => pausedSpacecraftIds(plans), [plans]);
const platforms = useMemo(
() => buildPlatformsForInterval(rawPlatforms, allPlans, appliedRange),
[rawPlatforms, allPlans, appliedRange]
);
const rows = useMemo(
() => buildTimelineRows(visiblePlans, platforms, appliedRange, selectedSpacecraftId),
[visiblePlans, platforms, appliedRange, selectedSpacecraftId]
);
const selectedPlan = useMemo(() => {
const plan = allPlans.find((item) => item.planId === selectedPlanId);
if (!plan) return undefined;
const platform = platforms.find((item) => item.spacecraftId === plan.spacecraftId);
return { ...plan, platform };
}, [allPlans, platforms, selectedPlanId]);
// Состав включений выбранного плана (счётчики съёмок/сбросов) для правой панели.
// planId == mission_id, поэтому грузим режимы плана тем же эндпоинтом, что и редактор.
// Зависим и от activeTab: при возврате на вкладку «Планы» перечитываем состав, иначе
// счётчик маршрутов остаётся прежним после правок в редакторе (selectedPlanId не менялся).
const [planModeCounts, setPlanModeCounts] = useState<PlanModeCounts>();
useEffect(() => {
if (!selectedPlanId || activeTab !== "timeline") {
setPlanModeCounts(undefined);
return;
}
let cancelled = false;
setPlanModeCounts(undefined);
void fetchPlanModes(selectedPlanId)
.then((modes) => {
if (cancelled) return;
setPlanModeCounts({
survey: modes.filter((mode) => mode.type === "SURVEY").length,
drop: modes.filter((mode) => mode.type === "DROP").length
});
})
.catch(() => {
if (!cancelled) setPlanModeCounts(undefined);
});
return () => {
cancelled = true;
};
}, [selectedPlanId, activeTab]);
const applyRange = () => {
setDecisionNotice(undefined);
if (invalidRange) {
setPageError("Некорректный интервал: from должен быть меньше to.");
return;
}
setPageError(undefined);
setAppliedRange(parsedInputRange);
};
const quickRange = (hours: number) => {
const fromMs = startOfCurrentDayMs();
const toMs = fromMs + hours * 60 * 60 * 1000;
setFromValue(toLocalInputValue(fromMs));
setToValue(toLocalInputValue(toMs));
setAppliedRange({ fromMs, toMs });
setPageError(undefined);
setDecisionNotice(undefined);
};
const handleDecision = async (planId: string, decision: TguPlanDecision, reason?: string) => {
setDecisionInFlight(true);
setDecisionError(undefined);
setDecisionNotice(undefined);
try {
await sendPlanDecision(planId, decision, reason);
setDecisionNotice(
decision === "REJECTED"
? "Решение REJECTED отправлено. План может вернуться в PLANNED для повторной выдачи."
: "Решение ACCEPTED отправлено."
);
await loadData(appliedRange);
} catch (error) {
setDecisionError(error instanceof Error ? error.message : "Не удалось отправить решение.");
} finally {
setDecisionInFlight(false);
}
};
// ТЕСТ: подтвердить закладку выбранного плана (AWAITING_LAYIN → LAID_IN) через тест-контроллер.
const handleConfirmLayin = async (planId: string) => {
setDecisionInFlight(true);
setDecisionError(undefined);
setDecisionNotice(undefined);
try {
await confirmPlanLayin(planId);
setDecisionNotice("Закладка подтверждена (LAID_IN). План стал базой цепочки.");
await loadData(appliedRange);
} catch (error) {
setDecisionError(error instanceof Error ? error.message : "Не удалось подтвердить закладку.");
} finally {
setDecisionInFlight(false);
}
};
// Гард ±10 мин: нельзя подтвердить закладку, если по КА уже заложен (LAID_IN) план со стартом
// в пределах 10 минут. Дублирует бэкенд-гард, чтобы заранее погасить кнопку с подсказкой.
const layinConflict = useMemo(() => {
if (!selectedPlan || selectedPlan.status !== "AWAITING_LAYIN") return false;
const windowMs = 10 * 60_000;
return allPlans.some(
(item) =>
item.planId !== selectedPlan.planId &&
item.spacecraftId === selectedPlan.spacecraftId &&
item.status === "LAID_IN" &&
Math.abs(item.startMs - selectedPlan.startMs) < windowMs
);
}, [allPlans, selectedPlan]);
// Открыть черновик в редакторе: выбираем его план/КА и переключаемся на вкладку «Редактор».
const handleEditDraft = (planId: string) => {
const draft = drafts.find((item) => item.planId === planId);
setSelectedPlanId(planId);
if (draft) setSelectedSpacecraftId(draft.spacecraftId);
setDecisionNotice(undefined);
setActiveTab("editor");
};
// Отправить черновик вручную (promote): уходит в выдачу, рабочим станет после приёмки.
const handlePromoteDraft = async (planId: string) => {
setDraftActionInFlight(true);
setDecisionError(undefined);
setDecisionNotice(undefined);
try {
await promoteDraft(planId);
setDecisionNotice("Черновик отправлен на выдачу. Рабочим (якорем цепочки) он станет после приёмки.");
await loadData(appliedRange);
} catch (error) {
setDecisionError(error instanceof Error ? error.message : "Не удалось отправить черновик.");
} finally {
setDraftActionInFlight(false);
}
};
// Создать черновик-копию на основе существующего плана (replace, окно 1:1, все включения).
// Это путь «сделать исправленный план», а не повторная отправка того же. В редактор сами
// не уходим — оператор выберет черновик и перейдёт в «Редактор» вручную.
const handleCreateDraftFromPlan = async (planId: string) => {
// Один открытый черновик на слот (КА + окно): replace-черновик копирует окно исходного плана
// 1:1, а его basePlanId теперь = база исходного (предшественник), а не сам исходный план —
// поэтому ищем уже созданный черновик по совпадению окна, а не по basePlanId.
const source = allPlans.find((item) => item.planId === planId);
const existing = source
? drafts.find(
(item) =>
item.spacecraftId === source.spacecraftId &&
item.startMs === source.startMs &&
item.endMs === source.endMs
)
: undefined;
if (existing) {
setShowDrafts(true);
setSelectedSpacecraftId(existing.spacecraftId);
setSelectedPlanId(existing.planId);
setDecisionNotice("Черновик на основе этого плана уже создан — выбран на схеме.");
return;
}
setDraftActionInFlight(true);
setDecisionError(undefined);
setDecisionNotice(undefined);
try {
const { draftPlanId } = await createDraft(planId, { mode: "replace" });
await loadData(appliedRange);
const base = allPlans.find((item) => item.planId === planId);
setShowDrafts(true);
if (base) setSelectedSpacecraftId(base.spacecraftId);
setSelectedPlanId(draftPlanId);
setDecisionNotice("Черновик создан и выбран на схеме. Откройте «Редактор», чтобы поправить и отправить его.");
} catch (error) {
setDecisionError(error instanceof Error ? error.message : "Не удалось создать черновик на основе плана.");
} finally {
setDraftActionInFlight(false);
}
};
// Удалить черновик (abandon): убирает DRAFT и его клон-миссию.
const handleDeleteDraft = async (planId: string) => {
setDraftActionInFlight(true);
setDecisionError(undefined);
setDecisionNotice(undefined);
try {
await abandonDraft(planId);
setSelectedPlanId(undefined);
setDecisionNotice("Черновик удалён.");
await loadData(appliedRange);
} catch (error) {
setDecisionError(error instanceof Error ? error.message : "Не удалось удалить черновик.");
} finally {
setDraftActionInFlight(false);
}
};
// Снять/вернуть план с учёта: косметико-статистический флаг, не трогает статус и цепочку.
const handleDismissPlan = async (planId: string) => {
setDraftActionInFlight(true);
setDecisionError(undefined);
setDecisionNotice(undefined);
try {
await dismissPlan(planId);
setDecisionNotice("План снят с учёта — больше не считается проблемой КА.");
await loadData(appliedRange);
} catch (error) {
setDecisionError(error instanceof Error ? error.message : "Не удалось снять план с учёта.");
} finally {
setDraftActionInFlight(false);
}
};
const handleRestorePlan = async (planId: string) => {
setDraftActionInFlight(true);
setDecisionError(undefined);
setDecisionNotice(undefined);
try {
await restorePlan(planId);
setDecisionNotice("План возвращён на учёт.");
await loadData(appliedRange);
} catch (error) {
setDecisionError(error instanceof Error ? error.message : "Не удалось вернуть план на учёт.");
} finally {
setDraftActionInFlight(false);
}
};
return (
<TguPlanningLayout
toolbar={
<TguToolbar
fromValue={fromValue}
toValue={toValue}
loading={loading}
activeTab={activeTab}
error={pageError}
onTabChange={setActiveTab}
onFromChange={setFromValue}
onToChange={setToValue}
onApply={applyRange}
onQuickRange={quickRange}
/>
}
>
<div style={tabStyle("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">
<div className="tgu-center__actions">
<label className="tgu-center__toggle">
<input type="checkbox" checked={showDrafts} onChange={(e) => setShowDrafts(e.target.checked)} />
Черновики
</label>
<label className="tgu-center__toggle">
<input type="checkbox" checked={showProjection} onChange={(e) => setShowProjection(e.target.checked)} />
Возможные будущие планы
</label>
<button
className="tgu-button tgu-button--primary"
type="button"
disabled={!selectedSpacecraftId}
title={selectedSpacecraftId ? "Создать план новой цепочки" : "Сначала выберите КА"}
onClick={() => setCreateChainOpen(true)}
>
Создать план
</button>
</div>
<TguTimeline
rows={rows}
range={appliedRange}
invalidRange={invalidRange}
zrvBands={zrvBands}
selectedZrvId={selectedZrvId}
pausedSpacecraftIds={paused}
selectedPlanId={selectedPlanId}
onSelectPlan={(planId) => {
setZrvSelection(undefined);
setDecisionNotice(undefined);
setDecisionError(undefined);
setSelectedPlanId(planId);
const plan = allPlans.find((item) => item.planId === planId);
if (plan) {
setSelectedSpacecraftId(plan.spacecraftId);
}
}}
onSelectZrv={(windows, index) => {
setSelectedPlanId(undefined);
setDecisionNotice(undefined);
setDecisionError(undefined);
setZrvSelection({ windows, index });
}}
/>
</section>
{zrvSelection ? (
<TguZrvPanel
windows={zrvSelection.windows}
selectedIndex={zrvSelection.index}
onSelect={(index) => setZrvSelection((current) => (current ? { ...current, index } : current))}
onClose={() => setZrvSelection(undefined)}
/>
) : (
<TguPlanDetails
plan={selectedPlan}
modeCounts={planModeCounts}
chainPaused={selectedPlan ? paused.has(selectedPlan.spacecraftId) : false}
decisionInFlight={decisionInFlight}
notice={decisionNotice}
error={decisionError}
onDecision={handleDecision}
draftActionInFlight={draftActionInFlight}
onEditDraft={handleEditDraft}
onPromoteDraft={handlePromoteDraft}
onDeleteDraft={handleDeleteDraft}
onCreateDraftFromPlan={handleCreateDraftFromPlan}
onConfirmLayin={handleConfirmLayin}
layinConflict={layinConflict}
onDismissPlan={handleDismissPlan}
onRestorePlan={handleRestorePlan}
onClose={() => setSelectedPlanId(undefined)}
/>
)}
</main>
{createChainOpen && selectedSpacecraftId && (
<CreateChainPlanDialog
spacecraftId={selectedSpacecraftId}
plans={plans}
initialBasePlanId={selectedPlanId}
onClose={() => setCreateChainOpen(false)}
onCreated={() => {
setShowDrafts(true);
void loadData(appliedRange);
}}
onEdit={(draftPlanId) => {
setCreateChainOpen(false);
setSelectedPlanId(draftPlanId);
setDecisionNotice(undefined);
setActiveTab("editor");
}}
/>
)}
</div>
{visitedTabs.has("map") && (
<div style={tabStyle("map")}>
<Tgu2DMapTab
range={appliedRange}
invalidRange={invalidRange}
selectedPlan={selectedPlan}
platforms={platforms}
/>
</div>
)}
{visitedTabs.has("complex") && (
<div style={tabStyle("complex")}>
<TguComplexPlanTab
range={appliedRange}
platforms={platforms}
/>
</div>
)}
{visitedTabs.has("requests") && (
<div style={tabStyle("requests")}>
<TguRequestsTab
range={appliedRange}
invalidRange={invalidRange}
platforms={platforms}
/>
</div>
)}
{visitedTabs.has("constellation") && (
<div style={tabStyle("constellation")}>
<ConstellationTab />
</div>
)}
{visitedTabs.has("editor") && (
<div style={tabStyle("editor")}>
<TguEditorTab
selectedSpacecraftId={selectedPlan?.spacecraftId ?? selectedSpacecraftId}
selectedPlan={selectedPlan}
appliedRange={appliedRange}
platforms={platforms}
/>
</div>
)}
</TguPlanningLayout>
);
}
function defaultRange(): TimelineRange {
const now = Date.now();
return { fromMs: now - 30 * MINUTE_MS, toMs: now + 3 * DAY_MS };
}
function startOfCurrentDayMs(): number {
const now = new Date();
return new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
}
function historyDaysForRange(range: TimelineRange): number {
const daysBack = Math.ceil(Math.max(0, Date.now() - range.fromMs) / DAY_MS);
return Math.max(1, daysBack + 1);
}
@@ -0,0 +1,79 @@
import type { TguPlatformUi } from "../../model/timelineTypes";
import { filterPlatforms, platformLabel } from "./tguTimelineMapper";
import { TguLegend } from "./TguLegend";
type TguSidebarProps = {
platforms: TguPlatformUi[];
selectedSpacecraftId?: string;
search: string;
platformLoadFailed: boolean;
onSearchChange: (value: string) => void;
onSelectSpacecraft: (spacecraftId?: string) => void;
};
export function TguSidebar({
platforms,
selectedSpacecraftId,
search,
platformLoadFailed,
onSearchChange,
onSelectSpacecraft
}: TguSidebarProps) {
const filtered = filterPlatforms(platforms, search);
return (
<aside className="tgu-sidebar">
<div className="tgu-panel-heading">
<span>Космические аппараты</span>
<button className="tgu-link-button" type="button" onClick={() => onSelectSpacecraft(undefined)}>
Все
</button>
</div>
<div className="tgu-search">
<input
type="search"
placeholder="Поиск name / NORAD / mission"
value={search}
onChange={(event) => onSearchChange(event.target.value)}
/>
</div>
{platformLoadFailed && (
<div className="tgu-alert tgu-alert--warning">
Список платформ не загрузился. Timeline построен по spacecraftId из планов.
</div>
)}
<div className="tgu-spacecraft-list">
{filtered.map((platform) => {
const selected = selectedSpacecraftId === platform.spacecraftId;
return (
<button
className={`tgu-spacecraft ${selected ? "is-selected" : ""}`}
type="button"
key={platform.spacecraftId}
onClick={() => onSelectSpacecraft(selected ? undefined : platform.spacecraftId)}
>
<span className={`tgu-spacecraft__problem ${platform.hasProblemStatus ? "is-problem" : ""}`} />
<span className="tgu-spacecraft__body">
<span className="tgu-spacecraft__name">{platformLabel(platform, platform.spacecraftId)}</span>
<span className="tgu-spacecraft__meta">
NORAD {platform.noradId || platform.spacecraftId}
{platform.mission ? ` · ${platform.mission}` : ""}
</span>
{platform.status && <span className="tgu-spacecraft__status">{platform.status}</span>}
</span>
<span className="tgu-spacecraft__count">{platform.planCount}</span>
</button>
);
})}
{filtered.length === 0 && <div className="tgu-empty tgu-empty--compact">Нет КА по текущему поиску.</div>}
</div>
<div className="tgu-sidebar__legend">
<TguLegend />
</div>
</aside>
);
}
@@ -0,0 +1,299 @@
import { useLayoutEffect, useMemo, useRef, useState } from "react";
import type { TimelineRange, TimelineRow } from "../../model/timelineTypes";
import {
buildTimeTicks,
buildTimelineLayout,
TIMELINE_AXIS_HEIGHT,
TIMELINE_BOTTOM_PADDING,
TIMELINE_LABEL_WIDTH,
TIMELINE_ZRV_BAND_HEIGHT,
timeToX
} from "./tguTimelineLayout";
import { platformLabel } from "./tguTimelineMapper";
import { statusStyle } from "./tguStatus";
import { formatLocalDate, formatLocalDateTime, formatLocalTime, parseLocal } from "../../utils/localTime";
export type ZrvWindow = { startMs: number; endMs: number; kppId: string };
// Терминально-«мёртвые» статусы: план выбыл из живой цепочки (вытеснен закладкой, отклонён,
// не принят, сбой закладки, неоднозначный старт). Применяется ТОЛЬКО к «следующему» (обратная
// ссылка basePlanId → выбранный, она неоднозначна): мёртвый план — это, как правило, коррекция,
// проигравшая гонку закладки и оставшаяся со ссылкой base_plan_id на победителя (особенно старые
// строки до наследования предшественника), и без фильтра она ложно показывалась бы «следующей».
// На «предыдущий» НЕ распространяется: это явная база самого выбранного плана (forward-ссылка из
// его поля basePlanId) — её показываем всегда, даже если база REJECTED/SUPERSEDED.
const DEAD_LINK_STATUSES: ReadonlySet<string> = new Set([
"SUPERSEDED",
"REJECTED",
"LAYIN_FAILED",
"NOT_ACCEPTED",
"START_AMBIGUOUS"
]);
type ZrvBands = {
spacecraftId: string;
windows: ZrvWindow[];
};
type TguTimelineProps = {
rows: TimelineRow[];
range: TimelineRange;
selectedPlanId?: string;
invalidRange?: boolean;
/** ЗРВ выбранного КА — подсветка доступных зон на его строке. */
zrvBands?: ZrvBands;
/** id выбранной ЗРВ (`${kppId}|${startMs}`) — подсветка кликнутой зоны. */
selectedZrvId?: string;
/** КА с цепочкой на паузе после REJECTED — рисуем пометку у имени КА. */
pausedSpacecraftIds?: ReadonlySet<string>;
onSelectPlan: (planId: string) => void;
/** Клик по ЗРВ: передаём пересекающиеся в этой точке зоны и индекс кликнутой. */
onSelectZrv?: (windows: ZrvWindow[], selectedIndex: number) => void;
};
function zrvId(zrv: ZrvWindow): string {
return `${zrv.kppId}|${zrv.startMs}`;
}
export function TguTimeline({
rows,
range,
selectedPlanId,
invalidRange,
zrvBands,
selectedZrvId,
pausedSpacecraftIds,
onSelectPlan,
onSelectZrv
}: TguTimelineProps) {
const containerRef = useRef<HTMLDivElement | null>(null);
const [containerWidth, setContainerWidth] = useState(1200);
const timelineWidth = Math.max(720, containerWidth - TIMELINE_LABEL_WIDTH - 28);
const fullWidth = TIMELINE_LABEL_WIDTH + timelineWidth;
const nowMs = Date.now();
useLayoutEffect(() => {
if (!containerRef.current) return;
setContainerWidth(Math.floor(containerRef.current.getBoundingClientRect().width));
const observer = new ResizeObserver(([entry]) => {
setContainerWidth(Math.floor(entry.contentRect.width));
});
observer.observe(containerRef.current);
return () => observer.disconnect();
}, []);
const layout = useMemo(
() => buildTimelineLayout(rows, range, timelineWidth),
[rows, range, timelineWidth]
);
const segments = layout.segments;
const height = Math.max(260, layout.height);
// basePlanId выбранного плана — чтобы подсветить его прошлое/будущее звено по РЕАЛЬНОЙ связи
// (а не по времени): предыдущий = план с этим planId, следующий = план с basePlanId == выбранный.
const selectedBasePlanId = selectedPlanId
? segments.find((segment) => segment.plan.planId === selectedPlanId)?.plan.basePlanId ?? undefined
: undefined;
const ticks = useMemo(() => buildTimeTicks(range), [range]);
const nowX =
nowMs >= range.fromMs && nowMs <= range.toMs
? TIMELINE_LABEL_WIDTH + timeToX(nowMs, range, timelineWidth)
: undefined;
return (
<div className="tgu-timeline" ref={containerRef}>
{invalidRange ? (
<div className="tgu-empty">Некорректный интервал: from должен быть меньше to.</div>
) : rows.length === 0 || segments.length === 0 ? (
<div className="tgu-empty">Нет планов в выбранном интервале.</div>
) : (
<svg className="tgu-timeline__svg" viewBox={`0 0 ${fullWidth} ${height}`} style={{ height }} role="img" aria-label="Timeline планов ТГУ">
<rect x="0" y="0" width={fullWidth} height={height} className="tgu-timeline__background" />
<rect x="0" y="0" width={TIMELINE_LABEL_WIDTH} height={height} className="tgu-timeline__labels-background" />
<line
x1={TIMELINE_LABEL_WIDTH}
x2={fullWidth}
y1={TIMELINE_AXIS_HEIGHT}
y2={TIMELINE_AXIS_HEIGHT}
className="tgu-timeline__axis"
/>
{ticks.map((tick) => {
const x = TIMELINE_LABEL_WIDTH + timeToX(tick, range, timelineWidth);
return (
<g key={tick}>
<line x1={x} x2={x} y1="0" y2={height - TIMELINE_BOTTOM_PADDING} className="tgu-timeline__grid" />
<text x={x + 6} y="18" className="tgu-timeline__tick-date">
{formatTickDate(tick)}
</text>
<text x={x + 6} y="34" className="tgu-timeline__tick-time">
{formatTickTime(tick)}
</text>
</g>
);
})}
{nowX !== undefined && (
<g>
<line x1={nowX} x2={nowX} y1="0" y2={height - TIMELINE_BOTTOM_PADDING} className="tgu-timeline__now" />
<text x={nowX + 7} y="16" className="tgu-timeline__now-label">
сейчас
</text>
</g>
)}
{layout.rowLayouts.map(({ row, rowIndex, y, height: rowHeight, zrvBandY }) => {
return (
<g key={row.spacecraftId}>
<rect
x="0"
y={y}
width={fullWidth}
height={rowHeight}
className={rowIndex % 2 === 0 ? "tgu-timeline__row" : "tgu-timeline__row tgu-timeline__row--alt"}
/>
<text x="16" y={y + 22} className="tgu-timeline__row-title">
{platformLabel(row.platform, row.spacecraftId)}
</text>
<text x="16" y={y + 38} className="tgu-timeline__row-meta">
NORAD {row.platform?.noradId || row.spacecraftId}
</text>
{pausedSpacecraftIds?.has(row.spacecraftId) && (
<text
x={TIMELINE_LABEL_WIDTH - 12}
y={y + 22}
textAnchor="end"
className="tgu-timeline__paused-badge"
>
на паузе
<title>Цепочка на паузе: план отклонён создайте исправленный план («Создать план»).</title>
</text>
)}
{zrvBands?.spacecraftId === row.spacecraftId && (
<>
<text x="16" y={zrvBandY + TIMELINE_ZRV_BAND_HEIGHT - 3} className="tgu-timeline__zrv-label">
ЗРВ
</text>
{zrvBands.windows.map((zrv, zrvIndex) => {
const from = Math.max(zrv.startMs, range.fromMs);
const to = Math.min(zrv.endMs, range.toMs);
if (to <= from) return null;
const bx = TIMELINE_LABEL_WIDTH + timeToX(from, range, timelineWidth);
const bw = Math.max(2, timeToX(to, range, timelineWidth) - timeToX(from, range, timelineWidth));
const id = zrvId(zrv);
return (
<rect
key={`zrv-${zrvIndex}`}
className={`tgu-timeline__zrv${selectedZrvId === id ? " is-selected" : ""}`}
x={bx}
y={zrvBandY}
width={bw}
height={TIMELINE_ZRV_BAND_HEIGHT}
style={onSelectZrv ? { cursor: "pointer" } : undefined}
onClick={(event) => {
if (!onSelectZrv) return;
event.stopPropagation();
// Зоны «в этой точке» — все ЗРВ, пересекающие кликнутую по времени
// (разные КПП могут перекрываться), как «заявки в точке» на карте.
const overlapping = zrvBands.windows.filter(
(w) => w.startMs < zrv.endMs && w.endMs > zrv.startMs
);
const idx = overlapping.findIndex((w) => zrvId(w) === id);
onSelectZrv(overlapping, Math.max(0, idx));
}}
>
<title>ЗРВ {zrv.kppId} · {formatLocalDateTime(zrv.startMs)} {formatLocalDateTime(zrv.endMs)}</title>
</rect>
);
})}
</>
)}
</g>
);
})}
{segments.map((segment) => {
const y = segment.centerY;
const x1 = TIMELINE_LABEL_WIDTH + segment.x;
const x2 = TIMELINE_LABEL_WIDTH + segment.x + segment.width;
const style = statusStyle(segment.plan.status);
const selected = selectedPlanId === segment.plan.planId;
// Сосед выбранного по цепочке: предыдущий (прошлое) — planId == basePlanId выбранного;
// следующий (будущее) — basePlanId == выбранный. Подсветка по реальной связи, не по времени.
// prev — явная база выбранного плана (forward-ссылка), показываем всегда, даже мёртвую.
// next — обратная ссылка; мёртвый план (вытесненный/отклонённый конкурент того же слота)
// живым продолжением не подсвечиваем.
const liveLink = !DEAD_LINK_STATUSES.has(segment.plan.status);
const neighbor = selected
? undefined
: selectedBasePlanId && segment.plan.planId === selectedBasePlanId
? "prev"
: selectedPlanId && liveLink && segment.plan.basePlanId === selectedPlanId
? "next"
: undefined;
const labelVisible = segment.width > 92;
// При активном выборе все «непричастные» планы гасим до еле заметных, а текущий /
// прошлый / следующий обводим яркой рамкой — так видно звенья цепочки выбранного плана.
const dimmed = selectedPlanId !== undefined && !selected && neighbor === undefined;
// Снят с учёта: гасим план на таймлайне (но не сильнее, чем выбор-затемнение).
const dismissed = Boolean(segment.plan.dismissedAt);
return (
<g
key={segment.plan.planId}
className={`tgu-timeline-plan ${selected ? "is-selected" : ""} ${dimmed ? "is-dimmed" : ""} ${
dismissed ? "is-dismissed-plan" : ""
}`}
onClick={() => onSelectPlan(segment.plan.planId)}
>
<title>
{segment.plan.planId} · {segment.plan.status}
{dismissed ? " · снят с учёта" : ""}
{neighbor === "prev" ? " · предыдущий в цепочке" : neighbor === "next" ? " · следующий в цепочке" : ""} ·{" "}
{formatDateTime(segment.plan.startTime)} - {formatDateTime(segment.plan.endTime)}
</title>
{neighbor && (
<line
x1={x1}
x2={x2}
y1={y}
y2={y}
className={`tgu-timeline-plan__neighbor tgu-timeline-plan__neighbor--${neighbor}`}
/>
)}
{selected && <line x1={x1} x2={x2} y1={y} y2={y} className="tgu-timeline-plan__selection" />}
<line
x1={x1}
x2={x2}
y1={y}
y2={y}
className={`tgu-timeline-plan__bar ${style.className}`}
stroke={style.color}
/>
{labelVisible && (
<text x={x1 + 10} y={y + 4} className="tgu-timeline-plan__label">
{segment.plan.kppId} · {segment.plan.status}
</text>
)}
</g>
);
})}
</svg>
)}
</div>
);
}
function formatTickDate(ms: number): string {
return formatLocalDate(ms);
}
function formatTickTime(ms: number): string {
return formatLocalTime(ms);
}
function formatDateTime(value: string): string {
return formatLocalDateTime(parseLocal(value));
}
@@ -0,0 +1,122 @@
import type { TguActiveTab } from "../../model/timelineTypes";
import { DateTime24Field } from "../../components/timeFields";
type TguToolbarProps = {
fromValue: string;
toValue: string;
loading: boolean;
activeTab: TguActiveTab;
error?: string;
onTabChange: (tab: TguActiveTab) => void;
onFromChange: (value: string) => void;
onToChange: (value: string) => void;
onApply: () => void;
onQuickRange: (hours: number) => void;
};
export function TguToolbar({
fromValue,
toValue,
loading,
activeTab,
error,
onTabChange,
onFromChange,
onToChange,
onApply,
onQuickRange
}: TguToolbarProps) {
return (
<header className="tgu-toolbar">
<div className="tgu-toolbar__row">
<div className="tgu-toolbar__brand">
<span className="tgu-toolbar__mark" aria-hidden="true" />
<div>
<div className="tgu-toolbar__title">Планирование ТГУ</div>
<div className="tgu-toolbar__subtitle">Mission Ops</div>
</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")}
>
Планы
</button>
<button
className={activeTab === "map" ? "is-active" : ""}
type="button"
role="tab"
aria-selected={activeTab === "map"}
onClick={() => onTabChange("map")}
>
Карта
</button>
<button
className={activeTab === "complex" ? "is-active" : ""}
type="button"
role="tab"
aria-selected={activeTab === "complex"}
onClick={() => onTabChange("complex")}
>
Комплексный план
</button>
<button
className={activeTab === "editor" ? "is-active" : ""}
type="button"
role="tab"
aria-selected={activeTab === "editor"}
onClick={() => onTabChange("editor")}
>
Редактор
</button>
<button
className={activeTab === "requests" ? "is-active" : ""}
type="button"
role="tab"
aria-selected={activeTab === "requests"}
onClick={() => onTabChange("requests")}
>
Заявки
</button>
<button
className={activeTab === "constellation" ? "is-active" : ""}
type="button"
role="tab"
aria-selected={activeTab === "constellation"}
onClick={() => onTabChange("constellation")}
>
Группировка
</button>
</div>
<label className="tgu-field">
<span>С</span>
<DateTime24Field value={fromValue} onChange={onFromChange} />
</label>
<label className="tgu-field">
<span>По</span>
<DateTime24Field value={toValue} onChange={onToChange} />
</label>
<button className="tgu-button tgu-button--primary" type="button" onClick={onApply} disabled={loading}>
Показать
</button>
<button className="tgu-button" type="button" onClick={() => onQuickRange(24)} disabled={loading}>
24ч
</button>
<button className="tgu-button" type="button" onClick={() => onQuickRange(72)} disabled={loading}>
3 суток
</button>
<button className="tgu-button" type="button" onClick={() => onQuickRange(168)} disabled={loading}>
7 суток
</button>
</div>
{error && <div className="tgu-alert tgu-alert--danger">{error}</div>}
</header>
);
}
@@ -0,0 +1,74 @@
import type { ZrvWindow } from "./TguTimeline";
import { formatLocalDateTime } from "../../utils/localTime";
type Props = {
windows: ZrvWindow[];
selectedIndex: number;
onSelect: (index: number) => void;
onClose: () => void;
};
/**
* Панель по выбранной ЗРВ в окне «Планы». Если в точке пересекается несколько зон
* (разные КПП), показываем список по примеру панели заявок на вкладке «Карта».
*/
export function TguZrvPanel({ windows, selectedIndex, onSelect, onClose }: Props) {
const selected = windows[selectedIndex];
return (
<aside className="tgu-map-requests-panel">
<div className="tgu-panel-heading">
<span>Зоны радиовидимости ({windows.length})</span>
<button className="tgu-link-button" type="button" onClick={onClose}>
Закрыть
</button>
</div>
<div className="tgu-map-requests-list">
{windows.map((zrv, index) => (
<button
key={`${zrv.kppId}|${zrv.startMs}`}
className={`tgu-map-request-row ${index === selectedIndex ? "is-selected" : ""}`}
type="button"
onClick={() => onSelect(index)}
>
<span className="tgu-map-request-row__body">
<span className="tgu-map-request-row__name">КПП {zrv.kppId}</span>
<span className="tgu-map-request-row__meta">
{formatLocalDateTime(zrv.startMs)} {formatLocalDateTime(zrv.endMs)}
{" · "}
{formatDuration(zrv.endMs - zrv.startMs)}
</span>
</span>
</button>
))}
</div>
{selected && (
<div className="tgu-map-request-details">
<div className="tgu-map-panel__label">Детали ЗРВ</div>
<dl className="tgu-map-request-details__grid">
<dt>КПП</dt>
<dd className="mono">{selected.kppId}</dd>
<dt>Начало</dt>
<dd className="mono">{formatLocalDateTime(selected.startMs)}</dd>
<dt>Конец</dt>
<dd className="mono">{formatLocalDateTime(selected.endMs)}</dd>
<dt>Длительность</dt>
<dd className="mono">{formatDuration(selected.endMs - selected.startMs)}</dd>
</dl>
</div>
)}
</aside>
);
}
function formatDuration(ms: number): string {
const totalSeconds = Math.max(0, Math.round(ms / 1000));
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return minutes > 0 ? `${minutes} мин ${seconds} с` : `${seconds} с`;
}
@@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import { buildChainPlanRequest, futureZrvOptions, type ZrvOption } from "./createChainWindows";
import type { ZrvWindowResponse } from "../../api/tguPlanApi";
const NOW = new Date(2026, 5, 3, 12, 0, 0).getTime();
function zrv(startIso: string, endIso: string, kppId = "KPP-1"): ZrvWindowResponse {
return { startTime: startIso, endTime: endIso, kppId };
}
describe("futureZrvOptions", () => {
it("keeps only future zones and sorts by start", () => {
const windows = [
zrv("2026-06-03T15:00:00", "2026-06-03T15:10:00"),
zrv("2026-06-03T11:00:00", "2026-06-03T11:10:00"), // в прошлом → отбросить
zrv("2026-06-03T13:00:00", "2026-06-03T13:10:00")
];
const options = futureZrvOptions(windows, NOW);
expect(options.map((o) => o.startMs)).toEqual([
new Date(2026, 5, 3, 13, 0, 0).getTime(),
new Date(2026, 5, 3, 15, 0, 0).getTime()
]);
});
it("produces naive (no zone) start/stop strings", () => {
const [option] = futureZrvOptions([zrv("2026-06-03T13:00:00", "2026-06-03T13:10:00")], NOW);
expect(option.start).toBe("2026-06-03T13:00:00");
expect(option.stop).toBe("2026-06-03T13:10:00");
expect(option.kppId).toBe("KPP-1");
});
});
describe("buildChainPlanRequest", () => {
const start: ZrvOption = {
id: "a", startMs: Date.UTC(2026, 5, 3, 13, 0, 0), endMs: Date.UTC(2026, 5, 3, 13, 10, 0),
start: "2026-06-03T13:00:00", stop: "2026-06-03T13:10:00", kppId: "KPP-1"
};
const end: ZrvOption = {
id: "b", startMs: Date.UTC(2026, 5, 3, 15, 0, 0), endMs: Date.UTC(2026, 5, 3, 15, 10, 0),
start: "2026-06-03T15:00:00", stop: "2026-06-03T15:10:00", kppId: "KPP-2"
};
it("spans from start zone begin to end zone finish and keeps the start kpp", () => {
const req = buildChainPlanRequest("25544", "base-1", start, end);
expect(req).toEqual({
spacecraftId: "25544",
basePlanId: "base-1",
windowStart: "2026-06-03T13:00:00",
windowStop: "2026-06-03T15:10:00",
windowKppId: "KPP-1"
});
});
it("allows a single-zone window (start == end zone)", () => {
const req = buildChainPlanRequest("25544", null, start, start);
expect(req.windowStart).toBe("2026-06-03T13:00:00");
expect(req.windowStop).toBe("2026-06-03T13:10:00");
expect(req.basePlanId).toBeNull();
});
it("throws when the end zone finishes before the start begins", () => {
expect(() => buildChainPlanRequest("25544", null, end, start)).toThrow();
});
});

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