diff --git a/services/pcp-tgu-ui-service/.dockerignore b/services/pcp-tgu-ui-service/.dockerignore new file mode 100644 index 0000000..fc8b165 --- /dev/null +++ b/services/pcp-tgu-ui-service/.dockerignore @@ -0,0 +1,4 @@ +node_modules +dist +.vite +npm-debug.log* diff --git a/services/pcp-tgu-ui-service/.env.example b/services/pcp-tgu-ui-service/.env.example new file mode 100644 index 0000000..9506e28 --- /dev/null +++ b/services/pcp-tgu-ui-service/.env.example @@ -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 diff --git a/services/pcp-tgu-ui-service/.gitignore b/services/pcp-tgu-ui-service/.gitignore new file mode 100644 index 0000000..a70b050 --- /dev/null +++ b/services/pcp-tgu-ui-service/.gitignore @@ -0,0 +1,6 @@ +dist +node_modules +.npm +.npm-cache +.env +.DS_Store diff --git a/services/pcp-tgu-ui-service/.gitlab-ci.yml b/services/pcp-tgu-ui-service/.gitlab-ci.yml new file mode 100644 index 0000000..7514c30 --- /dev/null +++ b/services/pcp-tgu-ui-service/.gitlab-ci.yml @@ -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 diff --git a/services/pcp-tgu-ui-service/.semgrepignore b/services/pcp-tgu-ui-service/.semgrepignore new file mode 100644 index 0000000..8116e2c --- /dev/null +++ b/services/pcp-tgu-ui-service/.semgrepignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +build/ +coverage/ +storybook-static/ +*.min.js diff --git a/services/pcp-tgu-ui-service/Dockerfile b/services/pcp-tgu-ui-service/Dockerfile new file mode 100644 index 0000000..cfaeb69 --- /dev/null +++ b/services/pcp-tgu-ui-service/Dockerfile @@ -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;"] diff --git a/services/pcp-tgu-ui-service/README.md b/services/pcp-tgu-ui-service/README.md new file mode 100644 index 0000000..26ca89b --- /dev/null +++ b/services/pcp-tgu-ui-service/README.md @@ -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. diff --git a/services/pcp-tgu-ui-service/docker-bake.hcl b/services/pcp-tgu-ui-service/docker-bake.hcl new file mode 100644 index 0000000..b55a28d --- /dev/null +++ b/services/pcp-tgu-ui-service/docker-bake.hcl @@ -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"] +} diff --git a/services/pcp-tgu-ui-service/docker-entrypoint.d/20-nstart-runtime-config.sh b/services/pcp-tgu-ui-service/docker-entrypoint.d/20-nstart-runtime-config.sh new file mode 100644 index 0000000..4d03d42 --- /dev/null +++ b/services/pcp-tgu-ui-service/docker-entrypoint.d/20-nstart-runtime-config.sh @@ -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 < { 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); +} diff --git a/services/pcp-tgu-ui-service/e2e-verify/keep-alive.mjs b/services/pcp-tgu-ui-service/e2e-verify/keep-alive.mjs new file mode 100644 index 0000000..49fd206 --- /dev/null +++ b/services/pcp-tgu-ui-service/e2e-verify/keep-alive.mjs @@ -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 присутствуют сразу несколько
+ await tab("Планы").click(); + await p.waitForTimeout(200); + const mains = await p.locator(".tgu-app-shell main").count(); + mains >= 3 + ? pass("keep-alive:tabs-mounted", `
в 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); +} diff --git a/services/pcp-tgu-ui-service/e2e-verify/requests.mjs b/services/pcp-tgu-ui-service/e2e-verify/requests.mjs new file mode 100644 index 0000000..2652dd8 --- /dev/null +++ b/services/pcp-tgu-ui-service/e2e-verify/requests.mjs @@ -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); +} diff --git a/services/pcp-tgu-ui-service/index.html b/services/pcp-tgu-ui-service/index.html new file mode 100644 index 0000000..140b521 --- /dev/null +++ b/services/pcp-tgu-ui-service/index.html @@ -0,0 +1,16 @@ + + + + + + ТГУ — Операции + + +
+ + + + + diff --git a/services/pcp-tgu-ui-service/nginx.conf b/services/pcp-tgu-ui-service/nginx.conf new file mode 100644 index 0000000..78b61f1 --- /dev/null +++ b/services/pcp-tgu-ui-service/nginx.conf @@ -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; + } +} diff --git a/services/pcp-tgu-ui-service/package-lock.json b/services/pcp-tgu-ui-service/package-lock.json new file mode 100644 index 0000000..02627dd --- /dev/null +++ b/services/pcp-tgu-ui-service/package-lock.json @@ -0,0 +1,2438 @@ +{ + "name": "pcp-tgu-ui-service", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "pcp-tgu-ui-service", + "version": "0.1.0", + "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" + }, + "engines": { + "node": "^24.0.0 || ^22.0.0" + } + }, + "node_modules/@acemir/cssom": { + "version": "0.9.31", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", + "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", + "dev": true + }, + "node_modules/@asamuzakjp/css-color": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.2.tgz", + "integrity": "sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==", + "dev": true, + "dependencies": { + "@csstools/css-calc": "^3.0.0", + "@csstools/css-color-parser": "^4.0.1", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.2.5" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.8.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", + "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", + "dev": true, + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.6" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.1.tgz", + "integrity": "sha512-eZ5XOtyhK+mggRafYUWzA0tvaYOFgdY8AkgQiCJF9qNAePnUo/zmsqqYubBBb3sQ8uNUaSKTY9s9klfRaAXL0g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.2.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.5.tgz", + "integrity": "sha512-oNjBvzLq2GPZtJphCjLqXow/cHySHSgtxvKZb7OqSZ/xHgw6NWNhfad+6AB9cLeVm6eA9d/qMll3JdEHjy6M+A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/react": { + "version": "0.27.19", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.19.tgz", + "integrity": "sha512-31B8h5mm8YxotlE7/AU/PhNAl8eWxAmjL/v2QOxroDNkTFLk3Uu82u63N3b6TXa4EGJeeZLVcd/9AlNlVqzeog==", + "dependencies": { + "@floating-ui/react-dom": "^2.1.8", + "@floating-ui/utils": "^0.2.11", + "tabbable": "^6.0.0" + }, + "peerDependencies": { + "react": ">=17.0.0", + "react-dom": ">=17.0.0" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", + "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", + "dependencies": { + "@floating-ui/dom": "^1.7.6" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==" + }, + "node_modules/@formkit/auto-animate": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/@formkit/auto-animate/-/auto-animate-0.8.4.tgz", + "integrity": "sha512-DHHC01EJ1p70Q0z/ZFRBIY8NDnmfKccQoyoM84Tgb6omLMat6jivCdf272Y8k3nf4Lzdin/Y4R9q8uFtU0GbnA==" + }, + "node_modules/@headless-tree/core": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@headless-tree/core/-/core-1.7.0.tgz", + "integrity": "sha512-LxcX7LNepwfOPrZcs4PNfDwCzbi326uCAX5jYBfw94jI+pZQA7ANaE/No3LW0XFgcBcQtK/G2bInbVEhLF1C/Q==", + "funding": { + "url": "https://github.com/sponsors/lukasbach" + } + }, + "node_modules/@headless-tree/react": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@headless-tree/react/-/react-1.7.0.tgz", + "integrity": "sha512-hcnG4mpTP98KVWZYqKsXNOoiv7ZQqZVM2jWwzEWtF+VDSz+1O/juMI+/Q0G4lZM+ez07/tmBBWjvZub/mLAUXQ==", + "funding": { + "url": "https://github.com/sponsors/lukasbach" + }, + "peerDependencies": { + "@headless-tree/core": "*", + "react": "*", + "react-dom": "*" + } + }, + "node_modules/@hookform/resolvers": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.4.0.tgz", + "integrity": "sha512-EIsqr/t/qbinPIhGjMdtvutIN1Kk4uwbROE9/UQ93CAVGR7GkA7Y92+fX80OzXi/OB67jVFYwKGO1WzkxmkFZw==", + "dependencies": { + "@standard-schema/utils": "^0.3.0" + }, + "peerDependencies": { + "react-hook-form": "^7.55.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@nstart/auth": { + "version": "0.3.3", + "resolved": "https://repo.nstart.cloud/repository/npm-group/@nstart/auth/-/auth-0.3.3.tgz", + "integrity": "sha512-/58dL7mAXfO/FFEsM4kIJb3v7bOR8BX+oQVTnC083f6XlLJVSyFaERyV/MKsPlyAZp9ngLhScjUxg/ir9G03Ng==", + "license": "UNLICENSED", + "dependencies": { + "keycloak-js": "^26.2.3" + }, + "engines": { + "node": "^24.0.0 || ^22.0.0" + }, + "peerDependencies": { + "@hookform/resolvers": "^3.0.0 || ^5.0.0", + "@nstart/ui": "^0.3.0 || ^0.4.0", + "@phosphor-icons/react": "^2.0.0", + "@tanstack/react-query": "^5.0.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-hook-form": "^7.0.0", + "react-i18next": "^17.0.2", + "vite": "^8.0.0", + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "@hookform/resolvers": { + "optional": true + }, + "@nstart/ui": { + "optional": true + }, + "@phosphor-icons/react": { + "optional": true + }, + "@tanstack/react-query": { + "optional": true + }, + "react-hook-form": { + "optional": true + }, + "react-i18next": { + "optional": true + }, + "vite": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/@nstart/ui": { + "version": "0.4.1", + "resolved": "https://repo.nstart.cloud/repository/npm-group/@nstart/ui/-/ui-0.4.1.tgz", + "integrity": "sha512-T+Gdrrm7HGgx93ghZ3a9p0kVh+RNz2rAbzHyle97FRs7CtmM58uiRu82+Uc2SnSGuzgngnsBj6md7E0bHYJhHQ==", + "license": "UNLICENSED", + "dependencies": { + "@floating-ui/react": "^0.27.19", + "@formkit/auto-animate": "^0.8.4", + "@headless-tree/core": "^1.7.0", + "@headless-tree/react": "^1.7.0", + "@phosphor-icons/react": "^2.1.10", + "@tanstack/react-table": "^8.21.3", + "@tanstack/react-virtual": "^3.14.2", + "clsx": "^2.1.1", + "date-fns": "^4.1.0", + "echarts": "^6.0.0", + "lottie-react": "^2.4.1", + "react-resizable-panels": "^4.11.2", + "tailwind-merge": "^3.5.0" + }, + "engines": { + "node": "^24.0.0 || ^22.0.0" + }, + "peerDependencies": { + "i18next": "^26.0.3", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-i18next": "^17.0.2", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/@oxc-project/types": { + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", + "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", + "devOptional": true, + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@phosphor-icons/react": { + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/@phosphor-icons/react/-/react-2.1.10.tgz", + "integrity": "sha512-vt8Tvq8GLjheAZZYa+YG/pW7HDbov8El/MANW8pOAz4eGxrwhnbfrQZq0Cp4q8zBEu8NIhHdnr+r8thnfRSNYA==", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">= 16.8", + "react-dom": ">= 16.8" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", + "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", + "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", + "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", + "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", + "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", + "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", + "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", + "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", + "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", + "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", + "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", + "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", + "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", + "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", + "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "devOptional": true + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==" + }, + "node_modules/@tanstack/query-core": { + "version": "5.101.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.0.tgz", + "integrity": "sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.101.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.0.tgz", + "integrity": "sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg==", + "dependencies": { + "@tanstack/query-core": "5.101.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@tanstack/react-table": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz", + "integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==", + "dependencies": { + "@tanstack/table-core": "8.21.3" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/@tanstack/react-virtual": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.2.tgz", + "integrity": "sha512-IpWnmCLvuymRfeeLNVXIzNEYBFLpd3drVIS91sqV78VTZFyldlChkOocZRCPp1B+Wnk09bcLNme8WaMU/9/9bQ==", + "dependencies": { + "@tanstack/virtual-core": "3.17.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/table-core": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz", + "integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.17.0", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.0.tgz", + "integrity": "sha512-gOxY/hFkPh/XQYhnThBHzkbkX3Ed+z/iushyz+R+JAr213aXxUDgQoTgTdrDpBSRsjFM73P/KfUyWmaF9WHMkQ==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true + }, + "node_modules/@types/node": { + "version": "22.19.20", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.20.tgz", + "integrity": "sha512-6tELRwSDYWW9EdZhbeZmYGZ1/7Djkt+Ah3/ScEYT9cDord7UJzasR/4D3VONg9tQI5CDp+/CZC1AXj2pCFOvpw==", + "dev": true, + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "dev": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.2.tgz", + "integrity": "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==", + "dev": true, + "dependencies": { + "@rolldown/pluginutils": "^1.0.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz", + "integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==", + "dev": true, + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.8", + "@vitest/utils": "4.1.8", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz", + "integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==", + "dev": true, + "dependencies": { + "@vitest/spy": "4.1.8", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz", + "integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==", + "dev": true, + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz", + "integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==", + "dev": true, + "dependencies": { + "@vitest/utils": "4.1.8", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz", + "integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==", + "dev": true, + "dependencies": { + "@vitest/pretty-format": "4.1.8", + "@vitest/utils": "4.1.8", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz", + "integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==", + "dev": true, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz", + "integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==", + "dev": true, + "dependencies": { + "@vitest/pretty-format": "4.1.8", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "engines": { + "node": ">= 14" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "engines": { + "node": ">=6" + } + }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/cssstyle": { + "version": "5.3.7", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.7.tgz", + "integrity": "sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==", + "dev": true, + "dependencies": { + "@asamuzakjp/css-color": "^4.1.1", + "@csstools/css-syntax-patches-for-csstree": "^1.0.21", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/data-urls": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.1.tgz", + "integrity": "sha512-euIQENZg6x8mj3fO6o9+fOW8MimUI4PpD/fZBhJfeioZVy9TUpM4UY7KjQNVZFlqwJ0UdzRDzkycB997HEq1BQ==", + "dev": true, + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^15.1.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "engines": { + "node": ">=20" + } + }, + "node_modules/date-fns": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz", + "integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "devOptional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/echarts": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/echarts/-/echarts-6.1.0.tgz", + "integrity": "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==", + "dependencies": { + "tslib": "2.3.0", + "zrender": "6.1.0" + } + }, + "node_modules/echarts/node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==" + }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "devOptional": true, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "dependencies": { + "void-elements": "3.1.0" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/i18next": { + "version": "26.3.1", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.1.tgz", + "integrity": "sha512-txQqd5EULsqEh9OJqRH15aCaOuy/nLJyhw5EHCSKLKJE1aBbb3Zve2+uQIxgWhPm1QqUQoWyQBm2kfmmIrzkcQ==", + "funding": [ + { + "type": "individual", + "url": "https://www.locize.com/i18next" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + }, + { + "type": "individual", + "url": "https://www.locize.com" + } + ], + "peer": true, + "peerDependencies": { + "typescript": "^5 || ^6" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "engines": { + "node": ">=12" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true + }, + "node_modules/jsdom": { + "version": "27.4.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.4.0.tgz", + "integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==", + "dev": true, + "dependencies": { + "@acemir/cssom": "^0.9.28", + "@asamuzakjp/dom-selector": "^6.7.6", + "@exodus/bytes": "^1.6.0", + "cssstyle": "^5.3.4", + "data-urls": "^6.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.0", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^15.1.0", + "ws": "^8.18.3", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/keycloak-js": { + "version": "26.2.4", + "resolved": "https://registry.npmjs.org/keycloak-js/-/keycloak-js-26.2.4.tgz", + "integrity": "sha512-PnXpR3ubETGOt0B/Qt2lxmPbkZr5bc3vlQsOqDoTPPQsZRp7JjhTKxlJ187uWh8qJhvBab6Gsjb06a8ayOPfuw==" + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "devOptional": true, + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lottie-react": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/lottie-react/-/lottie-react-2.4.1.tgz", + "integrity": "sha512-LQrH7jlkigIIv++wIyrOYFLHSKQpEY4zehPicL9bQsrt1rnoKRYCYgpCUe5maqylNtacy58/sQDZTkwMcTRxZw==", + "dependencies": { + "lottie-web": "^5.10.2" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/lottie-web": { + "version": "5.13.0", + "resolved": "https://registry.npmjs.org/lottie-web/-/lottie-web-5.13.0.tgz", + "integrity": "sha512-+gfBXl6sxXMPe8tKQm7qzLnUy5DUPJPKIyRHwtpCpyUEYjHYRJC/5gjUvdkuO2c3JllrPtHXH5UJJK8LRYl5yQ==" + }, + "node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "dev": true, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "devOptional": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.2.tgz", + "integrity": "sha512-AWGB9WFcRXOQs48Z/udjI5ZcZMHXwX8XPByNpOydgcGsDLIzjGizhoMWJyKAWze7AVW/2W1i+/gPX4YtKe5cyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "devOptional": true + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "devOptional": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "devOptional": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, + "node_modules/react-hook-form": { + "version": "7.78.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.78.0.tgz", + "integrity": "sha512-EEZqc+N23moyzTlz61Pj+JvcXo76ICkpfOZo8JZw+sM4+wLQGh6nI2Ms+PdMOYNluFu0ghlM7B8mCzhRYtJCnA==", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, + "node_modules/react-i18next": { + "version": "17.0.8", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.8.tgz", + "integrity": "sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==", + "dependencies": { + "@babel/runtime": "^7.29.2", + "html-parse-stringify": "^3.0.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "i18next": ">= 26.2.0", + "react": ">= 16.8.0", + "typescript": "^5 || ^6" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/react-resizable-panels": { + "version": "4.11.2", + "resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-4.11.2.tgz", + "integrity": "sha512-+kfFbDZ8mygc7g0vxOcDzCVGuwiIUOnILqPoUHo6/uP+Mmyx6HzZU+kj1aOPDlktXuobYbr6BtQekvJwHRX4Eg==", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rolldown": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", + "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", + "devOptional": true, + "dependencies": { + "@oxc-project/types": "=0.133.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.3", + "@rolldown/binding-darwin-arm64": "1.0.3", + "@rolldown/binding-darwin-x64": "1.0.3", + "@rolldown/binding-freebsd-x64": "1.0.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", + "@rolldown/binding-linux-arm64-gnu": "1.0.3", + "@rolldown/binding-linux-arm64-musl": "1.0.3", + "@rolldown/binding-linux-ppc64-gnu": "1.0.3", + "@rolldown/binding-linux-s390x-gnu": "1.0.3", + "@rolldown/binding-linux-x64-gnu": "1.0.3", + "@rolldown/binding-linux-x64-musl": "1.0.3", + "@rolldown/binding-openharmony-arm64": "1.0.3", + "@rolldown/binding-wasm32-wasi": "1.0.3", + "@rolldown/binding-win32-arm64-msvc": "1.0.3", + "@rolldown/binding-win32-x64-msvc": "1.0.3" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==" + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "devOptional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true + }, + "node_modules/tabbable": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", + "integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==" + }, + "node_modules/tailwind-merge": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", + "integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "devOptional": true, + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.2.tgz", + "integrity": "sha512-kCwffuaH8ntKtygnWe1b4BJKWiCUH30n5KfoTr6IchcXOwR7chAOFJxFrH3vjANafUYrIA4a7SDL+nn7SiR4Sw==", + "dev": true, + "dependencies": { + "tldts-core": "^7.4.2" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.2.tgz", + "integrity": "sha512-nwEyF4vl4RSJjwSjBUmOSxc3BFPoIFdlRthJ6e+5v9P3bHNsoD06UjuqMUspqp7vsEZ1beaHi1km+optiE17yA==", + "dev": true + }, + "node_modules/topojson-client": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/topojson-client/-/topojson-client-3.1.0.tgz", + "integrity": "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==", + "dependencies": { + "commander": "2" + }, + "bin": { + "topo2geo": "bin/topo2geo", + "topomerge": "bin/topomerge", + "topoquantize": "bin/topoquantize" + } + }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "optional": true + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "devOptional": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/vite": { + "version": "8.0.16", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", + "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", + "devOptional": true, + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "1.0.3", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/vitest": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz", + "integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==", + "dev": true, + "dependencies": { + "@vitest/expect": "4.1.8", + "@vitest/mocker": "4.1.8", + "@vitest/pretty-format": "4.1.8", + "@vitest/runner": "4.1.8", + "@vitest/snapshot": "4.1.8", + "@vitest/spy": "4.1.8", + "@vitest/utils": "4.1.8", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.8", + "@vitest/browser-preview": "4.1.8", + "@vitest/browser-webdriverio": "4.1.8", + "@vitest/coverage-istanbul": "4.1.8", + "@vitest/coverage-v8": "4.1.8", + "@vitest/ui": "4.1.8", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz", + "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==", + "dev": true, + "dependencies": { + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zrender": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/zrender/-/zrender-6.1.0.tgz", + "integrity": "sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==", + "dependencies": { + "tslib": "2.3.0" + } + }, + "node_modules/zrender/node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==" + } + } +} diff --git a/services/pcp-tgu-ui-service/package.json b/services/pcp-tgu-ui-service/package.json new file mode 100644 index 0000000..ce1d2d1 --- /dev/null +++ b/services/pcp-tgu-ui-service/package.json @@ -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" + } +} diff --git a/services/pcp-tgu-ui-service/public/config.js b/services/pcp-tgu-ui-service/public/config.js new file mode 100644 index 0000000..90ac439 --- /dev/null +++ b/services/pcp-tgu-ui-service/public/config.js @@ -0,0 +1 @@ +window.__NSTART_APP_ENV__ = {} diff --git a/services/pcp-tgu-ui-service/src/App.tsx b/services/pcp-tgu-ui-service/src/App.tsx new file mode 100644 index 0000000..75e2831 --- /dev/null +++ b/services/pcp-tgu-ui-service/src/App.tsx @@ -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 ( +
+
+

Ошибка авторизации

+

{description}

+ {errorDetails ? ( +

+ Техническая причина: {errorDetails} +

+ ) : null} +
+
+ ) +} + +/** + * Кнопка выхода из New Start ID. Показывается только в режиме new-start-id, + * не мешает полноэкранному UI ТГУ (фиксированный угол). + * + * @returns React-элемент кнопки выхода. + */ +function LogoutButton() { + const { logout } = useAppAuth() + return ( + + ) +} + +/** + * Корень приложения: гейт авторизации + полноэкранная рабочая страница ТГУ. + * + * @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 + } + + return ( + <> + {isNewStartIdMode ? : null} + + + ) +} diff --git a/services/pcp-tgu-ui-service/src/api/apiFetch.ts b/services/pcp-tgu-ui-service/src/api/apiFetch.ts new file mode 100644 index 0000000..42ddfa1 --- /dev/null +++ b/services/pcp-tgu-ui-service/src/api/apiFetch.ts @@ -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 { + 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 }) +} diff --git a/services/pcp-tgu-ui-service/src/api/ballisticsApi.ts b/services/pcp-tgu-ui-service/src/api/ballisticsApi.ts new file mode 100644 index 0000000..32cb524 --- /dev/null +++ b/services/pcp-tgu-ui-service/src/api/ballisticsApi.ts @@ -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 { + 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 { + 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 + 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 +} diff --git a/services/pcp-tgu-ui-service/src/api/complexPlanApi.ts b/services/pcp-tgu-ui-service/src/api/complexPlanApi.ts new file mode 100644 index 0000000..ccc655d --- /dev/null +++ b/services/pcp-tgu-ui-service/src/api/complexPlanApi.ts @@ -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 { + 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 { + 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 { + 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 + 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 +} diff --git a/services/pcp-tgu-ui-service/src/api/missionPlanningApi.ts b/services/pcp-tgu-ui-service/src/api/missionPlanningApi.ts new file mode 100644 index 0000000..7f44af0 --- /dev/null +++ b/services/pcp-tgu-ui-service/src/api/missionPlanningApi.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 + 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 +} diff --git a/services/pcp-tgu-ui-service/src/api/requestApi.ts b/services/pcp-tgu-ui-service/src/api/requestApi.ts new file mode 100644 index 0000000..1f51fb7 --- /dev/null +++ b/services/pcp-tgu-ui-service/src/api/requestApi.ts @@ -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 { + 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 +} + +/** + * Загружает заявки для карты постранично (до 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 { + 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 { + 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 +} + +/** + * Удаляет заявку по идентификатору (pcp-request-service, DELETE /requests/{id}). + * @param id Идентификатор заявки. + */ +export async function deleteRequest(id: string): Promise { + const response = await apiFetch(`${BASE}/requests/${encodeURIComponent(id)}`, { + method: 'DELETE', + headers: { Accept: 'application/json' }, + }) + + if (!response.ok) { + throw new Error(await readErrorMessage(response, 'Не удалось удалить заявку')) + } +} diff --git a/services/pcp-tgu-ui-service/src/api/stationsApi.ts b/services/pcp-tgu-ui-service/src/api/stationsApi.ts new file mode 100644 index 0000000..2407858 --- /dev/null +++ b/services/pcp-tgu-ui-service/src/api/stationsApi.ts @@ -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 { + const response = await apiFetch('/api/stations', { + headers: { Accept: 'application/json' }, + }) + if (!response.ok) { + throw new Error(`Ошибка загрузки станций: ${response.status}`) + } + return response.json() as Promise +} diff --git a/services/pcp-tgu-ui-service/src/api/tguApi.ts b/services/pcp-tgu-ui-service/src/api/tguApi.ts new file mode 100644 index 0000000..8579549 --- /dev/null +++ b/services/pcp-tgu-ui-service/src/api/tguApi.ts @@ -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 { + const query = new URLSearchParams({ + time_start: toLocalIso(fromMs), + time_stop: toLocalIso(toMs), + }) + return fetchJson( + `/api/tgu-planning/spacecraft/${encodeURIComponent(noradId)}/flight-line?${query}`, + ) +} + +/** + * Список платформ (КА) ТГУ. + * @returns Платформы планирования ТГУ. + */ +export async function fetchPlatforms(): Promise { + return fetchJson('/api/tgu-planning/platforms') +} + +/** + * Планы ТГУ с заданной глубиной истории, опционально по одному КА. + * @param historyDays Глубина истории в сутках. + * @param spacecraftId Идентификатор КА для фильтра (необязателен — иначе по всем КА). + * + * @returns Планы ТГУ. + */ +export async function fetchPlans(historyDays: number, spacecraftId?: string): Promise { + 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(path) +} + +/** + * Отправляет решение оператора по плану (принять/отклонить и т.п.). + * @param planId Идентификатор плана. + * @param decision Решение по плану. + * @param reason Причина решения (необязательна). + * + * @returns Сообщение-результат обработки решения. + */ +export async function sendPlanDecision( + planId: string, + decision: TguPlanDecision, + reason?: string, +): Promise { + const query = new URLSearchParams({ decision }) + if (reason?.trim()) { + query.set('reason', reason.trim()) + } + + return fetchJson( + `/api/tgu-planning/plans/${encodeURIComponent(planId)}/decision?${query}`, + { method: 'POST' }, + true, + ) +} + +/** + * «Снять с учёта» терминально-негативный план: он перестаёт считаться проблемой и красить + * индикатор КА. Статус и цепочку не трогает; обратимо ([restorePlan]). 422 — статус не снимаемый. + * @param planId Идентификатор плана. + */ +export async function dismissPlan(planId: string): Promise { + await fetchJson(`/api/tgu-planning/plans/${encodeURIComponent(planId)}/dismiss`, { + method: 'POST', + }) +} + +/** + * «Вернуть на учёт» ранее снятый план. + * @param planId Идентификатор плана. + */ +export async function restorePlan(planId: string): Promise { + await fetchJson(`/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 { + await fetchJson(`/api/tgu-planning/plans/${encodeURIComponent(planId)}/confirm-layin`, { + method: 'POST', + }) +} + +async function fetchJson(url: string, init?: RequestInit, decisionRequest = false): Promise { + 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 + 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 +} diff --git a/services/pcp-tgu-ui-service/src/api/tguPlanApi.ts b/services/pcp-tgu-ui-service/src/api/tguPlanApi.ts new file mode 100644 index 0000000..f6a97ac --- /dev/null +++ b/services/pcp-tgu-ui-service/src/api/tguPlanApi.ts @@ -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 { + 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 { + 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 { + 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 { + return postJson( + `${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 { + return postJson( + `${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 { + return postJson( + `${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 { + 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(url: string, body: unknown, fallbackMessage: string): Promise { + 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 + 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 +} diff --git a/services/pcp-tgu-ui-service/src/auth/appAuth.tsx b/services/pcp-tgu-ui-service/src/auth/appAuth.tsx new file mode 100644 index 0000000..8aa84e7 --- /dev/null +++ b/services/pcp-tgu-ui-service/src/auth/appAuth.tsx @@ -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(ANONYMOUS_STATE) + +function AnonymousAuthProvider({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} + +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 +} diff --git a/services/pcp-tgu-ui-service/src/components/timeFields.test.ts b/services/pcp-tgu-ui-service/src/components/timeFields.test.ts new file mode 100644 index 0000000..c597537 --- /dev/null +++ b/services/pcp-tgu-ui-service/src/components/timeFields.test.ts @@ -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(""); + }); +}); diff --git a/services/pcp-tgu-ui-service/src/components/timeFields.tsx b/services/pcp-tgu-ui-service/src/components/timeFields.tsx new file mode 100644 index 0000000..916e58c --- /dev/null +++ b/services/pcp-tgu-ui-service/src/components/timeFields.tsx @@ -0,0 +1,135 @@ +import { useEffect, useState, type CSSProperties } from "react"; + +/** + * Ввод времени суток в строгом 24-часовом формате `ЧЧ:ММ[:СС]` (локальное). + * + * Нативные ``/`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 ( + 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); // невалидный ввод — откат к последнему корректному + }} + /> + ); +} + +/** + * Замена нативного ``: нативный календарь для даты + * (у даты нет проблемы 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 ( + + onChange(combine(event.target.value, timePart))} + /> + onChange(combine(datePart, time))} + /> + + ); +} diff --git a/services/pcp-tgu-ui-service/src/config/env.ts b/services/pcp-tgu-ui-service/src/config/env.ts new file mode 100644 index 0000000..9f2d111 --- /dev/null +++ b/services/pcp-tgu-ui-service/src/config/env.ts @@ -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() diff --git a/services/pcp-tgu-ui-service/src/features/constellation/ConstellationDashboard.jsx b/services/pcp-tgu-ui-service/src/features/constellation/ConstellationDashboard.jsx new file mode 100644 index 0000000..97701f8 --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/constellation/ConstellationDashboard.jsx @@ -0,0 +1,226 @@ +/* ============================================================ + ConstellationDashboard.jsx — wiring, RAF clock, memory sim (ESM) + Default export: — монтируется как вкладка. + ============================================================ */ +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 ( +
+
+
+ +
+
+ +
+ {focusSat ? ( + setFocus(null)} /> + ) : ( +
КЛИК ПО АППАРАТУ — ФОКУС И ПОДСВЕТКА СОБЫТИЙ
+ )} +
+ +
+ {cursor ? `${Math.abs(cursor.lat).toFixed(3)}° ${cursor.lat >= 0 ? "с.ш." : "ю.ш."}, ${Math.abs(cursor.lon).toFixed(3)}° ${cursor.lon >= 0 ? "в.д." : "з.д."}` : "—"} +
+ {hover && hover.id !== focusId && ( +
+ {env.sats.find((s) => s.id === hover.id)?.name} +
+ )} +
+
+
+ ТАЙМЛАЙН СОБЫТИЙ +
+ + + + + +
+ {live ? "РЕЖИМ LIVE · ×1" : "СИМУЛЯЦИЯ ×" + speed} · перетащите для скраба +
+ +
+
+
+
+ ); +} + +function FocusCard({ sat, status, mem, onClose }) { + const meta = STATUS_META[status.state]; + return ( +
+ +
+
{sat.name}
+
+ NORAD {sat.norad}·{sat.group} + ·{status.eclipse ? "☾ " : ""}{meta.t} +
+
+
+
{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"}
+
ПАМЯТЬ {Math.round(mem)}%
+
+
+ ); +} + +function MapLegend() { + return ( +
+
СТАНЦИЯ ПРИЁМА
+
ОПТИКА
+
ЛОКАЦИЯ
+
МАРШРУТ СЪЁМКИ
+
СБРОС НА СТАНЦИЮ
+
СВЯЗЬ С КА
+
ТЕНЬ ЗЕМЛИ
+
+ ); +} +function LegItem({ c, t, hatch }) { + return {t}; +} + +export default App; diff --git a/services/pcp-tgu-ui-service/src/features/constellation/ConstellationTab.tsx b/services/pcp-tgu-ui-service/src/features/constellation/ConstellationTab.tsx new file mode 100644 index 0000000..8e26e85 --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/constellation/ConstellationTab.tsx @@ -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; + +type LoadState = + | { kind: "loading" } + | { kind: "error"; message: string } + | { kind: "ready"; cfg: ConstellationConfig }; + +export function ConstellationTab() { + const [state, setState] = useState({ 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 ; + } + if (state.kind === "error") { + return ; + } + if (state.cfg.env.sats.length === 0) { + return ; + } + return ; +} + +function ConstellationNotice({ text, tone }: { text: string; tone?: "error" }) { + return ( +
+ {text} +
+ ); +} + +export default ConstellationTab; diff --git a/services/pcp-tgu-ui-service/src/features/constellation/MapView.jsx b/services/pcp-tgu-ui-service/src/features/constellation/MapView.jsx new file mode 100644 index 0000000..e8952ef --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/constellation/MapView.jsx @@ -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 ( +
+ apiRef.current.onCursor && apiRef.current.onCursor(null)} onClick={onClick} /> +
+ ); +} + +// 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; diff --git a/services/pcp-tgu-ui-service/src/features/constellation/Panels.jsx b/services/pcp-tgu-ui-service/src/features/constellation/Panels.jsx new file mode 100644 index 0000000..b1e86ea --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/constellation/Panels.jsx @@ -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 ( +
+
+ +
+
СОСТОЯНИЕ ГРУППИРОВКИ
+
MISSION OPS · ТГУ
+
+
+ +
+ + + +
+
+ {speeds.map((s) => ( + + ))} +
+ +
+ +
+
+ {live ? "LIVE" : "СИМ"} +
+
+ {ck.time} + местн. +
+
{ck.dow} · {ck.date}
+
+
+ ); +} + +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 ( +
+
+ {tiles.map((t) => ( +
+
{t.n}
+
{t.label}
+
+ ))} +
+
+ ОПТИКА {counts.optic} + ЛОКАЦИЯ {counts.radar} + {counts.total} КА +
+
+ ); +} + +function SatRow({ sat, status, mem, focus, onClick }) { + const meta = STATUS_META[status.state]; + const memColor = mem > 92 ? "#d9685f" : mem > 78 ? "#d99a4e" : "#5fb3a3"; + return ( +
+ +
+
+ {sat.name} + + {status.eclipse && }{meta.t} + +
+
+ NORAD {sat.norad} +
+ {Math.round(mem)}% +
+
+
+ ); +} + +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 ( +
+ {groups.map((g, i) => { + if (g.type === "domain") return
{g.label}
; + if (g.type === "group") return
{g.label}
; + const s = g.sat; + return onFocus(focusId === s.id ? null : s.id)} />; + })} +
+ ); +} + +function ActivityFeed({ events, onFocus, focusId }) { + return ( +
+
АКТИВНЫЕ СОБЫТИЯ {events.length}
+
+ {events.length === 0 &&
нет активных событий
} + {events.map((e) => ( +
onFocus(e.satId)}> + +
+
{e.type}{e.target}
+
{e.sat}ещё {e.remain}
+
+
+ ))} +
+
+ ); +} + +function SidePanel({ sats, statuses, memory, counts, events, focusId, onFocus, query, setQuery }) { + return ( + + ); +} + +export { Header, SidePanel, STATUS_META, fmtDur }; diff --git a/services/pcp-tgu-ui-service/src/features/constellation/Timeline.jsx b/services/pcp-tgu-ui-service/src/features/constellation/Timeline.jsx new file mode 100644 index 0000000..cbb6ba6 --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/constellation/Timeline.jsx @@ -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 ( +
+ + + + + + + + + {/* axis */} + + {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 ( + + + + {isDay ? `${String(d.getDate()).padStart(2, "0")}.${String(d.getMonth() + 1).padStart(2, "0")}` : `${hh}:00`} + + + ); + })} + + {/* rows */} + {rows.map((r, i) => { + if (r.type === "group") { + return {r.label}; + } + 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 ; + }; + return ( + onFocus(focusId === s.id ? null : s.id)} style={{ cursor: "pointer" }}> + + + + {s.name} + {/* baseline */} + + {/* 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 ; + })} + + ); + })} + + {/* now marker (real wall clock) */} + {nowX > TL_GUTTER && nowX < w && ( + + + СЕЙЧАС + + )} + {/* playhead */} + + + + + +
+ ); +} + +export default Timeline; diff --git a/services/pcp-tgu-ui-service/src/features/constellation/assets/land-110m.json b/services/pcp-tgu-ui-service/src/features/constellation/assets/land-110m.json new file mode 100644 index 0000000..fbd897c --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/constellation/assets/land-110m.json @@ -0,0 +1 @@ +{"type":"Topology","objects":{"land":{"type":"GeometryCollection","geometries":[{"type":"MultiPolygon","arcs":[[[0]],[[1]],[[2]],[[3]],[[4]],[[5]],[[6]],[[7]],[[8]],[[9]],[[10]],[[11]],[[12]],[[13]],[[14]],[[15]],[[16,18]],[[19]],[[20]],[[21]],[[22]],[[23]],[[24]],[[25]],[[26]],[[27]],[[28]],[[29]],[[30]],[[31]],[[32]],[[33]],[[34]],[[35]],[[36]],[[37]],[[38]],[[39]],[[40]],[[41]],[[42]],[[43]],[[44]],[[45]],[[46]],[[47]],[[48]],[[49]],[[50]],[[51]],[[52]],[[53]],[[54]],[[55]],[[56]],[[57]],[[58]],[[59]],[[60]],[[61]],[[62]],[[63]],[[64]],[[65]],[[66]],[[67]],[[68]],[[69]],[[70]],[[71]],[[72]],[[73]],[[74]],[[75]],[[76]],[[77]],[[78]],[[79]],[[80]],[[81]],[[82]],[[83]],[[84]],[[85]],[[86]],[[87]],[[88]],[[89]],[[90]],[[91]],[[92,115],[114]],[[94]],[[95]],[[96]],[[97]],[[98]],[[99]],[[100]],[[101]],[[102]],[[103]],[[104]],[[105]],[[106]],[[107]],[[108]],[[109]],[[110]],[[111]],[[112]],[[113]],[[116]],[[117]],[[118]],[[119]],[[120]],[[121]],[[122]],[[123]],[[124]],[[125]],[[126]],[[127]],[[128]],[[129]]]}]}},"arcs":[[[33452,3290],[-82,-301],[-81,-266],[-582,81],[-621,-35],[-348,197],[0,23],[-152,174],[625,-23],[599,-58],[207,243],[147,208],[288,-243]],[[5775,3611],[-533,-81],[-364,208],[-163,209],[-11,35],[-180,162],[169,220],[517,-93],[277,-185],[212,-209],[76,-266]],[[37457,4468],[342,-255],[120,-359],[33,-254],[11,-301],[-430,-186],[-452,-150],[-522,-139],[-582,-116],[-658,35],[-365,197],[49,243],[593,162],[239,197],[174,254],[126,220],[168,209],[180,243],[141,0],[414,127],[419,-127]],[[16330,7154],[359,-93],[332,104],[-158,-208],[-261,-151],[-386,47],[-278,208],[60,197],[332,-104]],[[15122,7165],[425,-231],[-164,23],[-359,58],[-381,162],[202,127],[277,-139]],[[22505,8080],[305,-81],[304,69],[163,-335],[-217,46],[-337,-23],[-343,23],[-376,-35],[-283,116],[-146,243],[174,104],[353,-81],[403,-46]],[[30985,8657],[33,-266],[-49,-231],[-76,-220],[-326,-81],[-311,-116],[-364,11],[136,232],[-327,-81],[-310,-81],[-212,174],[-16,243],[305,231],[190,70],[321,-23],[82,301],[16,219],[-6,475],[158,278],[256,93],[147,-220],[65,-220],[120,-267],[92,-254],[76,-267]],[[0,529],[16,-5],[245,344],[501,-185],[32,21],[78,49],[94,61],[81,52],[41,26],[41,-1],[29,-10],[402,-246],[352,246],[63,34],[816,104],[265,-138],[130,-71],[419,-196],[789,-151],[625,-185],[1072,-139],[800,162],[1181,-116],[669,-185],[734,174],[773,162],[60,278],[-1094,23],[-898,139],[-234,231],[-745,128],[49,266],[103,243],[104,220],[-55,243],[-462,162],[-212,209],[-430,185],[675,-35],[642,93],[402,-197],[495,173],[457,220],[223,197],[-98,243],[-359,162],[-408,174],[-571,35],[-500,81],[-539,58],[-180,220],[-359,185],[-217,208],[-87,672],[136,-58],[250,-185],[457,58],[441,81],[228,-255],[441,58],[370,127],[348,162],[315,197],[419,58],[-11,220],[-97,220],[81,208],[359,104],[163,-196],[425,115],[321,151],[397,12],[375,57],[376,139],[299,128],[337,127],[218,-35],[190,-46],[414,81],[370,-104],[381,11],[364,81],[375,-57],[414,-58],[386,23],[403,-12],[413,-11],[381,23],[283,174],[337,92],[349,-127],[331,104],[300,208],[179,-185],[98,-208],[180,-197],[288,174],[332,-220],[375,-70],[321,-162],[392,35],[354,104],[418,-23],[376,-81],[381,-104],[147,254],[-180,197],[-136,209],[-359,46],[-158,220],[-60,220],[-98,440],[213,-81],[364,-35],[359,35],[327,-93],[283,-174],[119,-208],[376,-35],[359,81],[381,116],[342,70],[283,-139],[370,46],[239,451],[224,-266],[321,-104],[348,58],[228,-232],[365,-23],[337,-69],[332,-128],[218,220],[108,209],[278,-232],[381,58],[283,-127],[190,-197],[370,58],[288,127],[283,151],[337,81],[392,69],[354,81],[272,127],[163,186],[65,254],[-32,244],[-87,231],[-98,232],[-87,231],[-71,209],[-16,231],[27,232],[130,220],[109,243],[44,231],[-55,255],[-32,232],[136,266],[152,173],[180,220],[190,186],[223,173],[109,255],[152,162],[174,151],[267,34],[174,186],[196,115],[228,70],[202,150],[157,186],[218,69],[163,-151],[-103,-196],[-283,-174],[-120,-127],[-206,92],[-229,-58],[-190,-139],[-202,-150],[-136,-174],[-38,-231],[17,-220],[130,-197],[-190,-139],[-261,-46],[-153,-197],[-163,-185],[-174,-255],[-44,-220],[98,-243],[147,-185],[229,-139],[212,-185],[114,-232],[60,-220],[82,-232],[130,-196],[82,-220],[38,-544],[81,-220],[22,-232],[87,-231],[-38,-313],[-152,-243],[-163,-197],[-370,-81],[-125,-208],[-169,-197],[-419,-220],[-370,-93],[-348,-127],[-376,-128],[-223,-243],[-446,-23],[-489,23],[-441,-46],[-468,0],[87,-232],[424,-104],[311,-162],[174,-208],[-310,-185],[-479,58],[-397,-151],[-17,-243],[-11,-232],[327,-196],[60,-220],[353,-220],[588,-93],[500,-162],[398,-185],[506,-186],[690,-92],[681,-162],[473,-174],[517,-197],[272,-278],[136,-220],[337,209],[457,173],[484,186],[577,150],[495,162],[691,12],[680,-81],[560,-139],[180,255],[386,173],[702,12],[550,127],[522,128],[577,81],[614,104],[430,150],[-196,209],[-119,208],[0,220],[-539,-23],[-571,-93],[-544,0],[-77,220],[39,440],[125,128],[397,138],[468,139],[337,174],[337,174],[251,231],[380,104],[376,81],[190,47],[430,23],[408,81],[343,116],[337,139],[305,139],[386,185],[245,197],[261,173],[82,232],[-294,139],[98,243],[185,185],[288,116],[305,139],[283,185],[217,232],[136,277],[202,163],[331,-35],[136,-197],[332,-23],[11,220],[142,231],[299,-58],[71,-220],[331,-34],[360,104],[348,69],[315,-34],[120,-243],[305,196],[283,105],[315,81],[310,81],[283,139],[310,92],[240,128],[168,208],[207,-151],[288,81],[202,-277],[157,-209],[316,116],[125,232],[283,162],[365,-35],[108,-220],[229,220],[299,69],[326,23],[294,-11],[310,-70],[300,-34],[130,-197],[180,-174],[304,104],[327,24],[315,0],[310,11],[278,81],[294,70],[245,162],[261,104],[283,58],[212,162],[152,324],[158,197],[288,-93],[109,-208],[239,-139],[289,46],[196,-208],[206,-151],[283,139],[98,255],[250,104],[289,197],[272,81],[326,116],[218,127],[228,139],[218,127],[261,-69],[250,208],[180,162],[261,-11],[229,139],[54,208],[234,162],[228,116],[278,93],[256,46],[244,-35],[262,-58],[223,-162],[27,-254],[245,-197],[168,-162],[332,-70],[185,-162],[229,-162],[266,-35],[223,116],[240,243],[261,-127],[272,-70],[261,-69],[272,-46],[277,0],[229,-614],[-11,-150],[-33,-267],[-266,-150],[-218,-220],[38,-232],[310,12],[-38,-232],[-141,-220],[-131,-243],[212,-185],[321,-58],[321,104],[153,232],[92,220],[153,185],[174,174],[70,208],[147,289],[174,58],[316,24],[277,69],[283,93],[136,231],[82,220],[190,220],[272,151],[234,115],[153,197],[157,104],[202,93],[277,-58],[250,58],[272,69],[305,-34],[201,162],[142,393],[103,-162],[131,-278],[234,-115],[266,-47],[267,70],[283,-46],[261,-12],[174,58],[234,-35],[212,-127],[250,81],[300,0],[255,81],[289,-81],[185,197],[141,196],[191,163],[348,439],[179,-81],[212,-162],[185,-208],[354,-359],[272,-12],[256,0],[299,70],[299,81],[229,162],[190,174],[310,23],[207,127],[218,-116],[141,-185],[196,-185],[305,23],[190,-150],[332,-151],[348,-58],[288,47],[218,185],[185,185],[250,46],[251,-81],[288,-58],[261,93],[250,0],[245,-58],[256,-58],[250,104],[299,93],[283,23],[316,0],[255,58],[251,46],[76,290],[11,243],[174,-162],[49,-266],[92,-244],[115,-196],[234,-105],[315,35],[365,12],[250,35],[364,0],[262,11],[364,-23],[310,-46],[196,-186],[-54,-220],[179,-173],[299,-139],[310,-151],[360,-104],[375,-92],[283,-93],[315,-12],[180,197],[245,-162],[212,-185],[245,-139],[337,-58],[321,-69],[136,-232],[316,-139],[212,-208],[310,-93],[321,12],[299,-35],[332,12],[332,-47],[310,-81],[288,-139],[289,-116],[195,-173],[-32,-232],[-147,-208],[-125,-266],[-98,-209],[-131,-243],[-364,-93],[-163,-208],[-360,-127],[-125,-232],[-190,-220],[-201,-185],[-115,-243],[-70,-220],[-28,-266],[6,-220],[158,-232],[60,-220],[130,-208],[517,-81],[109,-255],[-501,-93],[-424,-127],[-528,-23],[-234,-336],[-49,-278],[-119,-220],[-147,-220],[370,-196],[141,-244],[239,-219],[338,-197],[386,-186],[419,-185],[636,-185],[142,-289],[800,-128],[53,-45],[208,-175],[767,151],[636,-186],[-99520,-142]],[[31180,18764],[361,-355],[389,-147],[-125,-296],[-264,-29],[-141,208],[-92,-239],[-238,-183],[-301,67],[-202,177],[-291,86],[-350,330],[-283,317],[-383,662],[229,-124],[390,-395],[369,-212],[143,271],[90,405],[256,244],[198,-70],[106,-274],[139,-443]],[[33736,20389],[222,-266],[-83,-207],[-375,-177],[-125,207],[-236,-266],[-139,266],[333,354],[236,-148],[167,237]],[[69522,21210],[-427,-38],[-7,314],[41,244],[19,121],[179,-186],[263,-74],[9,-112],[-77,-269]],[[90387,26479],[269,-204],[151,81],[217,113],[166,-39],[20,-702],[-95,-203],[-29,-476],[-97,162],[-193,-412],[-57,32],[-171,19],[-171,505],[-38,390],[-160,515],[7,271],[181,-52]],[[98060,26404],[63,-244],[198,239],[80,-249],[0,-249],[-103,-274],[-182,-435],[-142,-238],[103,-284],[-214,-7],[-238,-223],[-75,-387],[-157,-597],[-219,-264],[-138,-169],[-256,13],[-180,194],[-302,42],[-46,217],[149,438],[349,583],[179,111],[200,225],[238,310],[167,306],[123,441],[106,149],[41,330],[195,273],[61,-251]],[[98502,29218],[202,-622],[5,403],[126,-161],[41,-447],[224,-192],[188,-48],[158,226],[141,-69],[-67,-524],[-85,-345],[-212,12],[-74,-179],[26,-254],[-41,-110],[-105,-319],[-138,-404],[-214,-236],[-48,155],[-116,85],[160,486],[-91,326],[-299,236],[8,214],[201,206],[47,455],[-13,382],[-113,396],[8,104],[-133,244],[-218,523],[-117,418],[104,46],[151,-328],[216,-153],[78,-526]],[[96421,37487],[-105,-142],[-153,160],[-199,266],[-179,313],[-184,416],[-38,201],[119,-9],[156,-201],[122,-200],[89,-166],[228,-366],[144,-272]],[[99547,40335],[96,-171],[-46,-308],[-172,-81],[-153,73],[-27,260],[107,203],[126,-74],[69,98]],[[0,40798],[99822,-145],[-177,-124],[-36,220],[139,121],[88,33],[-99836,184]],[[0,41087],[0,-289]],[[0,41087],[57,27],[-34,-284],[-23,-32]],[[96623,40851],[-92,-78],[-93,259],[10,158],[175,-339]],[[96418,41756],[45,-476],[-75,74],[-58,-32],[-39,163],[-6,453],[133,-182]],[[63904,42571],[45,-711],[72,-276],[-28,-284],[-49,-174],[-94,347],[-53,-175],[53,-438],[-24,-250],[-77,-137],[-18,-500],[-109,-689],[-137,-814],[-172,-1120],[-106,-821],[-125,-685],[-226,-140],[-243,-250],[-160,151],[-220,211],[-77,312],[-18,524],[-98,471],[-26,425],[50,426],[128,102],[1,197],[133,447],[25,377],[-65,280],[-52,372],[-23,544],[97,331],[38,375],[138,22],[155,121],[103,107],[122,7],[158,337],[229,364],[83,297],[-38,253],[118,-71],[153,410],[6,356],[92,264],[96,-254],[74,-251],[69,-390]],[[89877,42448],[100,-464],[179,223],[92,-250],[133,-231],[-29,-262],[60,-506],[42,-295],[70,-72],[75,-505],[-27,-307],[90,-400],[301,-309],[197,-281],[186,-257],[-37,-143],[159,-371],[108,-639],[111,130],[113,-256],[68,91],[48,-626],[197,-363],[129,-226],[217,-478],[78,-475],[7,-337],[-19,-365],[132,-502],[-16,-523],[-48,-274],[-75,-527],[6,-339],[-55,-423],[-123,-538],[-205,-290],[-102,-458],[-93,-292],[-82,-510],[-107,-294],[-70,-442],[-36,-407],[14,-187],[-159,-205],[-311,-22],[-257,-242],[-127,-229],[-168,-254],[-230,262],[-170,104],[43,308],[-152,-112],[-243,-428],[-240,160],[-158,94],[-159,42],[-269,171],[-179,364],[-52,449],[-64,298],[-137,240],[-267,71],[91,287],[-67,438],[-136,-408],[-247,-109],[146,327],[42,341],[107,289],[-22,438],[-226,-504],[-174,-202],[-106,-470],[-217,243],[9,313],[-174,429],[-147,221],[52,137],[-356,358],[-195,17],[-267,287],[-498,-56],[-359,-211],[-317,-197],[-265,39],[-294,-303],[-241,-137],[-53,-309],[-103,-240],[-236,-15],[-174,-52],[-246,107],[-199,-64],[-191,-27],[-165,-315],[-81,26],[-140,-167],[-133,-187],[-203,23],[-186,0],[-295,377],[-149,113],[6,338],[138,81],[47,134],[-10,212],[34,411],[-31,350],[-147,598],[-45,337],[12,336],[-111,385],[-7,174],[-123,235],[-35,463],[-158,467],[-39,252],[122,-255],[-93,548],[137,-171],[83,-229],[-5,303],[-138,465],[-26,186],[-65,177],[31,341],[56,146],[38,295],[-29,346],[114,425],[21,-450],[118,406],[225,198],[136,252],[212,217],[126,46],[77,-73],[219,220],[168,66],[42,129],[74,54],[153,-14],[292,173],[151,262],[71,316],[163,300],[13,236],[7,321],[194,502],[117,-510],[119,118],[-99,279],[87,287],[122,-128],[34,449],[152,291],[67,233],[140,101],[4,165],[122,-69],[5,148],[122,85],[134,80],[205,-271],[155,-350],[173,-4],[177,-56],[-59,325],[133,473],[126,155],[-44,147],[121,338],[168,208],[142,-70],[234,111],[-5,302],[-204,195],[148,86],[184,-147],[148,-242],[234,-151],[79,60],[172,-182],[162,169],[105,-51],[65,113],[127,-292],[-74,-316],[-105,-239],[-96,-20],[32,-236],[-81,-295],[-99,-291],[20,-166],[221,-327],[214,-189],[143,-204],[201,-350],[78,1],[145,-151],[43,-183],[265,-200],[183,202],[55,317],[56,262],[34,324],[85,470],[-39,286],[20,171],[-32,339],[37,445],[53,120],[-43,197],[67,313],[52,325],[7,168],[104,222],[78,-289],[19,-371],[70,-71],[11,-249],[101,-300],[21,-335],[-10,-214]],[[95032,44386],[78,-203],[-194,4],[-106,363],[166,-142],[56,-22]],[[83531,44530],[-117,-11],[-368,414],[259,116],[146,-180],[97,-180],[-17,-159]],[[94680,44747],[-108,-14],[-170,60],[-58,91],[17,235],[183,-93],[91,-124],[45,-155]],[[94910,44908],[-42,-109],[-206,512],[-57,353],[94,0],[100,-473],[111,-283]],[[84565,44589],[-238,-130],[-33,71],[25,201],[119,360],[275,235],[32,139],[239,133],[194,20],[87,74],[105,-74],[-102,-160],[-289,-258],[-233,-170],[-181,-441]],[[82749,45797],[100,-158],[172,48],[69,-251],[-321,-119],[-193,-79],[-149,5],[95,340],[153,5],[74,209]],[[84139,45797],[-41,-328],[-417,-168],[-370,73],[0,216],[220,123],[174,-177],[185,45],[249,216]],[[94409,45654],[12,-119],[-218,251],[-152,212],[-104,197],[41,60],[128,-142],[228,-272],[65,-187]],[[93760,46238],[-56,-33],[-121,134],[-114,243],[14,99],[166,-250],[111,-193]],[[80172,46575],[533,-59],[61,244],[515,-284],[101,-383],[417,-108],[341,-351],[-317,-225],[-306,238],[-251,-16],[-288,44],[-260,106],[-322,225],[-204,59],[-116,-74],[-506,243],[-48,254],[-255,44],[191,564],[337,-35],[224,-231],[115,-45],[38,-210]],[[87423,46908],[-143,-402],[-27,445],[49,212],[58,200],[63,-173],[0,-282]],[[93299,46550],[-78,-59],[-120,227],[-122,375],[-59,450],[38,57],[30,-175],[84,-134],[135,-375],[131,-200],[-39,-166]],[[92217,47343],[-146,-48],[-44,-166],[-152,-144],[-142,-138],[-148,1],[-228,171],[-158,165],[23,183],[249,-86],[152,46],[42,283],[40,15],[27,-314],[158,45],[78,202],[155,211],[-30,348],[166,11],[56,-97],[-5,-327],[-93,-361]],[[85346,48536],[-104,-196],[-192,108],[-54,254],[281,29],[69,-195]],[[86241,48752],[101,-452],[-234,244],[-232,49],[-157,-39],[-192,21],[65,325],[344,24],[305,-172]],[[92538,47921],[-87,-157],[-52,348],[-65,229],[-126,193],[-158,252],[-200,174],[77,143],[150,-166],[94,-130],[117,-142],[111,-248],[106,-189],[33,-307]],[[87261,49899],[78,-955],[287,-354],[232,627],[319,356],[247,1],[238,-206],[206,-212],[298,-113],[482,-407],[513,-338],[192,-302],[154,-297],[43,-349],[462,-365],[68,-313],[-256,-64],[62,-393],[248,-388],[180,-627],[159,20],[-11,-262],[215,-100],[-84,-111],[295,-249],[-30,-171],[-184,-41],[-69,153],[-238,66],[-281,89],[-216,377],[-158,325],[-144,517],[-362,259],[-235,-169],[-170,-195],[35,-436],[-218,-203],[-155,99],[-288,25],[-247,485],[-282,118],[-69,-168],[-352,-18],[118,481],[175,164],[-72,642],[-134,496],[-538,500],[-229,50],[-417,546],[-82,-287],[-107,-52],[-63,216],[-1,257],[-212,290],[299,213],[198,-11],[-23,156],[-407,1],[-110,352],[-248,109],[-117,293],[374,143],[142,192],[446,-242],[44,-220]],[[84788,51419],[-223,-587],[-209,-113],[-267,115],[-463,-29],[-243,-85],[-39,-447],[248,-526],[150,268],[518,201],[-22,-272],[-121,86],[-121,-347],[-245,-229],[263,-757],[-50,-203],[249,-682],[-2,-388],[-148,-173],[-109,207],[134,484],[-273,-229],[-69,164],[36,228],[-200,346],[21,576],[-186,-179],[24,-689],[11,-846],[-176,-85],[-119,173],[79,544],[-43,570],[-117,4],[-86,405],[115,387],[40,469],[139,891],[58,243],[237,439],[217,-174],[350,-82],[319,25],[275,429],[48,-132]],[[85746,51249],[-15,-517],[-143,58],[-42,-359],[114,-312],[-78,-71],[-112,374],[-82,755],[56,472],[92,215],[20,-322],[164,-52],[26,-241]],[[79393,47122],[-308,-12],[-234,494],[-356,482],[-119,358],[-210,481],[-138,443],[-212,827],[-244,493],[-81,508],[-103,461],[-250,372],[-145,506],[-209,330],[-290,652],[-24,300],[178,-24],[430,-114],[246,-577],[215,-401],[153,-246],[263,-635],[283,-9],[233,-405],[161,-495],[211,-270],[-111,-482],[159,-205],[100,-15],[47,-412],[97,-330],[204,-52],[135,-374],[-70,-735],[-11,-914]],[[82742,51659],[312,-546],[-329,-70],[-93,-403],[12,-535],[-267,-404],[-7,-589],[-107,-903],[-41,210],[-316,-266],[-110,361],[-198,34],[-139,189],[-330,-212],[-101,285],[-182,-32],[-229,68],[-43,793],[-138,164],[-134,505],[-38,517],[32,548],[165,392],[204,-202],[214,110],[56,500],[119,112],[333,128],[199,467],[137,374],[110,221],[236,323],[214,411],[140,462],[112,2],[143,-299],[13,-257],[183,-165],[231,-177],[-20,-232],[-186,-29],[50,-289],[-205,-201],[-158,-533],[204,-560],[-48,-272]],[[85104,55551],[28,-392],[16,-332],[-94,-540],[-102,602],[-130,-300],[89,-435],[-79,-277],[-327,343],[-78,428],[84,280],[-176,280],[-87,-245],[-131,23],[-205,-330],[-46,173],[109,498],[175,166],[151,223],[98,-268],[212,162],[45,264],[196,15],[-16,457],[225,-280],[23,-297],[20,-218]],[[72560,54241],[-242,-135],[-132,470],[-49,849],[126,959],[192,-328],[129,-416],[134,-616],[-42,-615],[-116,-168]],[[33073,56553],[-232,-65],[-50,53],[81,163],[-6,233],[160,77],[58,-21],[-11,-440]],[[84439,56653],[-100,-195],[-87,-373],[-87,-175],[-171,409],[57,158],[70,165],[30,367],[153,35],[-44,-398],[205,570],[-26,-563]],[[82917,56084],[-369,-561],[136,414],[200,364],[167,409],[146,587],[49,-482],[-183,-325],[-146,-406]],[[83856,57606],[166,-183],[177,1],[-5,-247],[-129,-251],[-176,-178],[-10,275],[20,301],[-43,282]],[[84861,57766],[78,-660],[-214,157],[5,-199],[68,-364],[-132,-133],[-11,416],[-84,31],[-43,357],[163,-47],[-4,224],[-169,451],[266,-13],[77,-220]],[[83757,58301],[-74,-510],[-119,295],[-142,450],[238,-22],[97,-213]],[[83700,61512],[171,-168],[85,153],[26,-150],[-46,-245],[95,-423],[-73,-491],[-164,-196],[-43,-476],[62,-471],[147,-65],[123,70],[347,-328],[-27,-321],[91,-142],[-29,-272],[-216,290],[-103,310],[-71,-217],[-177,354],[-253,-87],[-138,130],[14,244],[87,151],[-83,136],[-36,-213],[-137,340],[-41,257],[-11,566],[112,-195],[29,925],[90,535],[169,-1]],[[31780,61349],[-71,-149],[-209,4],[-163,-21],[-16,253],[40,86],[227,-3],[142,-52],[50,-118]],[[28638,61137],[-84,-99],[-156,95],[-159,215],[34,135],[116,41],[64,-20],[187,-53],[147,-142],[46,-161],[-195,-11]],[[29839,62320],[241,-93],[34,101],[217,-3],[165,-152],[73,15],[50,-209],[152,11],[-9,-176],[124,-21],[136,-217],[-103,-240],[-132,128],[-127,-25],[-92,28],[-50,-107],[-106,-37],[-43,144],[-92,-85],[-111,-405],[-71,94],[-14,170],[-185,100],[-131,-41],[-169,43],[-130,-110],[-149,184],[24,190],[256,-82],[210,-47],[100,131],[-127,256],[2,226],[-175,92],[62,163],[170,-26]],[[80649,61615],[-240,-284],[-228,183],[-8,509],[137,267],[304,166],[159,-14],[62,-226],[-122,-260],[-64,-341]],[[6794,61855],[-41,-99],[-69,84],[8,165],[-46,216],[14,65],[48,97],[-19,116],[16,55],[21,-11],[107,-100],[49,-51],[45,-79],[71,-207],[-7,-33],[-108,-126],[-89,-92]],[[6645,62777],[-94,-43],[-47,125],[-32,48],[-3,37],[27,50],[99,-56],[73,-90],[-23,-71]],[[6456,63091],[-9,-63],[-149,17],[21,72],[137,-26]],[[6207,63177],[-15,-34],[-19,8],[-97,21],[-35,133],[-11,24],[74,82],[23,-38],[80,-196]],[[5737,63567],[-33,-58],[-93,107],[14,43],[43,58],[64,-12],[5,-138]],[[27867,64030],[110,-216],[260,66],[98,-138],[235,-366],[173,-267],[92,8],[165,-120],[-20,-167],[205,-24],[210,-242],[-33,-138],[-185,-75],[-187,-29],[-191,46],[-398,-57],[186,329],[-113,154],[-179,39],[-96,171],[-66,336],[-157,-23],[-259,159],[-83,124],[-362,91],[-97,115],[104,148],[-273,30],[-199,-307],[-115,-8],[-40,-144],[-138,-65],[-118,56],[146,183],[60,213],[126,131],[142,116],[210,56],[67,65],[240,-42],[219,-7],[261,-201]],[[28462,64617],[-68,-29],[-70,340],[-104,171],[60,375],[84,-23],[97,-491],[1,-343]],[[83659,64045],[-119,-485],[-146,499],[-32,438],[163,581],[223,447],[127,-176],[-49,-357],[-167,-947]],[[28383,66284],[-303,-95],[-19,219],[130,47],[184,-18],[8,-153]],[[28611,66290],[-48,-420],[-51,75],[4,309],[-124,234],[-1,67],[220,-265]],[[87399,70756],[35,-203],[-156,-357],[-114,189],[-143,-137],[-73,-346],[-181,168],[2,281],[154,352],[158,-68],[114,248],[204,-127]],[[59604,71655],[-188,-251],[21,-111],[8,-48],[-285,-240],[-136,77],[-64,237],[132,22],[19,3],[40,143],[200,-8],[253,176]],[[56583,71675],[152,-199],[216,34],[207,-42],[-7,-103],[151,71],[-35,-175],[-400,-50],[3,98],[-339,115],[52,251]],[[54311,73167],[-100,-465],[41,-183],[-58,-303],[-213,222],[-141,64],[-387,300],[38,304],[325,-54],[284,64],[211,51]],[[52558,74927],[166,-419],[-39,-782],[-126,38],[-113,-197],[-105,156],[-11,713],[-64,338],[153,-30],[139,183]],[[89159,72524],[-104,-472],[48,-296],[-145,-416],[-355,-278],[-488,-36],[-396,-675],[-186,227],[-12,442],[-483,-130],[-329,-279],[-325,-11],[282,-435],[-186,-1004],[-179,-248],[-135,229],[69,533],[-176,172],[-113,405],[263,182],[145,371],[280,306],[203,403],[553,177],[297,-121],[291,1050],[185,-282],[408,591],[158,229],[174,723],[-47,664],[117,374],[295,108],[152,-819],[-9,-479],[-256,-595],[4,-610]],[[52655,75484],[-92,-456],[-126,120],[-64,398],[56,219],[179,226],[47,-507]],[[89974,76679],[195,-126],[197,250],[62,-663],[-412,-162],[-244,-587],[-436,404],[-152,-646],[-308,-9],[-39,587],[138,455],[296,33],[81,817],[83,460],[326,-615],[213,-198]],[[32315,78082],[202,-79],[257,16],[-137,-242],[-102,-38],[-353,250],[-69,198],[105,183],[97,-288]],[[32831,79592],[-135,-11],[-360,186],[-258,279],[96,49],[365,-148],[284,-247],[8,-108]],[[15692,79240],[-140,-82],[-456,269],[-84,209],[-248,207],[-50,168],[-286,107],[-107,321],[24,137],[291,-129],[171,-89],[261,-63],[94,-204],[138,-280],[277,-244],[115,-327]],[[34407,80527],[-184,-517],[181,199],[187,-126],[-98,-206],[247,-162],[128,144],[277,-182],[-86,-433],[194,101],[36,-313],[86,-367],[-117,-520],[-125,-22],[-183,111],[60,484],[-77,75],[-322,-513],[-166,21],[196,277],[-267,144],[-298,-35],[-539,18],[-43,175],[173,208],[-121,160],[234,356],[287,941],[172,336],[241,204],[129,-26],[-54,-160],[-148,-372]],[[13136,82508],[267,47],[-84,-671],[242,-475],[-111,1],[-167,270],[-103,272],[-140,184],[-51,260],[16,188],[131,-76]],[[89901,80562],[280,-1046],[-411,195],[-171,-854],[271,-605],[-8,-413],[-211,356],[-182,-457],[-51,496],[31,575],[-32,638],[64,446],[13,790],[-163,581],[24,808],[257,271],[-110,274],[123,83],[73,-391],[96,-569],[-7,-581],[114,-597]],[[48114,81456],[-493,-349],[-393,89],[225,617],[-145,601],[378,463],[210,276],[233,24],[298,-365],[-149,-406],[46,-422],[-210,-528]],[[53524,83435],[-166,-478],[-291,333],[-39,246],[408,195],[88,-296]],[[7498,84325],[-277,-225],[-142,152],[-43,277],[252,210],[148,90],[185,-40],[117,-183],[-240,-281]],[[49165,85222],[-297,-639],[283,81],[304,-3],[-72,-481],[-250,-530],[287,-38],[270,-759],[190,-95],[171,-673],[79,-233],[337,-113],[-34,-378],[-142,-173],[111,-305],[-250,-310],[-371,6],[-473,-163],[-130,116],[-183,-276],[-257,67],[-195,-226],[-148,118],[407,621],[249,127],[-436,99],[-79,235],[291,183],[-152,319],[52,387],[414,-54],[40,343],[-190,372],[-337,104],[-66,160],[101,264],[-92,163],[-149,-279],[-17,569],[-140,301],[101,611],[216,480],[222,-47],[335,49]],[[4006,85976],[-171,-92],[-182,110],[-168,161],[274,101],[220,-54],[27,-226]],[[27981,87304],[-108,-310],[-123,50],[-73,176],[13,41],[107,177],[114,-13],[70,-121]],[[27250,87631],[-325,-326],[-196,13],[-61,160],[207,273],[381,-6],[-6,-114]],[[2297,88264],[171,-113],[173,61],[225,-156],[276,-79],[-23,-64],[-211,-125],[-211,128],[-106,107],[-245,-34],[-66,52],[17,223]],[[26344,89371],[51,-259],[143,91],[161,-155],[304,-203],[318,-184],[25,-281],[204,46],[199,-196],[-247,-186],[-432,142],[-156,266],[-275,-314],[-396,-306],[-95,346],[-377,-57],[242,292],[35,465],[95,542],[201,-49]],[[45969,89843],[-64,-382],[314,-403],[-361,-451],[-801,-405],[-240,-107],[-365,87],[-775,187],[273,261],[-605,289],[492,114],[-12,174],[-583,137],[188,385],[421,87],[433,-400],[422,321],[349,-167],[453,315],[461,-42]],[[28926,90253],[-312,-30],[-69,289],[118,331],[255,82],[217,-163],[3,-253],[-32,-82],[-180,-174]],[[0,91325],[681,-451],[728,-588],[-24,-367],[187,-147],[-64,429],[754,-88],[544,-553],[-276,-257],[-455,-61],[-7,-578],[-111,-122],[-260,17],[-212,206],[-369,172],[-62,257],[-283,96],[-315,-76],[-151,207],[60,219],[-333,-140],[126,-278],[-158,-251]],[[0,88971],[0,2354]],[[23431,91410],[-173,-207],[-374,179],[-226,-65],[-380,266],[245,183],[194,256],[295,-168],[166,-106],[84,-112],[169,-226]],[[0,92833],[99999,-404],[-305,-30],[-49,187],[-99645,247]],[[0,92833],[36,24],[235,-1],[402,-169],[-24,-81],[-286,-141],[-363,-36],[0,404]],[[24848,91640],[-1,-604],[371,463],[332,-380],[-83,-438],[269,-399],[290,427],[202,510],[16,648],[394,-45],[411,-87],[373,-293],[17,-293],[-207,-315],[196,-316],[-36,-288],[-544,-413],[-386,-91],[-287,178],[-83,-297],[-268,-498],[-81,-258],[-322,-400],[-397,-39],[-220,-250],[-18,-384],[-323,-74],[-340,-479],[-301,-665],[-108,-466],[-15,-686],[408,-99],[125,-553],[130,-448],[388,117],[517,-256],[277,-225],[199,-279],[348,-162],[294,-249],[459,-34],[302,-58],[-45,-511],[86,-594],[201,-661],[414,-561],[214,192],[150,607],[-145,934],[-196,311],[445,276],[314,415],[154,411],[-22,395],[-189,502],[-338,445],[328,619],[-121,535],[-93,922],[194,137],[476,-161],[286,-57],[230,155],[258,-200],[342,-343],[85,-229],[495,-45],[-8,-496],[92,-747],[254,-92],[201,-348],[402,328],[266,652],[184,274],[216,-527],[362,-754],[307,-709],[-112,-371],[370,-333],[250,-338],[442,-152],[179,-189],[110,-500],[216,-78],[112,-223],[20,-664],[-202,-222],[-199,-207],[-458,-210],[-349,-486],[-470,-96],[-594,125],[-417,4],[-287,-41],[-233,-424],[-354,-262],[-401,-782],[-320,-545],[236,97],[446,776],[583,493],[416,59],[245,-290],[-262,-397],[88,-637],[91,-446],[361,-295],[459,86],[278,664],[19,-429],[180,-214],[-344,-387],[-615,-351],[-276,-239],[-310,-426],[-211,44],[-11,500],[483,488],[-445,-19],[-309,-72],[48,-194],[-296,-286],[-286,-204],[-293,-175],[-159,-386],[-35,-98],[-3,-313],[92,-313],[115,-15],[-29,216],[83,-131],[-22,-169],[-188,-96],[-133,12],[-205,-104],[-121,-29],[-162,-29],[-231,-171],[408,111],[82,-112],[-389,-177],[-177,-1],[8,72],[-84,-164],[82,-27],[-60,-424],[-203,-455],[-20,152],[-61,31],[-91,147],[57,-318],[66,-106],[8,-222],[-89,-230],[-157,-472],[-25,24],[86,402],[-142,226],[-33,490],[-53,-255],[59,-375],[-175,88],[183,-186],[12,-562],[79,-41],[29,-204],[39,-591],[-176,-439],[-288,-175],[-182,-346],[-139,-38],[-141,-217],[-39,-199],[-305,-383],[-157,-281],[-131,-351],[-43,-419],[50,-411],[92,-505],[124,-418],[1,-256],[132,-685],[-9,-398],[-12,-230],[-69,-361],[-83,-74],[-137,71],[-44,259],[-106,136],[-147,508],[-129,452],[-42,231],[57,393],[-77,325],[-217,494],[-108,91],[-281,-269],[-49,30],[-135,276],[-174,146],[-314,-75],[-247,66],[-212,-41],[-118,-83],[54,-166],[-5,-240],[59,-117],[-53,-77],[-103,87],[-104,-112],[-202,18],[-207,313],[-242,-74],[-202,137],[-173,-42],[-234,-138],[-253,-438],[-276,-255],[-152,-282],[-63,-266],[-3,-407],[14,-284],[52,-201],[1,-1],[-1,-1],[-107,-516],[-49,-426],[-20,-791],[-27,-289],[48,-322],[86,-288],[56,-458],[184,-440],[65,-337],[109,-291],[295,-157],[114,-247],[244,165],[212,60],[208,106],[175,101],[176,241],[67,345],[22,496],[48,173],[188,155],[294,137],[246,-21],[169,50],[66,-125],[-9,-285],[-149,-351],[-66,-360],[51,-103],[-42,-255],[-69,-461],[-71,152],[-58,-10],[1,-87],[53,-3],[-5,-160],[-45,-256],[24,-91],[-29,-212],[18,-56],[-32,-299],[-55,-156],[-50,-19],[-55,-205],[90,-107],[24,88],[82,-75],[29,-23],[61,104],[79,8],[26,-48],[43,29],[129,-53],[128,16],[90,65],[32,66],[89,-31],[66,-40],[73,14],[55,51],[127,-82],[44,-13],[85,-110],[80,-132],[101,-91],[73,-162],[-24,-57],[-14,-132],[29,-216],[-64,-202],[-30,-237],[-9,-261],[15,-152],[7,-266],[-43,-58],[-26,-253],[19,-156],[-56,-151],[12,-159],[43,-97],[70,-321],[108,-238],[130,-252],[100,-212],[-6,-125],[111,-27],[26,48],[77,-145],[136,42],[119,150],[168,119],[95,176],[153,-34],[-10,-58],[155,-21],[124,-102],[90,-177],[105,-164],[143,-18],[209,412],[114,63],[3,195],[51,500],[159,274],[175,11],[22,123],[218,-49],[218,298],[109,132],[134,285],[98,-36],[73,-156],[-54,-199],[-8,-139],[-163,-69],[91,-268],[-3,-309],[-123,-343],[105,-469],[120,38],[62,427],[-86,208],[-14,447],[346,241],[-38,278],[97,186],[100,-415],[195,-9],[180,-330],[11,-195],[249,-6],[297,61],[159,-264],[213,-73],[155,184],[4,149],[344,35],[333,9],[-236,-175],[95,-279],[222,-44],[210,-291],[45,-473],[144,13],[109,-139],[183,-217],[171,-385],[8,-304],[105,-14],[149,-289],[109,-205],[333,-119],[30,107],[225,43],[298,-159],[95,-65],[204,-140],[294,-499],[46,-242],[95,28],[69,-327],[155,-1033],[149,-97],[7,-408],[-208,-487],[86,-178],[491,-92],[10,-593],[211,388],[349,-212],[462,-361],[135,-346],[-45,-327],[323,182],[540,-313],[415,23],[411,-489],[355,-662],[214,-170],[237,-24],[101,-186],[94,-752],[46,-358],[-110,-977],[-142,-385],[-391,-822],[-177,-668],[-206,-513],[-69,-11],[-78,-435],[20,-1107],[-77,-910],[-30,-390],[-88,-233],[-49,-790],[-282,-771],[-47,-610],[-225,-256],[-65,-355],[-302,2],[-437,-227],[-195,-263],[-311,-173],[-327,-470],[-235,-586],[-41,-441],[46,-326],[-51,-597],[-63,-289],[-195,-325],[-308,-1040],[-244,-468],[-189,-277],[-127,-562],[-183,-337],[-121,-372],[-313,-328],[-205,118],[-151,-63],[-256,253],[-189,-19],[-169,327],[-19,-308],[353,-506],[-38,-408],[173,-257],[-14,-289],[-267,-757],[-412,-317],[-557,-123],[-305,59],[59,-352],[-57,-442],[51,-298],[-167,-208],[-284,-82],[-267,216],[-108,-155],[39,-587],[188,-178],[152,186],[82,-307],[-255,-183],[-223,-367],[-41,-595],[-66,-316],[-262,-2],[-218,-302],[-80,-443],[274,-433],[265,-119],[-96,-531],[-328,-333],[-180,-692],[-254,-234],[-113,-276],[89,-614],[185,-342],[-117,30],[-247,4],[-134,-145],[-250,-213],[-45,-552],[-118,-14],[-313,192],[-318,412],[-346,338],[-87,374],[79,346],[-140,393],[-36,1007],[119,568],[293,457],[-422,172],[265,522],[94,982],[309,-208],[145,1224],[-186,157],[-87,-738],[-175,83],[87,845],[95,1095],[127,404],[-79,576],[-23,666],[117,19],[170,954],[192,945],[118,881],[-64,885],[83,487],[-34,730],[163,721],[50,1143],[89,1227],[87,1321],[-20,967],[-58,832],[-279,340],[-24,242],[-551,593],[-498,646],[-214,365],[-115,488],[46,170],[-236,775],[-274,1090],[-262,1177],[-114,269],[-87,435],[-216,386],[-198,239],[90,264],[-134,563],[86,414],[221,373],[148,442],[-60,258],[-106,-275],[-166,259],[56,167],[-47,536],[97,89],[52,368],[105,381],[-20,241],[153,126],[190,236],[-37,183],[103,44],[-12,296],[65,214],[138,40],[117,371],[106,310],[-102,141],[52,343],[-62,540],[59,155],[-44,500],[-112,315],[-93,170],[-59,319],[68,158],[-70,40],[-52,195],[-138,165],[-122,-38],[-56,-205],[-112,-149],[-61,-20],[-27,-123],[132,-321],[-75,-76],[-40,-87],[-130,-30],[-48,353],[-36,-101],[-92,35],[-56,238],[-114,39],[-72,69],[-119,-1],[-8,-128],[-32,89],[-151,131],[-56,124],[32,103],[-11,130],[-77,142],[-109,116],[-95,76],[-19,173],[-73,105],[18,-172],[-55,-141],[-64,164],[-89,58],[-38,120],[2,179],[36,187],[-78,83],[64,114],[-96,186],[-130,238],[-61,200],[-117,185],[-140,267],[31,92],[46,-89],[21,41],[-48,185],[-84,52],[-31,-140],[-161,9],[-100,57],[-115,117],[-154,37],[-79,127],[-142,103],[-174,11],[-127,117],[-149,244],[-314,636],[-144,192],[-226,154],[-156,-43],[-223,-223],[-140,-58],[-196,156],[-208,112],[-260,271],[-208,83],[-314,275],[-233,282],[-70,158],[-155,35],[-284,187],[-116,270],[-299,335],[-139,373],[-66,288],[93,57],[-29,169],[64,153],[1,204],[-93,266],[-25,235],[-94,298],[-244,587],[-280,462],[-135,368],[-238,241],[-51,145],[42,365],[-142,137],[-164,288],[-69,412],[-149,48],[-162,311],[-130,288],[-12,184],[-149,446],[-99,452],[5,227],[-201,235],[-93,-26],[-159,163],[-44,-240],[46,-284],[27,-444],[95,-243],[206,-407],[46,-139],[42,-42],[37,-203],[49,8],[56,-381],[85,-150],[59,-210],[174,-300],[92,-550],[83,-259],[77,-277],[15,-311],[134,-20],[112,-268],[100,-264],[-6,-106],[-117,-217],[-49,3],[-74,359],[-182,337],[-200,286],[-142,150],[9,432],[-42,320],[-132,183],[-191,264],[-37,-76],[-70,154],[-171,143],[-164,343],[20,44],[115,-33],[103,221],[10,266],[-214,422],[-163,163],[-102,369],[-103,388],[-129,472],[-113,531],[-46,302],[-180,340],[-130,71],[-30,169],[-156,30],[-100,159],[-258,59],[-70,95],[-34,324],[-270,594],[-231,821],[10,137],[-123,195],[-215,495],[-38,482],[-148,323],[61,489],[-10,507],[-89,453],[109,557],[67,1072],[-50,792],[-88,506],[-80,274],[33,115],[402,-200],[148,-558],[68,156],[-44,485],[-94,484],[-38,1],[-537,581],[-199,255],[-503,245],[-155,523],[40,362],[-356,252],[-48,476],[-336,429],[-6,304],[-153,223],[-245,188],[-78,515],[-358,478],[-150,558],[-267,38],[-441,15],[-326,170],[-574,613],[-266,112],[-486,211],[-385,-50],[-546,271],[-330,252],[-309,-125],[58,-411],[-154,-38],[-321,-123],[-245,-199],[-307,-126],[-40,348],[125,580],[295,182],[-76,148],[-354,-329],[-190,-394],[-400,-420],[203,-287],[-262,-424],[-299,-247],[-278,-181],[-69,-261],[-434,-305],[-87,-278],[-325,-252],[-191,45],[-259,-165],[-282,-201],[-231,-197],[-477,-169],[-43,99],[304,276],[271,182],[296,324],[345,66],[137,243],[385,353],[62,119],[205,208],[48,448],[141,349],[-320,-179],[-90,102],[-150,-215],[-181,300],[-75,-212],[-104,294],[-278,-236],[-170,0],[-24,352],[50,217],[-179,210],[-361,-113],[-235,277],[-190,142],[-1,334],[-214,252],[108,340],[226,330],[99,303],[225,43],[191,-94],[224,285],[201,-51],[212,183],[-52,270],[-155,106],[205,228],[-170,-7],[-295,-128],[-85,-131],[-219,131],[-392,-67],[-407,142],[-117,238],[-351,343],[390,247],[620,289],[228,0],[-38,-295],[586,22],[-225,366],[-342,226],[-197,295],[-267,252],[-381,187],[155,309],[493,19],[350,270],[66,287],[284,281],[271,68],[526,262],[256,-40],[427,315],[421,-124],[201,-266],[123,114],[469,-35],[-16,-136],[425,-101],[283,59],[585,-186],[534,-56],[214,-77],[370,96],[421,-177],[302,-83],[518,-142],[438,-284],[289,-55],[244,247],[336,184],[413,-72],[416,259],[455,148],[191,-245],[207,138],[62,278],[192,-63],[470,-530],[369,401],[38,-448],[341,96],[105,173],[337,-34],[424,-248],[650,-217],[383,-100],[272,38],[375,-300],[-391,-293],[502,-127],[750,70],[236,103],[296,-354],[302,299],[-283,251],[179,202],[338,27],[223,59],[224,-141],[279,-321],[310,47],[491,-266],[431,94],[405,-14],[-32,367],[247,103],[431,-200],[-2,-559],[177,471],[223,-16],[126,594],[-298,364],[-324,239],[22,653],[329,429],[366,-95],[281,-261],[378,-666],[-247,-290],[517,-120]],[[18287,93781],[-139,-277],[618,179],[386,-298],[314,302],[254,-194],[227,-580],[140,244],[-197,606],[244,86],[276,-94],[311,-239],[175,-575],[86,-417],[466,-293],[502,-279],[-31,-260],[-456,-48],[178,-227],[-94,-217],[-503,93],[-478,160],[-322,-36],[-522,-201],[-824,-103],[-374,-41],[-151,279],[-379,161],[-246,-66],[-343,468],[185,62],[429,101],[392,-26],[362,103],[-537,138],[-594,-47],[-394,12],[-146,217],[644,237],[-428,-9],[-485,156],[233,443],[193,235],[744,359],[284,-114]],[[20972,93958],[-244,-390],[-434,413],[95,83],[372,24],[211,-130]],[[28794,93770],[25,-163],[-296,17],[-299,13],[-304,-80],[-80,36],[-306,313],[12,213],[133,39],[636,-63],[479,-325]],[[25955,93803],[219,-369],[256,477],[704,242],[477,-611],[-42,-387],[550,172],[263,235],[616,-299],[383,-282],[36,-258],[515,134],[290,-376],[670,-234],[242,-238],[263,-553],[-510,-275],[654,-386],[441,-130],[400,-543],[437,-39],[-87,-414],[-487,-687],[-342,253],[-437,568],[-359,-74],[-35,-338],[292,-344],[377,-272],[114,-157],[181,-584],[-96,-425],[-350,160],[-697,473],[393,-509],[289,-357],[45,-206],[-753,236],[-596,343],[-337,287],[97,167],[-414,304],[-405,286],[5,-171],[-803,-94],[-235,203],[183,435],[522,10],[571,76],[-92,211],[96,294],[360,576],[-77,261],[-107,203],[-425,286],[-563,201],[178,150],[-294,367],[-245,34],[-219,201],[-149,-175],[-503,-76],[-1011,132],[-588,174],[-450,89],[-231,207],[290,270],[-394,2],[-88,599],[213,528],[286,241],[717,158],[-204,-382]],[[22123,94208],[331,-124],[496,75],[72,-172],[-259,-283],[420,-254],[-50,-532],[-455,-229],[-268,50],[-192,225],[-690,456],[5,189],[567,-73],[-306,386],[329,286]],[[89889,93835],[-421,-4],[-569,66],[-49,31],[263,234],[348,54],[394,-226],[34,-155]],[[24112,93575],[-298,-442],[-317,22],[-173,519],[4,294],[145,251],[276,161],[579,-20],[530,-144],[-415,-526],[-331,-115]],[[16539,92764],[-731,-294],[-147,259],[-641,312],[93,193],[218,489],[241,388],[-272,362],[939,93],[397,-123],[709,-33],[270,-171],[298,-249],[-349,-149],[-681,-415],[-344,-414],[0,-248]],[[91869,94941],[-321,-234],[-444,53],[-516,233],[66,192],[518,-89],[697,-155]],[[23996,94879],[-151,-229],[-403,44],[-337,155],[148,266],[399,159],[243,-208],[101,-187]],[[90301,95224],[-219,-439],[-1023,16],[-461,-139],[-550,384],[149,406],[366,111],[734,-26],[1004,-313]],[[22639,95907],[212,-273],[9,-303],[-127,-440],[-458,-60],[-298,94],[5,345],[-455,-46],[-18,457],[299,-18],[419,201],[390,-34],[22,77]],[[19941,95601],[109,-210],[247,99],[291,-26],[49,-289],[-169,-281],[-940,-91],[-701,-256],[-423,-14],[-35,193],[577,261],[-1255,-70],[-389,106],[379,577],[262,165],[782,-199],[493,-350],[485,-45],[-397,565],[255,215],[286,-68],[94,-282]],[[65981,92363],[-164,-52],[-907,77],[-74,262],[-503,158],[-40,320],[284,126],[-10,323],[551,503],[-255,73],[665,518],[-75,268],[621,312],[917,380],[925,110],[475,220],[541,76],[193,-233],[-187,-184],[-984,-293],[-848,-282],[-863,-562],[-414,-577],[-435,-568],[56,-491],[531,-484]],[[23699,96131],[308,-190],[547,1],[240,-194],[-64,-222],[319,-134],[177,-140],[374,-26],[406,-50],[441,128],[566,51],[451,-42],[298,-223],[62,-244],[-174,-157],[-414,-127],[-355,72],[-797,-91],[-570,-11],[-449,73],[-738,190],[-96,325],[-34,293],[-279,258],[-574,72],[-322,183],[104,242],[573,-37]],[[17722,96454],[-38,-454],[-214,-205],[-259,-29],[-517,-252],[-444,-91],[-377,128],[472,442],[570,383],[426,-9],[381,87]],[[63641,74970],[141,-419],[130,-28],[85,-159],[-228,-47],[-49,-459],[-47,-207],[-102,-138],[7,-293],[88,-436],[263,-123],[193,-296],[395,-102],[434,156],[27,139],[-52,417],[40,618],[-216,200],[71,405],[-184,34],[61,498],[262,-145],[244,189],[-202,355],[-80,338],[-224,-151],[-28,-433],[-87,383],[-15,144],[68,246],[-53,206],[-322,202],[-125,530],[-154,150],[-9,192],[270,-56],[11,432],[236,96],[243,-88],[50,576],[-50,365],[-278,-28],[-236,144],[-321,-260],[-259,-124],[-127,-350],[-269,-97],[-276,-610],[252,-561],[-27,-398],[303,-696],[146,-311]],[[0,88971],[99997,-3],[-357,-260],[-360,44],[250,-315],[166,-487],[128,-159],[32,-244],[-71,-157],[-518,129],[-777,-445],[-247,-69],[-425,-415],[-403,-362],[-102,-269],[-397,409],[-724,-464],[-126,220],[-268,-254],[-371,81],[-90,-388],[-333,-572],[10,-239],[316,-132],[-37,-860],[-258,-22],[-119,-494],[116,-255],[-486,-301],[-96,-675],[-415,-144],[-83,-600],[-400,-551],[-103,407],[-119,862],[-155,1313],[134,819],[234,353],[15,276],[431,132],[496,744],[479,608],[499,471],[223,833],[-337,-50],[-167,-487],[-705,-648],[-227,726],[-717,-201],[-696,-990],[230,-362],[-620,-154],[-430,-61],[20,427],[-431,90],[-344,-291],[-850,102],[-913,-175],[-900,-1153],[-1065,-1394],[438,-74],[136,-370],[270,-132],[178,296],[305,-39],[401,-650],[9,-502],[-217,-591],[-23,-705],[-126,-945],[-418,-855],[-94,-409],[-377,-688],[-374,-682],[-179,-349],[-370,-346],[-175,-8],[-175,287],[-373,-432],[-43,-197],[-106,36],[-120,-201],[-83,-201],[10,-424],[-143,-130],[-50,-105],[-104,-174],[-185,-97],[-121,-159],[-9,-256],[-32,-65],[111,-96],[157,-259],[240,-697],[68,-383],[3,-681],[-105,-325],[-252,-113],[-222,-245],[-250,-51],[-31,322],[52,443],[-123,615],[206,99],[-190,506],[-135,113],[-34,-112],[-81,-49],[-10,112],[-72,54],[-75,94],[77,260],[65,69],[-25,108],[71,319],[-18,97],[-163,64],[-131,158],[-388,-171],[-204,-277],[-300,-161],[148,274],[-58,230],[220,397],[-147,310],[-242,-209],[-314,-411],[-171,-381],[-272,-29],[-142,-275],[147,-400],[227,-97],[9,-265],[220,-172],[311,421],[247,-230],[179,-15],[46,-310],[-394,-165],[-130,-319],[-270,-296],[-142,-414],[299,-324],[109,-582],[169,-541],[189,-454],[-5,-439],[-174,-161],[66,-315],[164,-184],[-43,-481],[-71,-468],[-155,-53],[-203,-640],[-225,-775],[-258,-705],[-382,-545],[-386,-498],[-313,-68],[-170,-262],[-96,192],[-157,-294],[-388,-296],[-294,-90],[-95,-624],[-154,-35],[-73,429],[66,228],[-373,190],[-131,-97],[-371,-505],[-231,-558],[-61,-410],[212,-623],[260,-772],[252,-365],[169,-475],[127,-1093],[-37,-1039],[-232,-389],[-318,-381],[-227,-492],[-346,-550],[-101,378],[78,401],[-206,335],[-233,87],[-112,307],[-141,611],[-249,271],[-238,-11],[41,464],[-245,-3],[-22,-650],[-150,-863],[-90,-522],[19,-428],[181,-18],[113,-539],[50,-512],[155,-338],[168,-69],[144,-306],[64,-56],[164,-356],[116,-396],[16,-398],[-29,-269],[27,-203],[20,-349],[98,-163],[109,-523],[-5,-199],[-197,-40],[-263,438],[-329,469],[-32,301],[-161,395],[-38,489],[-100,322],[30,431],[-61,250],[-110,227],[-47,292],[-148,334],[-135,280],[-45,-347],[-53,328],[30,369],[82,566],[-27,439],[86,452],[-94,350],[23,644],[-113,306],[-90,707],[-50,746],[-121,490],[-183,-297],[-315,-421],[-156,53],[-172,138],[96,732],[-58,554],[-218,681],[34,213],[-163,76],[-197,481],[-79,309],[-16,301],[-53,284],[-116,344],[-256,23],[25,-243],[-87,-329],[-118,120],[-41,-108],[-78,65],[-108,53],[-39,-216],[-189,7],[-343,-122],[16,-445],[-148,-349],[-400,-398],[-311,-695],[-209,-373],[-276,-386],[-1,-272],[-138,-146],[-250,-212],[-130,-31],[-84,-450],[58,-769],[15,-490],[-118,-561],[-1,-1004],[-144,-29],[-126,-450],[84,-195],[-253,-167],[-93,-402],[-112,-170],[-263,552],[-128,827],[-107,596],[-97,279],[-148,568],[-69,739],[-48,369],[-253,811],[-115,1145],[-83,756],[1,716],[-54,553],[-404,-353],[-196,70],[-362,716],[133,214],[-82,232],[-326,501],[-203,150],[-83,425],[-215,449],[-512,-111],[-451,-11],[-391,-83],[-523,179],[-302,136],[-314,76],[-118,725],[-133,105],[-214,-106],[-280,-286],[-339,196],[-281,454],[-267,168],[-186,561],[-205,788],[-149,-96],[-177,196],[-103,-231],[-165,29],[58,-261],[-25,-135],[89,-445],[109,-510],[137,-135],[47,-207],[190,-248],[16,-244],[-27,-197],[35,-199],[80,-165],[37,-194],[41,-145],[-18,430],[75,310],[76,64],[84,-186],[5,-345],[-61,-348],[53,-226],[49,29],[11,-162],[217,93],[230,-15],[168,-18],[190,400],[207,379],[176,364],[80,201],[35,-51],[-26,-244],[-37,-108],[38,-466],[125,-404],[155,-214],[204,-78],[164,-107],[125,-339],[75,-196],[100,-75],[-1,-132],[-101,-352],[-44,-166],[-117,-189],[-104,-404],[-126,31],[-58,-141],[-44,-300],[34,-395],[-26,-72],[-128,2],[-174,-221],[-27,-288],[-63,-125],[-173,5],[-109,-149],[1,-239],[-134,-164],[-153,56],[-186,-199],[-128,-33],[-201,-159],[-54,-263],[-6,-201],[-277,-249],[-444,-276],[-249,-417],[-122,-32],[-83,34],[-163,-245],[-177,-113],[-233,-31],[-70,-34],[-61,-156],[-73,-43],[-42,-150],[-138,13],[-89,-80],[-192,30],[-72,345],[8,323],[-46,174],[-54,437],[-80,243],[56,29],[-29,270],[34,114],[-12,257],[-36,253],[-84,177],[-22,236],[-143,212],[-148,495],[-79,482],[-192,406],[-124,97],[-184,563],[-32,411],[12,350],[-159,655],[-130,231],[-150,122],[-92,339],[15,133],[-77,307],[-81,131],[-108,440],[-170,476],[-141,406],[-139,-3],[44,325],[12,206],[34,236],[-9,86],[-78,-238],[-60,-446],[-75,-308],[-65,-103],[-93,191],[-125,263],[-198,847],[-29,-53],[115,-624],[171,-594],[210,-920],[102,-321],[90,-334],[249,-654],[-55,-103],[9,-384],[323,-530],[49,-121],[90,-580],[-61,-107],[40,-608],[102,-706],[106,-145],[152,-219],[161,-683],[77,-543],[152,-288],[379,-558],[154,-336],[151,-341],[87,-203],[136,-178],[66,-183],[-9,-245],[-158,-142],[119,-161],[91,-109],[54,-244],[125,-248],[138,-2],[262,151],[302,70],[245,184],[138,39],[99,108],[158,20],[89,12],[128,88],[147,59],[132,202],[105,2],[6,-163],[-25,-344],[1,-310],[-59,-214],[-78,-639],[-134,-659],[-172,-755],[-238,-866],[-237,-661],[-327,-806],[-278,-479],[-415,-586],[-259,-450],[-304,-715],[-64,-312],[-63,-140],[-195,-236],[-68,-246],[-104,-44],[-40,-416],[-89,-238],[-54,-393],[-112,-195],[-128,-728],[16,-335],[178,-216],[8,-153],[-76,-357],[16,-180],[-18,-282],[97,-370],[115,-583],[101,-129],[45,-265],[-11,-588],[34,-519],[11,-923],[49,-290],[-83,-422],[-108,-410],[-177,-366],[-254,-225],[-313,-287],[-313,-634],[-107,-108],[-194,-420],[-115,-136],[-23,-421],[132,-448],[54,-346],[4,-177],[49,29],[-8,-579],[-45,-274],[65,-102],[-41,-246],[-116,-210],[-229,-199],[-334,-320],[-122,-219],[24,-248],[71,-40],[-24,-311],[-70,-430],[-32,-491],[-72,-267],[-190,-298],[-54,-86],[-118,-300],[-77,-303],[-158,-424],[-314,-609],[-196,-355],[-209,-269],[-291,-229],[-141,-31],[-36,-164],[-169,88],[-138,-113],[-301,114],[-168,-72],[-115,31],[-286,-233],[-238,-94],[-171,-223],[-127,-14],[-117,210],[-94,11],[-120,264],[-13,-82],[-37,159],[2,346],[-90,396],[89,108],[-7,453],[-182,553],[-139,501],[-1,1],[-199,768],[-207,446],[-108,432],[-62,575],[-68,428],[-93,910],[-7,707],[-35,322],[-108,243],[-144,489],[-146,708],[-60,371],[-226,577],[-17,453],[-26,372],[38,519],[96,541],[15,254],[90,532],[66,243],[159,386],[90,263],[29,438],[-15,335],[-83,211],[-74,358],[-68,355],[15,122],[85,235],[-84,570],[-57,396],[-139,374],[26,115],[-39,183],[-74,444],[-228,626],[-285,596],[-184,488],[-169,610],[9,196],[61,189],[67,430],[56,438],[-52,90],[96,663],[40,467],[-108,390],[-127,100],[-56,265],[-71,85],[3,163],[-289,-213],[-105,32],[-107,-133],[-222,13],[-149,370],[-91,427],[-197,390],[-209,-8],[-245,1],[-229,-69],[-224,-126],[-436,-346],[-154,-203],[-250,-171],[-248,168],[-126,-7],[-194,116],[-178,-7],[-329,-103],[-193,-170],[-275,-217],[-54,15],[-73,-5],[-286,282],[-252,450],[-237,323],[-187,381],[-75,44],[-200,238],[-144,316],[-49,216],[-34,437],[-122,349],[-108,232],[-71,76],[-69,118],[-32,261],[-41,130],[-80,97],[-149,247],[-117,39],[-63,166],[1,90],[-84,125],[-18,127],[-46,453],[36,262],[-115,460],[-138,210],[122,112],[134,415],[66,304],[-24,318],[78,291],[34,557],[-30,583],[-34,294],[28,295],[-72,281],[-146,255],[12,249],[13,274],[106,161],[91,308],[-18,200],[96,417],[155,376],[93,95],[74,344],[6,315],[100,365],[185,216],[177,603],[144,235],[259,66],[219,403],[139,158],[232,493],[-70,735],[106,508],[37,312],[179,399],[278,270],[206,244],[186,612],[87,362],[205,-2],[167,-251],[264,41],[288,-131],[121,-6],[267,323],[300,102],[175,244],[268,180],[471,105],[459,48],[140,-87],[262,232],[297,5],[113,-137],[190,35],[302,239],[195,-71],[-9,-299],[236,217],[20,-113],[-139,-289],[-2,-274],[96,-147],[-36,-511],[-183,-297],[53,-322],[143,-10],[70,-281],[106,-92],[326,-204],[117,51],[232,-98],[368,-264],[130,-526],[250,-114],[391,-248],[296,-293],[136,153],[133,272],[-65,452],[87,288],[200,277],[192,80],[375,-121],[95,-264],[104,-2],[88,-101],[276,-69],[68,-196],[369,10],[268,-156],[275,-175],[129,-92],[214,188],[114,169],[245,49],[198,-75],[75,-293],[65,193],[222,-140],[217,-33],[137,149],[80,194],[-19,34],[74,276],[56,446],[40,149],[8,6],[99,482],[138,416],[5,21],[-26,452],[68,243],[-102,268],[105,222],[-169,-51],[-233,136],[-191,-340],[-421,-66],[-225,317],[-300,20],[-64,-245],[-192,-71],[-268,315],[-303,-10],[-165,587],[-203,328],[135,459],[-176,283],[308,565],[428,23],[117,449],[529,-78],[334,383],[324,167],[459,13],[485,-416],[399,-229],[323,91],[239,-53],[328,309],[42,252],[-70,403],[-160,218],[-154,68],[-102,181],[-354,499],[-317,223],[-240,347],[202,95],[231,494],[-156,234],[410,241],[-8,129],[-249,-95],[-222,-48],[-185,-191],[-260,-31],[-239,-220],[16,-368],[136,-142],[284,35],[-55,-210],[-304,-103],[-377,-342],[-154,121],[61,277],[-304,173],[50,113],[265,197],[-80,135],[-432,149],[-19,221],[-257,-73],[-103,-325],[-215,-437],[6,-152],[-135,-128],[-84,56],[-78,-713],[-144,-245],[-101,-422],[89,-337],[33,-228],[243,-190],[-51,-145],[-330,-33],[-118,-182],[-232,-319],[-87,275],[3,122],[-169,17],[-145,56],[-336,-154],[192,-332],[-141,-96],[-154,0],[-147,304],[-52,-130],[62,-353],[139,-277],[-105,-130],[155,-272],[137,-171],[4,-334],[-257,157],[82,-302],[-176,-62],[105,-521],[-184,-7],[-228,257],[-104,472],[-49,393],[-108,272],[-143,337],[-18,168],[-48,41],[-5,130],[-154,199],[-24,281],[23,403],[38,184],[-46,93],[-59,46],[-78,192],[-120,118],[-261,218],[-161,213],[-254,176],[-233,435],[56,44],[-127,248],[-5,200],[-179,93],[-85,-255],[-82,198],[6,205],[10,9],[62,54],[-221,86],[-226,-210],[15,-293],[-34,-168],[91,-301],[261,-298],[140,-488],[309,-476],[217,3],[68,-130],[-78,-118],[249,-213],[204,-179],[238,-308],[29,-111],[-52,-211],[-154,276],[-242,97],[-116,-382],[200,-219],[-33,-309],[-116,-35],[-148,-506],[-116,-46],[1,181],[57,317],[60,126],[-108,342],[-85,298],[-115,74],[-82,255],[-179,107],[-120,238],[-206,38],[-217,267],[-254,384],[-189,341],[-86,584],[-138,68],[-226,195],[-128,-80],[-161,-274],[-115,-43],[-252,-334],[-548,160],[-404,-192],[-32,-355],[15,-344],[-263,-393],[-356,-125],[-25,-199],[-171,-327],[-107,-481],[108,-338],[-160,-263],[-60,-384],[-210,-118],[-197,-455],[-352,-8],[-265,11],[-174,-209],[-106,-223],[-136,49],[-103,199],[-79,340],[-259,92],[-112,-153],[-146,83],[-143,-65],[42,462],[-26,363],[-124,55],[-67,224],[22,386],[111,215],[20,239],[58,355],[-6,250],[-56,212],[-12,200],[14,420],[-114,257],[393,426],[340,-107],[373,4],[296,-101],[230,31],[449,-19],[144,354],[53,1177],[-287,620],[-205,299],[-424,228],[-28,430],[360,129],[466,-152],[-88,669],[263,-254],[646,461],[84,484],[243,119],[222,117],[143,162],[244,870],[380,247],[231,-17],[54,125],[232,32],[52,-130],[188,291],[-63,222],[-13,335],[-113,328],[-8,604],[46,159],[80,178],[244,36],[98,163],[223,167],[-9,-304],[-82,-192],[33,-166],[151,-89],[-68,-223],[-83,64],[-200,-425],[76,-288],[4,-228],[281,-138],[-3,-210],[283,111],[156,162],[313,-233],[132,-189],[189,174],[434,273],[350,200],[277,-100],[21,-144],[268,-7],[63,260],[383,191],[-59,497],[10,445],[136,371],[262,202],[221,-442],[223,12],[53,453],[32,349],[-102,-75],[-176,210],[-24,340],[351,164],[350,86],[301,-97],[287,17],[316,327],[-291,280],[-504,-47],[-489,-216],[-452,-125],[-161,322],[-269,195],[62,581],[-135,534],[133,344],[252,371],[635,640],[185,124],[-28,250],[-387,279],[-478,-167],[-269,-413],[43,-361],[-441,-475],[-537,-509],[-202,-832],[198,-416],[265,-328],[-255,-666],[-289,-138],[-106,-992],[-157,-554],[-337,57],[-158,-468],[-321,-27],[-89,558],[-232,671],[-211,835],[-187,363],[-548,-684],[-370,-138],[-385,301],[-99,635],[-88,1364],[256,380],[733,496],[549,609],[508,824],[668,1141],[465,444],[763,741],[610,259],[457,-31],[423,489],[506,-26],[499,118],[869,-433],[-358,-158],[305,-371],[286,206],[456,-358],[761,-140],[1050,-668],[213,-281],[18,-393],[-308,-311],[-454,-157],[-1240,449],[-204,-75],[453,-433],[36,-878],[358,-180],[217,-153],[36,286],[-174,263],[183,215],[672,-368],[234,144],[-187,433],[647,578],[256,-34],[260,-206],[161,406],[-231,352],[136,353],[-204,367],[777,-190],[158,-331],[-351,-73],[2,-328],[218,-203],[429,128],[68,377],[581,282],[969,507],[209,-29],[-273,-359],[344,-61],[199,202],[521,16],[412,245],[317,-356],[315,391],[-291,343],[145,195],[820,-179],[385,-185],[1006,-675],[186,309],[-282,313],[-8,125],[-335,58],[92,280],[-149,461],[-8,189],[512,535],[182,537],[207,116],[735,-156],[58,-328],[-263,-479],[173,-189],[89,-413],[-63,-809],[307,-362],[-120,-395],[-544,-839],[318,-87],[110,213],[306,151],[74,293],[240,281],[-162,336],[130,390],[-304,49],[-67,328],[222,594],[-361,481],[497,398],[-64,421],[139,13],[145,-328],[-109,-570],[297,-108],[-127,426],[465,233],[577,31],[513,-337],[-247,492],[-28,630],[484,119],[668,-26],[602,77],[-226,309],[321,388],[319,16],[540,293],[734,79],[93,162],[729,55],[227,-133],[624,314],[510,-10],[77,255],[265,252],[656,242],[476,-191],[-378,-146],[629,-90],[75,-292],[254,143],[812,-7],[626,-289],[223,-221],[-69,-307],[-307,-175],[-730,-328],[-209,-175],[345,-83],[410,-149],[250,112],[142,-379],[122,153],[444,93],[892,-97],[67,-276],[1162,-88],[15,451],[590,-103],[443,3],[449,-312],[128,-378],[-165,-247],[349,-465],[437,-240],[268,620],[446,-266],[473,159],[538,-182],[204,166],[455,-83],[-201,549],[367,256],[2509,-384],[236,-351],[727,-451],[1122,112],[553,-98],[231,-244],[-33,-432],[342,-168],[372,121],[492,15],[525,-116],[526,66],[484,-526],[344,189],[-224,378],[123,263],[886,-166],[578,36],[799,-282],[-99610,-258]],[[23933,96380],[-126,-17],[-521,38],[-74,165],[559,-9],[195,-109],[-33,-68]],[[19392,96485],[-518,-170],[-411,191],[224,188],[406,60],[392,-92],[-93,-177]],[[56867,96577],[-620,-241],[-490,137],[191,152],[-167,189],[575,119],[110,-222],[401,-134]],[[19538,97019],[-339,-115],[-461,1],[5,84],[285,177],[149,-27],[361,-120]],[[23380,96697],[-411,-122],[-226,138],[-119,221],[-22,245],[360,-24],[162,-39],[332,-205],[-76,-214]],[[22205,96856],[108,-247],[-453,66],[-457,192],[-619,21],[268,176],[-335,142],[-21,227],[546,-81],[751,-215],[212,-281]],[[79187,96845],[-1566,-228],[507,776],[229,66],[208,-38],[704,-336],[-82,-240]],[[55069,97669],[915,-440],[-699,-233],[-155,-435],[-243,-111],[-132,-490],[-335,-23],[-598,361],[252,210],[-416,170],[-541,499],[-216,463],[757,212],[152,-207],[396,8],[105,202],[408,20],[350,-206]],[[57068,98086],[545,-207],[-412,-318],[-806,-70],[-819,98],[-50,163],[-398,11],[-304,271],[858,165],[403,-142],[281,177],[702,-148]],[[64204,98169],[-373,-78],[-250,-45],[-39,-97],[-324,-98],[-301,140],[158,185],[-618,18],[542,107],[422,8],[57,-160],[159,142],[262,97],[412,-129],[-107,-90]],[[77760,97184],[-606,-73],[-773,170],[-462,226],[-213,423],[-379,117],[722,404],[600,133],[540,-297],[640,-572],[-69,-531]],[[25828,97644],[334,-190],[-381,-176],[-513,-445],[-492,-42],[-575,76],[-299,240],[4,215],[220,157],[-508,-4],[-306,196],[-176,268],[193,262],[192,180],[285,42],[-122,135],[646,30],[355,-315],[468,-127],[455,-112],[220,-390]],[[30972,99681],[742,-47],[597,-75],[508,-161],[-12,-157],[-678,-257],[-672,-119],[-251,-133],[605,3],[-656,-358],[-452,-167],[-476,-483],[-573,-98],[-177,-120],[-841,-64],[383,-74],[-192,-105],[230,-292],[-264,-202],[-429,-167],[-132,-232],[-388,-176],[39,-134],[475,23],[6,-144],[-742,-355],[-726,163],[-816,-91],[-414,71],[-525,31],[-35,284],[514,133],[-137,427],[170,41],[742,-255],[-379,379],[-450,113],[225,229],[492,141],[79,206],[-392,231],[-118,304],[759,-26],[220,-64],[433,216],[-625,68],[-972,-38],[-491,201],[-232,239],[-324,173],[-61,202],[413,112],[324,19],[545,96],[409,220],[344,-30],[300,-166],[211,319],[367,95],[498,65],[849,24],[148,-63],[802,100],[601,-38],[602,-37]],[[42472,99925],[1737,-469],[-513,-227],[-1062,-26],[-1496,-58],[140,-105],[984,65],[836,-204],[540,181],[231,-212],[-305,-344],[707,220],[1348,229],[833,-114],[156,-253],[-1132,-420],[-157,-136],[-888,-102],[643,-28],[-324,-431],[-224,-383],[9,-658],[333,-386],[-434,-24],[-457,-187],[513,-313],[65,-502],[-297,-55],[360,-508],[-617,-42],[322,-241],[-91,-208],[-391,-91],[-388,-2],[348,-400],[4,-263],[-549,244],[-143,-158],[375,-148],[364,-361],[105,-476],[-495,-114],[-214,228],[-344,340],[95,-401],[-322,-311],[732,-25],[383,-32],[-745,-515],[-755,-466],[-813,-204],[-306,-2],[-288,-228],[-386,-624],[-597,-414],[-192,-24],[-370,-145],[-399,-138],[-238,-365],[-4,-415],[-141,-388],[-453,-472],[112,-462],[-125,-488],[-142,-577],[-391,-36],[-410,482],[-556,3],[-269,324],[-186,577],[-481,735],[-141,385],[-38,530],[-384,546],[100,435],[-186,208],[275,691],[418,220],[110,247],[58,461],[-318,-209],[-151,-88],[-249,-84],[-341,193],[-19,401],[109,314],[258,9],[567,-157],[-478,375],[-249,202],[-276,-83],[-232,147],[310,550],[-169,220],[-220,409],[-335,626],[-353,230],[3,247],[-745,346],[-590,43],[-743,-24],[-677,-44],[-323,188],[-482,372],[729,186],[559,31],[-1188,154],[-627,241],[39,229],[1051,285],[1018,284],[107,214],[-750,213],[243,235],[961,413],[404,63],[-115,265],[658,156],[854,93],[853,5],[303,-184],[737,325],[663,-221],[390,-46],[577,-192],[-660,318],[38,253],[932,353],[975,-27],[354,218],[982,57],[2219,-74]]],"bbox":[-180,-85.60903777459777,180,83.64513000000002],"transform":{"scale":[0.0036000360003600037,0.0016925586033320111],"translate":[-180,-85.60903777459777]}} diff --git a/services/pcp-tgu-ui-service/src/features/constellation/dashboard.css b/services/pcp-tgu-ui-service/src/features/constellation/dashboard.css new file mode 100644 index 0000000..f8d2a01 --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/constellation/dashboard.css @@ -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;} diff --git a/services/pcp-tgu-ui-service/src/features/constellation/data.js b/services/pcp-tgu-ui-service/src/features/constellation/data.js new file mode 100644 index 0000000..d66b900 --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/constellation/data.js @@ -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 }; + diff --git a/services/pcp-tgu-ui-service/src/features/constellation/index.js b/services/pcp-tgu-ui-service/src/features/constellation/index.js new file mode 100644 index 0000000..ab53214 --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/constellation/index.js @@ -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"; diff --git a/services/pcp-tgu-ui-service/src/features/constellation/realData.ts b/services/pcp-tgu-ui-service/src/features/constellation/realData.ts new file mode 100644 index 0000000..3558a0e --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/constellation/realData.ts @@ -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; + /** Подспутниковая точка по реальной трассе; null — нет данных на это время. */ + subSat: (sat: ConstellationSat, tMs: number) => { lon: number; lat: number } | null; + /** Готовое расписание (съёмка/сброс/связь/тень) — из него statusAt берёт состояние. */ + sched?: Record; + /** Заполнение бортового накопителя на момент tMs, % от ёмкости. Из объёмов маршрутов плана. */ + memAt?: (sat: ConstellationSat, tMs: number) => number; +} + +export interface ConstellationConfig { + env: ConstellationEnv; + domain: { start: number; end: number }; + sched: Record; +} + +// --- классификация полезной нагрузки по имени КА (из НСИ) ------------------- +// Поля 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 = { + 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 +): 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, + domainStart: number, + domainEnd: number +): Promise> { + const byId = new Map(); + 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 { + 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( + (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 { lon: number; lat: number } | null> = {}; + const rvaBySat: Record = {}; + + 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 = 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 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 }; +} diff --git a/services/pcp-tgu-ui-service/src/features/constellation/sim.js b/services/pcp-tgu-ui-service/src/features/constellation/sim.js new file mode 100644 index 0000000..c247507 --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/constellation/sim.js @@ -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 dφ = (lat2 - lat1) * D2R; + const dλ = (lon2 - lon1) * D2R; + const a = Math.sin(dφ / 2) ** 2 + Math.cos(φ1) * Math.cos(φ2) * Math.sin(dλ / 2) ** 2; + return 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)) * R2D; + } + + function inShadow(lon, lat, tMs) { + const ss = subSolar(tMs); + return angDist(lon, lat, ss.lon, ss.lat) > 90; + } + + // point in polygon (ray casting), poly = [[lon,lat],...] + function pointInPoly(lon, lat, poly) { + let inside = false; + for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) { + const xi = poly[i][0], yi = poly[i][1]; + const xj = poly[j][0], yj = poly[j][1]; + const intersect = (yi > lat) !== (yj > lat) && + lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi; + if (intersect) inside = !inside; + } + return inside; + } + + const STATION_RANGE = 22; // deg ground range for downlink visibility + + // which ROI is the sat over (matching payload), null if none + function overROI(sat, p, rois) { + for (const r of rois) { + if (r.payload !== sat.payload) continue; + if (pointInPoly(p.lon, p.lat, r.poly)) return r; + } + return null; + } + // best (nearest in-range) station, null if none + function nearStation(p, stations) { + let best = null, bd = STATION_RANGE; + for (const st of stations) { + const d = angDist(p.lon, p.lat, st.lon, st.lat); + if (d < bd) { bd = d; best = st; } + } + return best; + } + + // --- prohibition windows (интервалы запрета) per sat ---------- + function buildProhibits(sats, epochMs) { + const H = 3600e3; + const out = {}; + sats.forEach((s, i) => { + const list = []; + // deterministic-ish spread + const a0 = (i * 1.9) % 22 - 4; // hours from epoch + list.push({ start: epochMs + a0 * H, end: epochMs + (a0 + 0.7 + (i % 3) * 0.15) * H, reason: "ВНЗ" }); + if (i % 2 === 0) { + const a1 = a0 + 8 + (i % 4); + list.push({ start: epochMs + a1 * H, end: epochMs + (a1 + 0.9) * H, reason: "ОГРАН. ПИТАНИЕ" }); + } + out[s.id] = list; + }); + return out; + } + function inProhibit(list, tMs) { + if (!list) return null; + for (const w of list) if (tMs >= w.start && tMs < w.end) return w; + return null; + } + + // 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, +}; diff --git a/services/pcp-tgu-ui-service/src/features/constellation/util.js b/services/pcp-tgu-ui-service/src/features/constellation/util.js new file mode 100644 index 0000000..0f91d08 --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/constellation/util.js @@ -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})`; +} diff --git a/services/pcp-tgu-ui-service/src/features/tgu-complex-plan/TguComplexPlanTab.tsx b/services/pcp-tgu-ui-service/src/features/tgu-complex-plan/TguComplexPlanTab.tsx new file mode 100644 index 0000000..0183bba --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-complex-plan/TguComplexPlanTab.tsx @@ -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([]); + const [error, setError] = useState(); + const [notice, setNotice] = useState(); + + // История прогонов и выбранный для просмотра прогон. + const [runs, setRuns] = useState([]); + const [runsError, setRunsError] = useState(); + const [selectedRunId, setSelectedRunId] = useState(); + const [loadingRun, setLoadingRun] = useState(false); + + // Наложение заявок поверх контуров комплексного плана. + const [showRequests, setShowRequests] = useState(false); + const [requestItems, setRequestItems] = useState([]); + const [requestsLoading, setRequestsLoading] = useState(false); + const [requestsError, setRequestsError] = useState(); + + // 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( + () => ({ polygons: [...modePolygons, ...requestPolygons], lines: [], markers: [] }), + [modePolygons, requestPolygons] + ); + + const layers = useMemo( + () => ({ + tracks: false, + swath: false, + planWorks: false, + complexPlan: true, + stations: false, + spacecraftMarkers: false, + requests: showRequests + }), + [showRequests] + ); + + const loadRuns = useCallback(async (): Promise => { + 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 ( +
+
+
+
Комплексный план
+
+ Расчёт по {satelliteIds.length} КА вкладки «Планы» +
+
+ + + + + +
+ {displayRun && ( +
+ run #{displayRun.runId} · режимов {displayRun.calculatedModesCount} · охват {formatPercent(displayRun.coveredAreaPercent)} · объём {formatVolume(totalVolumeMb)} + {displayRun.snapshotIds.length > 0 ? ` · snapshot ${displayRun.snapshotIds.join(", ")}` : ""} · на карте {contoursCount} контуров + {showRequests ? ` · заявок ${requestItems.length}` : ""} +
+ )} +
+ +
+ {overlayMessage &&
{overlayMessage}
} + undefined} + /> +
+ +
+ {satelliteIds.length === 0 && ( +
+ Нет эксплуатируемых КА на вкладке «Планы» — расчёт недоступен. +
+ )} + {rangeTooLong && ( +
+ Интервал расчёта не может превышать {MAX_COMPLEX_PLAN_RANGE_DAYS} суток — сократите диапазон «С/По». +
+ )} + {requestsLoading &&
Загрузка заявок…
} + {requestsError &&
{requestsError}
} + {runsError &&
{runsError}
} + {notice &&
{notice}
} + {error &&
{error}
} +
+
+ ); +} + +/** Контуры режимов (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)}%`; +} diff --git a/services/pcp-tgu-ui-service/src/features/tgu-editor/EditorPanels.tsx b/services/pcp-tgu-ui-service/src/features/tgu-editor/EditorPanels.tsx new file mode 100644 index 0000000..e0794dd --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-editor/EditorPanels.tsx @@ -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; + 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 ( +
+ +
{work.id}
+ + {conflicts.length > 0 && ( +
+ {conflicts.map((conflict) => ( +
{conflict}
+ ))} +
+ )} + +
+
Время
+
+
Начало
+
{formatDateTimeSeconds(work.startMs)}
+
Конец
+
{formatDateTimeSeconds(work.endMs)}
+
Длительность
+
{formatDuration(work.endMs - work.startMs)}
+
Объём
+
{formatVolume(work.volumeMb)}
+
+
+ +
+
Параметры включения
+
+
Аппарат
+
{spacecraftLabel ?? "—"}
+
Виток
+
{work.revolution ?? "—"}
+ {work.kind === "shoot" && ( + <> +
Крен
+
{work.rollDeg != null ? `${work.rollDeg.toFixed(1)}°` : "—"}
+
Угол захвата
+
{work.captureAngleDeg != null ? `${work.captureAngleDeg.toFixed(1)}°` : "—"}
+
Центр контура
+
+ {work.centerLat != null && work.centerLon != null + ? `${work.centerLat.toFixed(2)}, ${work.centerLon.toFixed(2)}` + : "—"} +
+ + )} + {work.kind === "downlink" && ( + <> +
Станция
+
{stations.find((station) => station.id === work.stationId)?.name ?? work.stationId ?? "—"}
+ + )} +
+
+ + {work.kind === "shoot" && ( +
+
Задания под маршрутом
+
Привязка заданий к маршруту появится позже.
+
+ )} + + {work.kind === "downlink" && ( +
+
Сбрасываемые съёмки
+ {work.surveyIds && work.surveyIds.length > 0 ? ( +
    + {work.surveyIds.map((surveyId) => ( +
  • {surveyModeLabel ? surveyModeLabel(surveyId) : `Маршрут #${surveyId}`}
  • + ))} +
+ ) : ( +
Маршруты съёмки к сбросу не привязаны.
+ )} +
+ )} + + {work.kind === "shoot" && ( +
+
Режим аппаратуры
+
{modes.find((mode) => mode.id === work.modeId)?.label ?? "—"}
+ {work.targetName &&
цель: {work.targetName}
} +
+ )} + + {(canEdit || !readOnly || canDelete) && ( +
+ {canEdit && ( + + )} + {(!readOnly || canDelete) && ( + + )} +
+ )} + +
+ ); +} + +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 ( +
+ + +
+
+
1 · опорная точка прицеливания
+ {target && !picking && ( + + )} +
+ {target ? ( + <> +
+ + {target.name} + + {target.lat.toFixed(2)}, {target.lon.toFixed(2)} + +
+ {picking && ( +
+ Кликайте по карте, чтобы переставить точку. Выберите проход ниже, когда точка верна. +
+ )} + + ) : picking ? ( +
Кликните точку на карте слева, чтобы задать опорную точку прицеливания.
+ ) : ( + + )} +
+ + {target && ( +
+
2 · окно видимости
+ {loading ? ( +
Запрос параметров видимости…
+ ) : error ? ( +
{error}
+ ) : passes.length === 0 ? ( +
В окне планирования проходов над целью нет.
+ ) : ( +
+ {passes.map((pass) => { + const selected = selectedPass?.startMs === pass.startMs && selectedPass.endMs === pass.endMs; + return ( + + ); + })} +
+ )} +
+ )} + + {target && selectedPass && ( +
+
3 · режим съёмки
+
+ {modes.map((mode) => ( + + ))} +
+
+ )} + + {target && selectedPass && routeWindow && ( +
+
4 · окно маршрута
+ + +
+ длительность: {formatDuration(routeWindow.endMs - routeWindow.startMs)} + {passStartMs != null && passEndMs != null && ` · окно видимости: ${formatDuration(passEndMs - passStartMs)}`} +
+ +
+ сдвиг — ◀ назад · вперёд ▶ +
+ + + + +
+
+ +
+ крен — ◀ влево · вправо ▶ +
+ + + { + const deg = Number.parseFloat(event.target.value); + // Крен — не точнее одного знака после запятой. + if (Number.isFinite(deg)) onRouteRollSet(Math.round(deg * 10) / 10); + }} + /> + + +
+
+ + {routeLoading ? ( +
Построение маршрута…
+ ) : routeError ? ( +
{routeError}
+ ) : route ? ( +
+ Маршрут построен · виток {route.revolution} · крен {route.roll.toFixed(1)}° · захват {route.captureAngle.toFixed(1)}° · объём {formatVolume(route.volumeMb)} +
+ центр {route.lat.toFixed(2)}, {route.longitude.toFixed(2)} · контур показан на карте +
+ ) : null} +
+ )} + + {addBlockedReason &&
{addBlockedReason}
} + + +
+ ); +} + +export function DownlinkPanel({ stationOptions, selected, onSelect, onAdd, onClose }: DownlinkPanelProps) { + return ( +
+ +
Выберите станцию и сеанс связи для добавления сброса в черновик.
+ +
+ {stationOptions.map((option) => ( +
+
+ {option.station.name} + {option.passes.length} сеанс. +
+ {option.passes.length === 0 ? ( +
нет сеансов в текущем окне
+ ) : ( + option.passes.slice(0, 5).map((pass) => { + const isSelected = selected?.station.id === option.station.id && selected.pass.startMs === pass.startMs; + return ( + + ); + }) + )} +
+ ))} +
+ + +
+ ); +} + +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 ( +
+ +
+ Выберите зону радиовидимости станции, затем маршруты съёмки, которые успевают завершиться до начала зоны. +
+ + {loading &&
Загрузка зон радиовидимости…
} + {error &&
{error}
} + {!loading && !error && zones.length === 0 && ( +
Нет зон радиовидимости в текущем окне.
+ )} + + {zones.length > 0 && ( +
+ {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 ( + + ); + })} +
+ )} + + {selectedZone && ( +
+
Съёмки для сброса
+ {candidates.length === 0 ? ( +
Нет съёмок, завершающихся до начала выбранной зоны.
+ ) : ( +
+ {candidates.map((survey) => { + const checked = selectedSurveyIds.has(survey.id); + return ( + + ); + })} +
+ )} +
+ )} + + {addBlockedReason &&
{addBlockedReason}
} + {saveError &&
{saveError}
} + + +
+ ); +} + +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 ( +
+ + + {selection.type === "marker" && selection.layer === "stations" && ( +
+
{selection.name}
+
+
Номер
+
{selection.stationNumber ?? "—"}
+
Широта
+
{selection.lat != null ? `${selection.lat.toFixed(4)}°` : "—"}
+
Долгота
+
{selection.lon != null ? `${selection.lon.toFixed(4)}°` : "—"}
+
Высота, м
+
{selection.altitude != null ? selection.altitude.toFixed(1) : "—"}
+
Угол места мин, °
+
{selection.elevationMin != null ? selection.elevationMin.toFixed(1) : "—"}
+
Угол места макс, °
+
{selection.elevationMax != null ? selection.elevationMax.toFixed(1) : "—"}
+
+
+ )} + + {selection.type === "polygon" && selection.layer === "requests" && ( + requestItem ? ( +
+
+
ID
+
{requestItem.id}
+
Название
+
{requestItem.name || "—"}
+
Статус
+
{requestItem.status}
+
Тип съёмки
+
{surveyTypeLabel(requestItem.surveyType)}
+
Начало
+
{formatIso(requestItem.beginDateTime)}
+
Конец
+
{formatIso(requestItem.endDateTime)}
+
Важность
+
{requestItem.importance}
+
Покрытие
+
{requestItem.coveragePercent != null ? `${requestItem.coveragePercent.toFixed(1)} %` : "—"}
+
КПП
+
{requestItem.kpp?.join(", ") || "—"}
+
Приоритет передачи
+
{requestItem.highPriorityTransmit ? "Да" : "Нет"}
+
+
+ ) : ( +
{selection.name || selection.id}
+ ) + )} + + {selection.type === "polygon" && selection.layer === "planWorks" && ( +
+
{selection.name}
+ {selection.status && ( + + + {statusStyle(selection.status).label} + + )} +
+ )} +
+ ); +} + +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 ( +
+ +
Кликните объект в списке, чтобы раскрыть его данные.
+
+ {hits.map((hit, index) => { + const open = hit.id === activeId; + return ( +
+ + {open &&
{renderDetail(hit)}
} +
+ ); + })} +
+
+ ); +} + +/** Класс цветовой метки объекта в списке выбора. */ +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 ( +
+

{title}

+ +
+ ); +} + +/** + * Степпер времени окна маршрута с секундной точностью: кнопки ±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 ( + + ); +} diff --git a/services/pcp-tgu-ui-service/src/features/tgu-editor/EditorRail.tsx b/services/pcp-tgu-ui-service/src/features/tgu-editor/EditorRail.tsx new file mode 100644 index 0000000..b2bbef4 --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-editor/EditorRail.tsx @@ -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; + 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 ( + + ); +} + +function RailStat({ label, value, color }: { label: string; value: number; color?: string }) { + return ( +
+ {value} + {label} +
+ ); +} diff --git a/services/pcp-tgu-ui-service/src/features/tgu-editor/EditorTimeline.tsx b/services/pcp-tgu-ui-service/src/features/tgu-editor/EditorTimeline.tsx new file mode 100644 index 0000000..8aaca74 --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-editor/EditorTimeline.tsx @@ -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; + 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(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) => { + 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) => { + if (suppressClickRef.current) { + suppressClickRef.current = false; + event.stopPropagation(); + event.preventDefault(); + } + }; + + return ( +
+
+
+ Время +
+
+ {days.map((day) => ( +
+ {formatDate(day).split(" ")[0]} + {formatDate(day).split(" ")[1]} +
+ ))} + {ticks.map((tick) => ( +
+ {formatTick(tick, tickStep)} +
+ ))} +
+ +
+ {ticks.map((tick) => ( +
+ ))} +
+ + {planStartMs != null && planStartMs >= timelineWindow.fromMs && planStartMs <= timelineWindow.toMs && ( +
+ Начало плана +
+ )} + + {planEndMs != null && planEndMs >= timelineWindow.fromMs && planEndMs <= timelineWindow.toMs && ( +
+ Конец плана +
+ )} + +
+
+ {activeLabel} + редактируется + Съёмка + Сброс +
+
+ {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 ( + + ); + })} +
+
+ + + + {contextLanes.length > 0 && ( +
+ Другие КА · контекст (read-only) +
+ )} + + {contextLanes.map((lane, index) => ( +
+
+ {lane.label} +
+
+ {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 ( +
+ ); + })} +
+
+ ))} +
+
+ ); +} + +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; +} diff --git a/services/pcp-tgu-ui-service/src/features/tgu-editor/EditorToolbar.tsx b/services/pcp-tgu-ui-service/src/features/tgu-editor/EditorToolbar.tsx new file mode 100644 index 0000000..5668e4c --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-editor/EditorToolbar.tsx @@ -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 ( +
+
+
+ +
+ + +
+ +
+ + +
+ +
+ +
+ + {windowLabel || formatDuration(0)} + +
+ + +
+ ); +} diff --git a/services/pcp-tgu-ui-service/src/features/tgu-editor/MapPassSelector.tsx b/services/pcp-tgu-ui-service/src/features/tgu-editor/MapPassSelector.tsx new file mode 100644 index 0000000..a7eaaf3 --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-editor/MapPassSelector.tsx @@ -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(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) { + e.stopPropagation(); + (e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId); + }, + onPointerMove(e: React.PointerEvent) { + 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) { + (e.currentTarget as HTMLDivElement).releasePointerCapture(e.pointerId); + }, + }); + + const onTrackPointerDown = (e: React.PointerEvent) => { + 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 ( +
+
+ Витки + {fromRev}–{toRev} + + {multiDay ? `${localDM(fromPass.startMs)} ` : ""}{localHM(fromPass.startMs)} + {" – "} + {multiDay ? `${localDM(toPass.startMs)} ` : ""}{localHM(toPass.startMs)} + + {!isFullRange && ( + + )} +
+ +
+ {/* Track line */} +
+
+ {/* Tick marks */} + {passes.map((p) => ( +
= fromIndex && p.index <= toIndex ? " is-active" : ""}`} + style={{ left: `${pct(p.index)}%` }} + /> + ))} + {/* From handle */} +
+ {/* To handle */} +
+
+ +
+
+ ); +} diff --git a/services/pcp-tgu-ui-service/src/features/tgu-editor/TguEditorTab.tsx b/services/pcp-tgu-ui-service/src/features/tgu-editor/TguEditorTab.tsx new file mode 100644 index 0000000..a4335e6 --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-editor/TguEditorTab.tsx @@ -0,0 +1,1734 @@ +import { useEffect, useMemo, useRef, useState, useCallback, type MutableRefObject } from "react"; +import { fetchRequestsForMap } from "../../api/requestApi"; +import { fetchStations, type StationDto } from "../../api/stationsApi"; +import { fetchFlightLine, type FlightLinePoint } from "../../api/tguApi"; +import type { RequestMapItem } from "../../model/requestTypes"; +import { stationPasses, type OrbitPassInfo, type PassRange } from "../tgu-map-2d/geometry/passes"; +import { fetchPointVisibility, fetchSatelliteRva, type PointVisibilityParam, type RvaZone } from "../../api/ballisticsApi"; +import { + buildSurveyRoute, + calculateDrops, + calculateSurveys, + clearPlanModes, + deleteMode, + saveDropRoute, + saveSurveyRoute, + validateMission, + type SurveyRoute +} from "../../api/missionPlanningApi"; +import { abandonDraft, createDraft } from "../../api/tguPlanApi"; +import { parseLocal, toLocalIso } from "../../utils/localTime"; +import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView"; +import { parseWktPolygon } from "../tgu-map-2d/geometry/wktParser"; +import { buildStationMarkers } from "../tgu-map-2d/Tgu2DMapLayers"; +import type { Tgu2DMapLayersState } from "../tgu-map-2d/model/mapLayerTypes"; +import type { Tgu2DMapSelection } from "../tgu-map-2d/model/mapSelectionTypes"; +import type { GeoPoint, MapLine, MapPolygon, Tgu2DMapScene } from "../tgu-map-2d/model/mapTypes"; +import { fetchPlanModes, type PlanMode, type PlanSurveyMode } from "./editorApi"; +import { validationToConflicts } from "./editorValidation"; +import { + addDownlink, + addShoot, + deleteWork, + mutate, + planModesToWorks, + seedDraft +} from "./editorDraft"; +import { checkVisibility, conflictCount, mergeConflictMaps, overlaps, type EditorConflictMap } from "./editorConflicts"; +import { DEFAULT_EDITOR_MODES, formatDuration } from "./editorPresentation"; +import { DropPanel, type DropCandidateSurvey, MapInfoPanel, MapPickPanel, ShootPanel, WorkInspector } from "./EditorPanels"; +import { MapPassSelector } from "./MapPassSelector"; +import { EditorRail } from "./EditorRail"; +import { EditorTimeline } from "./EditorTimeline"; +import { EditorToolbar } from "./EditorToolbar"; +import type { + DraftHistory, + EditorGroundStation, + EditorMode, + EditorTarget, + EditorTimelineWindow, + EditorVisibilityTrack, + EditorVisibilityWindow, + EditorWork, + TguEditorTabProps +} from "./model/editorTypes"; + +type EditorRightMode = "none" | "inspect" | "shoot" | "downlink" | "mapInfo" | "mapPick"; + +const HOUR_MS = 60 * 60 * 1000; +/** + * Отступ окна редактора от границ плана при самом отдалённом масштабе: начало и конец + * плана отстоят на час от краёв видимого окна. Это же — предел «отдаления» (zoom out). + */ +const EDITOR_WINDOW_PAD_MS = HOUR_MS; +/** Минимальная ширина окна редактора при «приближении» (zoom in): ~10 с, чтобы на оси + * появлялись секундные деления. */ +const MIN_EDITOR_WINDOW_MS = 10 * 1000; +/** Фиксированная длительность окна съёмки, строится вокруг момента видимости. */ +const SHOOT_WINDOW_MS = 6 * 60 * 1000; +/** + * Угол захвата (полуширина полосы) по умолчанию, градусы. + * Пока задаётся на фронте так же, как в текущем построении маршрутов; в дальнейшем + * это значение будет определять бэкенд по профилю наблюдения КА. + */ +const DEFAULT_CAPTURE_ANGLE_DEG = 1.5; +/** Дефолтная длительность строящегося маршрута съёмки (мс). */ +const DEFAULT_ROUTE_DURATION_MS = 30_000; +/** + * Предел крена (gamma) маршрута, градусы. Параметры видимости/маршрута бэкенд считает только + * в диапазоне |крен| ≤ 45°; за его пределами геометрия маршрута не определена, поэтому жёстко + * зажимаем здесь — и при ручной правке, и при загрузке сохранённого маршрута в окно построения. + */ +const MAX_ROUTE_ROLL_DEG = 45; +/** Крен с зажимом в допустимый диапазон и округлением до 1 знака (как при ручном вводе). */ +const clampRoll = (deg: number): number => + Math.round(Math.max(-MAX_ROUTE_ROLL_DEG, Math.min(MAX_ROUTE_ROLL_DEG, deg)) * 10) / 10; +/** Допустимый выход окна маршрута за пределы окна видимости, мс (±30 минут). */ +const ROUTE_TIME_MARGIN_MS = 30 * 60_000; +const EDITOR_MAP_LAYERS: Tgu2DMapLayersState = { + tracks: true, + swath: true, + planWorks: true, + complexPlan: false, + stations: true, + spacecraftMarkers: false, + requests: true +}; + +export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, platforms }: TguEditorTabProps) { + const idCounterRef = useRef(0); + const targetCounterRef = useRef(0); + const centerRef = useRef(null); + // Доля высоты под карту: по умолчанию карта крупная, а нижняя панель компактно + // примыкает к низу. Подъём резайзера вверх уменьшает долю и расширяет панель. + const [mapRatio, setMapRatio] = useState(0.8); + const [history, setHistory] = useState(() => ({ + draft: seedDraft(selectedPlan, selectedSpacecraftId), + past: [], + future: [] + })); + const [window, setWindow] = useState(() => defaultEditorWindow(selectedPlan, appliedRange)); + // Предел отдаления: окно не шире, чем план ± час (тот же диапазон, что и дефолтное окно). + const maxWindow = useMemo( + () => + selectedPlan + ? { fromMs: selectedPlan.startMs - EDITOR_WINDOW_PAD_MS, toMs: selectedPlan.endMs + EDITOR_WINDOW_PAD_MS } + : { fromMs: appliedRange.fromMs, toMs: appliedRange.toMs }, + [selectedPlan, appliedRange] + ); + const [rightMode, setRightMode] = useState("none"); + const [selectedWorkId, setSelectedWorkId] = useState(); + // id редактируемого включения: окно построения открыто для правки существующего маршрута, а не + // создания нового. При сохранении старый маршрут заменяется (удаляется в БД + локально). + const [editingWorkId, setEditingWorkId] = useState(); + // Параметры окна/крена, которыми нужно ОДНОКРАТНО предзаполнить окно построения съёмки при правке + // (иначе эффект дефолтного окна затёр бы их при установке прохода). Сбрасывается после применения. + const editShootPrefillRef = useRef<{ startMs: number; endMs: number; roll: number } | null>(null); + // Аналогично для сброса: предвыбранная зона + сбрасываемые съёмки, применяются после входа в режим. + const editDropPrefillRef = useRef<{ zone: RvaZone; surveyIds: number[] } | null>(null); + const [target, setTarget] = useState(); + // Режим прицеливания: карта ждёт клик по опорной точке (перекрестие). Включается явно — кнопкой + // «Указать/Сменить точку», после одного клика гаснет. Иначе карта в обычном режиме («рука»), + // и клики не сбрасывают параметры маршрута. + const [targetPickActive, setTargetPickActive] = useState(false); + const [selectedShootPass, setSelectedShootPass] = useState(); + const [modeId, setModeId] = useState(DEFAULT_EDITOR_MODES[0].id); + const [rvaZones, setRvaZones] = useState([]); + const [rvaLoading, setRvaLoading] = useState(false); + const [rvaError, setRvaError] = useState(); + const [selectedZone, setSelectedZone] = useState(); + const [selectedDropSurveyIds, setSelectedDropSurveyIds] = useState>(new Set()); + const [dropSaving, setDropSaving] = useState(false); + const [dropError, setDropError] = useState(); + const [mapInfoSelection, setMapInfoSelection] = useState(); + // Несколько объектов под точкой клика на карте: список для правой панели + раскрытый элемент. + const [mapPickHits, setMapPickHits] = useState([]); + const [mapPickActiveId, setMapPickActiveId] = useState(); + const [stationDtos, setStationDtos] = useState([]); + const [serviceConflicts, setServiceConflicts] = useState({}); + const [checking, setChecking] = useState(false); + // Идёт серверная операция над всем планом (расчёт съёмки+сброса или полная очистка) — + // блокирует повторный запуск и кнопки правок, пока не перечитаем включения. + const [planCalcRunning, setPlanCalcRunning] = useState(false); + // Открыт диалог выбора режима очистки плана (всё / оставить ручные съёмки). + const [clearDialogOpen, setClearDialogOpen] = useState(false); + const [notice, setNotice] = useState(); + const [applyError, setApplyError] = useState(); + const [contextSpacecraftIds, setContextSpacecraftIds] = useState>(new Set()); + const [requestItems, setRequestItems] = useState([]); + const [planModes, setPlanModes] = useState([]); + // Ленивый черновик (Ф5/Ф6): создаётся клоном базового плана на ПЕРВУЮ правку (replace). + // Пока null — редактор показывает живой план read-only; после создания все правки и валидация + // адресуются в этот DRAFT. Отправка (promote) и удаление черновика делаются на вкладке «Планы». + const [draftPlanId, setDraftPlanId] = useState(); + const [passFrom, setPassFrom] = useState(1); + const [passTo, setPassTo] = useState(999); + const [flightLineData, setFlightLineData] = useState([]); + // Статус загрузки трассы (витков): отличаем «ещё грузится» от «бэк вернул пусто на это окно», + // чтобы при отсутствии данных не было молчаливой пустоты вместо панели витков. + const [flightLineLoading, setFlightLineLoading] = useState(false); + // Команда карте центрироваться/подсветить объект выбранного включения (клик по включению). + const [mapFocus, setMapFocus] = useState<{ point?: GeoPoint; objectId?: string; token: number }>({ token: 0 }); + const draft = history.draft; + const stations = useMemo(() => buildStations(selectedPlan), [selectedPlan]); + const mapRange = useMemo(() => { + const center = (window.fromMs + window.toMs) / 2; + return { fromMs: center - MAP_TRACK_WINDOW_MS / 2, toMs: center + MAP_TRACK_WINDOW_MS / 2 }; + }, [window.fromMs, window.toMs]); + const noradId = useMemo(() => { + if (!selectedSpacecraftId) return undefined; + const platform = platforms.find((p) => p.spacecraftId === selectedSpacecraftId); + return platform?.noradId ?? undefined; + }, [selectedSpacecraftId, platforms]); + const orbitPasses = useMemo(() => { + if (flightLineData.length === 0) return []; + const groups = new Map(); + for (const pt of flightLineData) { + const arr = groups.get(pt.revolution) ?? []; + arr.push(pt); + groups.set(pt.revolution, arr); + } + return Array.from(groups.entries()) + .sort(([a], [b]) => a - b) + .map(([revolution, pts], idx) => ({ + index: idx + 1, + revolution, + startMs: parseLocal(pts[0].time), + endMs: parseLocal(pts[pts.length - 1].time), + })); + }, [flightLineData]); + const modes: EditorMode[] = DEFAULT_EDITOR_MODES; + // Существующие включения плана (read-only) + правки из черновика — то, что видно на таймлайне. + const planWorks = useMemo(() => planModesToWorks(planModes), [planModes]); + // id режимов, уже представленных локальной (редактируемой) работой черновика — чтобы не дублировать + // только что сохранённое включение его plan-origin копией из перечитанных planModes. + const addedModeIds = useMemo( + () => new Set(draft.works.map((work) => work.planModeId).filter((id): id is number => id != null)), + [draft.works] + ); + const displayWorks = useMemo( + () => [ + ...planWorks.filter((work) => work.planModeId == null || !addedModeIds.has(work.planModeId)), + ...draft.works + ], + [planWorks, draft.works, addedModeIds] + ); + const liveConflicts = useMemo(() => overlaps(draft.works), [draft.works]); + const conflicts = useMemo(() => mergeConflictMaps(liveConflicts, serviceConflicts), [liveConflicts, serviceConflicts]); + const selectedWork = displayWorks.find((work) => work.id === selectedWorkId); + // Редактируемое включение и id его режима в БД: на время правки прячем его и из сцены карты, и с + // таймлайна — иначе исходный контур виден одновременно с живым превью (карта 2, таймлайн 1). + const editingWork = useMemo( + () => (editingWorkId ? displayWorks.find((work) => work.id === editingWorkId) : undefined), + [editingWorkId, displayWorks] + ); + const editingModeId = editingWork?.planModeId; + // Включения для таймлайна: при правке исключаем редактируемое (его место занимает превью на карте). + const timelineWorks = useMemo( + () => (editingWorkId ? displayWorks.filter((work) => work.id !== editingWorkId) : displayWorks), + [displayWorks, editingWorkId] + ); + // Режимы для сцены карты: при правке исключаем контур редактируемого режима (показываем превью). + const sceneModes = useMemo( + () => (editingModeId != null ? planModes.filter((mode) => mode.id !== editingModeId) : planModes), + [planModes, editingModeId] + ); + const activeLabel = platformLabel(platforms, selectedSpacecraftId); + const [shootPasses, setShootPasses] = useState([]); + const [shootPassesLoading, setShootPassesLoading] = useState(false); + const [shootPassesError, setShootPassesError] = useState(); + // Окно строящегося маршрута съёмки: задаётся вручную в правой панели (начало/конец, локальное). + const [routeWindow, setRouteWindow] = useState<{ startMs: number; endMs: number }>(); + // Крен (gamma) строящегося маршрута, градусы. По умолчанию — из прохода, дальше правится вручную. + const [routeRoll, setRouteRoll] = useState(); + const [surveyRoute, setSurveyRoute] = useState(); + const [routeLoading, setRouteLoading] = useState(false); + const [routeError, setRouteError] = useState(); + const visibilityTracks = useMemo( + () => buildVisibilityTracks(target, shootPasses, selectedWork, stations, selectedSpacecraftId, window), + [selectedSpacecraftId, selectedWork, shootPasses, stations, target, window] + ); + const effectiveFrom = Math.max(1, Math.min(passFrom, orbitPasses.length || 1)); + const effectiveTo = Math.max(effectiveFrom, Math.min(passTo, orbitPasses.length || 1)); + const mapScene = useMemo( + () => buildEditorMapScene(selectedSpacecraftId, flightLineData, orbitPasses, stationDtos, requestItems, sceneModes, effectiveFrom, effectiveTo, surveyRoute), + [selectedSpacecraftId, flightLineData, orbitPasses, stationDtos, requestItems, sceneModes, effectiveFrom, effectiveTo, surveyRoute] + ); + // Клик по включению = «как клик по объекту на карте»: центрируем карту на нём и подсвечиваем. + const focusWorkOnMap = useCallback( + (workId: string) => { + const work = displayWorks.find((w) => w.id === workId); + const focus = workMapFocus(work, mapScene); + setMapFocus((prev) => ({ point: focus.point, objectId: focus.objectId, token: prev.token + 1 })); + }, + [displayWorks, mapScene] + ); + const contextLanes = useMemo( + () => + Array.from(contextSpacecraftIds).map((spacecraftId) => ({ + spacecraftId, + label: platformLabel(platforms, spacecraftId), + works: [] + })), + [contextSpacecraftIds, platforms] + ); + // Кандидаты на сброс: маршруты съёмки плана, заканчивающиеся строго до начала выбранной зоны. + const dropCandidates = useMemo(() => { + if (!selectedZone) return []; + const zoneStart = localDateTimeMs(selectedZone.onStart.time); + return planModes + .filter((m): m is PlanSurveyMode => m.type === "SURVEY" && m.timeStart != null) + .map((m) => { + const startMs = localDateTimeMs(m.timeStart as string); + return { + id: m.id, + label: `Съёмка витка ${m.revolution}`, + startMs, + endMs: startMs + m.duration * 1000 + }; + }) + .filter((c) => c.endMs < zoneStart) + .sort((a, b) => a.startMs - b.startMs); + }, [planModes, selectedZone]); + + // Реальный состав плана берём из включений (plan modes), а не из черновика: + // черновик сидируется заглушкой, когда у плана нет встроенных works. + const planSurveyCount = useMemo(() => planModes.filter((mode) => mode.type === "SURVEY").length, [planModes]); + const planDownlinkCount = useMemo(() => planModes.filter((mode) => mode.type === "DROP").length, [planModes]); + + // Первая правка живого плана клонирует его в DRAFT (replace). Бэк это запрещает (422) для + // заложенного (LAID_IN) или уже стартовавшего плана — нужен fork-forward со вкладки «Планы». + // Предупреждаем заранее и гасим «Добавить», чтобы не доводить до ошибки сохранения. Если + // черновик уже создан (draftPlanId) или сам план — DRAFT, правки идут напрямую и блок не нужен. + const inPlaceEditBlockedReason = useMemo(() => { + if (!selectedPlan || draftPlanId || selectedPlan.status === "DRAFT") return undefined; + const blocked = selectedPlan.status === "LAID_IN" || selectedPlan.startMs <= Date.now(); + return blocked + ? "План заложен или уже стартовал — править его на месте нельзя. Создайте исправленный план через fork-forward на вкладке «Планы»." + : undefined; + }, [selectedPlan, draftPlanId]); + + useEffect(() => { + setHistory({ draft: seedDraft(selectedPlan, selectedSpacecraftId), past: [], future: [] }); + setWindow(defaultEditorWindow(selectedPlan, appliedRange)); + setSelectedWorkId(undefined); + setEditingWorkId(undefined); + setRightMode("none"); + setTarget(undefined); + setTargetPickActive(false); + setSelectedShootPass(undefined); + setSurveyRoute(undefined); + setRouteError(undefined); + setSelectedZone(undefined); + setSelectedDropSurveyIds(new Set()); + setDropError(undefined); + setRvaZones([]); + setMapInfoSelection(undefined); + setMapPickHits([]); + setMapPickActiveId(undefined); + setServiceConflicts({}); + setNotice(undefined); + setApplyError(undefined); + setPlanModes([]); + setPassFrom(1); + setPassTo(999); + }, [appliedRange, selectedPlan, selectedSpacecraftId]); + + // Линия трассы (витки) живёт исключительно по своим входам запроса — noradId и mapRange. + // НЕ сбрасываем её в общем reset-эффекте по ссылке selectedPlan: фоновый loadData (после + // решения/создания черновика/обновления интервала) пересобирает объект selectedPlan с теми же + // временами окна, mapRange по значению не меняется, и фетч бы не перезапустился — витки + // молча пропадали бы до реальной смены окна. Привязка к [noradId, mapRange] делает это + // идемпотентным: сброс и перезагрузка происходят вместе и только когда меняется само окно/КА. + useEffect(() => { + if (!noradId) { + setFlightLineData([]); + setFlightLineLoading(false); + return; + } + let cancelled = false; + setFlightLineLoading(true); + void fetchFlightLine(noradId, mapRange.fromMs, mapRange.toMs) + .then((data) => { if (!cancelled) setFlightLineData(data); }) + .catch(() => { if (!cancelled) setFlightLineData([]); }) + .finally(() => { if (!cancelled) setFlightLineLoading(false); }); + return () => { cancelled = true; }; + }, [noradId, mapRange]); + + useEffect(() => { + const controller = new AbortController(); + void fetchRequestsForMap({ + endFrom: toLocalIso(window.fromMs), + beginTo: toLocalIso(window.toMs) + }).then((result) => { + if (!controller.signal.aborted) { + setRequestItems(result.items); + } + }).catch(() => { + // silently ignore — requests are supplementary + }); + return () => controller.abort(); + }, [window.fromMs, window.toMs]); + + useEffect(() => { + let cancelled = false; + void fetchStations() + .then((items) => { if (!cancelled) setStationDtos(items); }) + .catch(() => { if (!cancelled) setStationDtos([]); }); + return () => { cancelled = true; }; + }, []); + + useEffect(() => { + const numericNorad = noradId ? Number(noradId) : NaN; + if (rightMode !== "shoot" || !target || !Number.isFinite(numericNorad)) { + setShootPasses([]); + setShootPassesError(noradId ? undefined : target ? "Не задан NORAD-идентификатор КА." : undefined); + setShootPassesLoading(false); + return; + } + + let cancelled = false; + setShootPassesLoading(true); + setShootPassesError(undefined); + void fetchPointVisibility( + numericNorad, + { id: target.id, lat: target.lat, lon: target.lon }, + window.fromMs, + window.toMs + ) + .then((params) => { + if (cancelled) return; + setShootPasses( + params.map(visibilityWindowFromParam).sort((a, b) => a.startMs - b.startMs) + ); + }) + .catch((error) => { + if (cancelled) return; + setShootPasses([]); + setShootPassesError(error instanceof Error ? error.message : "Не удалось получить параметры видимости."); + }) + .finally(() => { + if (!cancelled) setShootPassesLoading(false); + }); + return () => { + cancelled = true; + }; + }, [rightMode, target, noradId, window.fromMs, window.toMs]); + + // При выборе нового прохода задаём дефолтное окно маршрута: 30 c вокруг момента + // видимости, обрезанное окном видимости прохода. Дальше пользователь правит вручную. + useEffect(() => { + if (rightMode !== "shoot" || !selectedShootPass) { + setRouteWindow(undefined); + setRouteRoll(undefined); + return; + } + // Правка существующего маршрута: подставляем его окно/крен один раз, не центрируем по проходу. + const prefill = editShootPrefillRef.current; + if (prefill) { + editShootPrefillRef.current = null; + setRouteWindow({ startMs: prefill.startMs, endMs: prefill.endMs }); + // Сохранённый маршрут мог быть с креном вне ±45° (легаси) — зажимаем при загрузке в окно. + setRouteRoll(clampRoll(prefill.roll)); + return; + } + const passDurationMs = selectedShootPass.endMs - selectedShootPass.startMs; + const half = Math.min(DEFAULT_ROUTE_DURATION_MS, passDurationMs) / 2; + const mid = (selectedShootPass.startMs + selectedShootPass.endMs) / 2; + setRouteWindow({ startMs: mid - half, endMs: mid + half }); + setRouteRoll(clampRoll(selectedShootPass.rollDeg ?? 0)); + }, [rightMode, selectedShootPass]); + + useEffect(() => { + const numericNorad = noradId ? Number(noradId) : NaN; + if (rightMode !== "shoot" || !target || !selectedShootPass || !routeWindow || routeRoll == null || !Number.isFinite(numericNorad)) { + setSurveyRoute(undefined); + setRouteError(undefined); + setRouteLoading(false); + return; + } + + const durationSeconds = Math.max(1, (routeWindow.endMs - routeWindow.startMs) / 1000); + + let cancelled = false; + setRouteLoading(true); + setRouteError(undefined); + void buildSurveyRoute({ + satelliteId: numericNorad, + // Окно задано явно (начало/конец) — строим вперёд от начала на выбранную длительность. + time: toLocalIso(routeWindow.startMs), + durationSeconds, + roll: routeRoll, + captureAngle: DEFAULT_CAPTURE_ANGLE_DEG, + revolution: selectedShootPass.revolution ?? 0, + anchor: "FORWARD" + }) + .then((route) => { + if (!cancelled) setSurveyRoute(route); + }) + .catch((error) => { + if (cancelled) return; + setSurveyRoute(undefined); + setRouteError(error instanceof Error ? error.message : "Не удалось построить маршрут съёмки."); + }) + .finally(() => { + if (!cancelled) setRouteLoading(false); + }); + return () => { + cancelled = true; + }; + }, [rightMode, target, selectedShootPass, noradId, routeWindow, routeRoll]); + + // Зоны радиовидимости (ЗРВ) для ручного сброса — грузим при входе в режим «Сброс». + useEffect(() => { + const numericNorad = noradId ? Number(noradId) : NaN; + if (rightMode !== "downlink" || !Number.isFinite(numericNorad)) { + setRvaZones([]); + setRvaError(rightMode === "downlink" && !noradId ? "Не задан NORAD-идентификатор КА." : undefined); + setRvaLoading(false); + setSelectedZone(undefined); + setSelectedDropSurveyIds(new Set()); + return; + } + + let cancelled = false; + setRvaLoading(true); + setRvaError(undefined); + // Правка существующего сброса: подставляем его зону и сбрасываемые съёмки один раз. + const prefill = editDropPrefillRef.current; + editDropPrefillRef.current = null; + if (prefill) { + setSelectedZone(prefill.zone); + setSelectedDropSurveyIds(new Set(prefill.surveyIds)); + } else { + setSelectedZone(undefined); + setSelectedDropSurveyIds(new Set()); + } + void fetchSatelliteRva(numericNorad, window.fromMs, window.toMs) + .then((zones) => { + if (!cancelled) setRvaZones(zones); + }) + .catch((error) => { + if (cancelled) return; + setRvaZones([]); + setRvaError(error instanceof Error ? error.message : "Не удалось получить зоны радиовидимости."); + }) + .finally(() => { + if (!cancelled) setRvaLoading(false); + }); + return () => { + cancelled = true; + }; + }, [rightMode, noradId, window.fromMs, window.toMs]); + + // Если выбран уже существующий черновик (status DRAFT — из режима «Черновики»/диалога цепочки), + // редактор работает с ним напрямую; иначе черновик создаётся лениво на первую правку (replace). + // createdImplicitlyRef различает эти случаи: авто-удалять при уходе можно только неявно созданный. + const createdImplicitlyRef = useRef(false); + useEffect(() => { + setDraftPlanId(selectedPlan?.status === "DRAFT" ? selectedPlan.planId : undefined); + createdImplicitlyRef.current = false; + setServiceConflicts({}); + }, [selectedPlan]); + + // Зеркало draftPlanId в ref — чтобы при уходе/смене плана отменить незавершённый черновик, + // не оставляя осиротевший DRAFT (он бы заблокировал createDraft по той же базе на 409). + const draftPlanIdRef = useRef(undefined); + useEffect(() => { draftPlanIdRef.current = draftPlanId; }, [draftPlanId]); + useEffect(() => () => { + const stale = draftPlanIdRef.current; + // Авто-отмена только для неявно созданного черновика; явно открытый DRAFT не трогаем. + if (stale && createdImplicitlyRef.current) { + // best-effort: применённый/уже отменённый черновик сюда не попадёт (ref обнулён). + void abandonDraft(stale).catch(() => {}); + } + }, [selectedPlan]); + + // Источник истины для включений — DRAFT, если он уже создан, иначе живой план (read-only превью). + useEffect(() => { + // Нет выбранного плана (например, открытый черновик удалили) — чистим включения и НЕ тянем + // режимы по устаревшему draftPlanId. Он обнулится следующим коммитом (эффект выше), но в этом + // ещё указывает на удалённый черновик; без гарда fetchPlanModes(удалённый id) вернул бы его + // включения обратно — редактор «не чистился» бы после удаления. + if (!selectedPlan) { + setPlanModes([]); + return; + } + const missionId = draftPlanId ?? selectedPlan.planId; + let cancelled = false; + void fetchPlanModes(missionId).then((modes) => { + if (!cancelled) setPlanModes(modes); + }).catch(() => { + // silently ignore — plan modes are supplementary display data + }); + return () => { cancelled = true; }; + }, [selectedPlan, draftPlanId]); + + const changeDraft = (transform: (draft: DraftHistory["draft"]) => DraftHistory["draft"]) => { + setHistory((current) => mutate(current, transform)); + setServiceConflicts({}); + setNotice(undefined); + setApplyError(undefined); + }; + + // «Редактировать» включение: открываем то же окно, что и при построении, предзаполнив его + // параметрами включения. Сохранение заменит исходный маршрут (см. addShootWork/addDropWork). + const beginEditWork = (workId: string) => { + const work = displayWorks.find((item) => item.id === workId); + if (!work) return; + setEditingWorkId(workId); + setSelectedWorkId(undefined); + setMapInfoSelection(undefined); + clearMapPick(); + setNotice(undefined); + setApplyError(undefined); + + if (work.kind === "shoot") { + const lat = work.targetLat ?? work.centerLat; + const lon = work.targetLon ?? work.centerLon; + setTarget( + lat != null && lon != null + ? { id: `edit-${workId}`, name: work.targetName ?? "Цель съёмки", lat, lon } + : undefined + ); + // Окно/крен подставит эффект дефолтного окна по этому ref (иначе он бы их центрировал). + editShootPrefillRef.current = { startMs: work.startMs, endMs: work.endMs, roll: work.rollDeg ?? 0 }; + // Синтетический «проход» задаёт границы окна маршрута (±30 мин) и виток для пересчёта маршрута. + setSelectedShootPass({ + startMs: work.startMs, + endMs: work.endMs, + quality: 1, + revolution: work.revolution, + rollDeg: work.rollDeg + }); + setModeId(work.modeId ?? DEFAULT_EDITOR_MODES[0].id); + // Правка начинается без прицеливания: точка уже есть, карта в режиме «рука». + setTargetPickActive(false); + setRightMode("shoot"); + return; + } + + // Сброс: синтезируем зону из включения (для отображения и сохранения) и предвыбираем съёмки. + const stationNum = Number(work.stationId); + const midMs = (work.startMs + work.endMs) / 2; + const zone: RvaZone = { + noradId: noradId ? Number(noradId) : 0, + stationId: Number.isFinite(stationNum) ? stationNum : 0, + revolution: work.revolution ?? 0, + onStart: { time: toLocalIso(work.startMs).slice(0, 19), elevation: 0, azimuth: 0, range: 0 }, + onMaximum: { time: toLocalIso(midMs).slice(0, 19), elevation: 0, azimuth: 0, range: 0 }, + onStop: { time: toLocalIso(work.endMs).slice(0, 19), elevation: 0, azimuth: 0, range: 0 }, + duration: Math.max(1, (work.endMs - work.startMs) / 1000) + }; + editDropPrefillRef.current = { zone, surveyIds: work.surveyIds ?? [] }; + setRightMode("downlink"); + }; + + // Гарантирует наличие черновика: при первой правке клонирует текущий план в DRAFT (replace, тот же + // слот) и возвращает его id. Дальше все правки/валидация/promote адресуются именно сюда. Создание + // плана на другую ЗРВ / новой цепочки вынесено во вкладку «Планы». + const ensureDraft = useCallback(async (): Promise => { + if (draftPlanId) return draftPlanId; + if (!selectedPlan) throw new Error("План не выбран — нечего редактировать."); + // Выбран уже существующий черновик — правим его напрямую, новый клон не создаём. + if (selectedPlan.status === "DRAFT") { + setDraftPlanId(selectedPlan.planId); + return selectedPlan.planId; + } + const created = await createDraft(selectedPlan.planId, { mode: "replace" }); + createdImplicitlyRef.current = true; + setDraftPlanId(created.draftPlanId); + return created.draftPlanId; + }, [draftPlanId, selectedPlan]); + + // Бэкенд-валидация черновика (Ф4): авторитетные конфликты по режимам → карта по workId. + // MVP-заглушка возвращает пусто; ошибки валидации не эскалируем в основной поток правок. + const revalidate = useCallback(async (missionId: string, works: EditorWork[]) => { + try { + const validation = await validateMission(missionId); + setServiceConflicts(validationToConflicts(validation, works)); + } catch { + // валидация вспомогательна — молча игнорируем сбой + } + }, []); + + // После каждой серверной правки перечитываем включения DRAFT (источник истины) и ревалидируем. + const refreshDraftModes = useCallback(async (missionId: string) => { + const modes = await fetchPlanModes(missionId); + setPlanModes(modes); + void revalidate(missionId, planModesToWorks(modes)); + }, [revalidate]); + + // Удаляет из черновика маршрут редактируемого включения (замена при правке). Свежепостроенное или + // уже-в-черновике включение адресуется по planModeId; включение из живого плана после клонирования + // получает новый id в черновике — находим его по типу/витку/времени старта. + const deleteEditedModeFromDraft = async ( + draftId: string, + draftExistedBefore: boolean, + work: EditorWork, + type: "SURVEY" | "DROP" + ) => { + if (work.planModeId != null && draftExistedBefore) { + await deleteMode(work.planModeId).catch(() => {}); + return; + } + const draftModes = await fetchPlanModes(draftId); + const match = draftModes.find( + (m) => + m.type === type && + m.revolution === work.revolution && + m.timeStart != null && + Math.abs(parseLocal(m.timeStart) - work.startMs) < 2000 + ); + if (match) await deleteMode(match.id).catch(() => {}); + }; + + const addShootWork = async () => { + if (!target || !selectedShootPass || !modeId) return; + + // Правка существующего маршрута: его исходную версию удаляем при сохранении (замена). + const editId = editingWorkId; + const editWork = editId ? displayWorks.find((item) => item.id === editId) : undefined; + const draftExistedBefore = draftPlanId != null; + + // Персистенция построенного маршрута съёмки в pcp-mission-planing-service по id + // выбранного плана (planId == mission_id). Сохраняем только если план выбран и маршрут + // уже построен; иначе (открыт только КА без плана) добавляем включение без сохранения. + const numericNorad = noradId ? Number(noradId) : NaN; + // Маршрут, сохранённый в БД черновика (несёт surveyModeId для последующего delete/patch). + let savedRoute: SurveyRoute | undefined = surveyRoute; + if (selectedPlan && surveyRoute && routeWindow && routeRoll != null && Number.isFinite(numericNorad)) { + const routeDurationSeconds = Math.max(1, (routeWindow.endMs - routeWindow.startMs) / 1000); + setRouteLoading(true); + setRouteError(undefined); + try { + // Ленивое создание черновика на первую правку; адресуем съёмку в DRAFT, а не в живой план. + const draftId = await ensureDraft(); + // Заменяем: сносим исходный маршрут редактируемого включения перед сохранением нового. + if (editWork) await deleteEditedModeFromDraft(draftId, draftExistedBefore, editWork, "SURVEY"); + savedRoute = await saveSurveyRoute(draftId, { + satelliteId: numericNorad, + time: toLocalIso(routeWindow.startMs), + durationSeconds: routeDurationSeconds, + roll: routeRoll, + captureAngle: DEFAULT_CAPTURE_ANGLE_DEG, + revolution: selectedShootPass.revolution ?? 0, + anchor: "FORWARD", + // Окно выбранного плана — чтобы при автосоздании миссии/плана задать корректный интервал. + planStart: toLocalIso(selectedPlan.startMs), + planEnd: toLocalIso(selectedPlan.endMs) + }); + setSurveyRoute(savedRoute); + void refreshDraftModes(draftId); + } catch (error) { + setRouteError(error instanceof Error ? error.message : "Не удалось сохранить маршрут съёмки."); + return; + } finally { + setRouteLoading(false); + } + } + + // Окно включения в черновике — заданное вручную окно маршрута (фолбэк — окно прохода). + const startMs = routeWindow?.startMs ?? selectedShootPass.startMs; + const endMs = routeWindow?.endMs ?? selectedShootPass.endMs; + const mode = modes.find((item) => item.id === modeId); + const id = nextWorkId(idCounterRef); + + changeDraft((current) => + addShoot(editId ? deleteWork(current, editId) : current, { + id, + startMs, + endMs, + label: mode?.label ?? "Съёмка", + modeId, + target, + // id сохранённого режима съёмки — чтобы удаление/правка адресовались в БД черновика. + planModeId: savedRoute?.surveyModeId, + revolution: savedRoute?.revolution ?? selectedShootPass.revolution, + rollDeg: savedRoute?.roll ?? routeRoll, + captureAngleDeg: savedRoute?.captureAngle ?? DEFAULT_CAPTURE_ANGLE_DEG, + centerLat: savedRoute?.lat, + centerLon: savedRoute?.longitude, + volumeMb: savedRoute?.volumeMb + }) + ); + setEditingWorkId(undefined); + setSelectedWorkId(id); + setRightMode("inspect"); + setTarget(undefined); + setTargetPickActive(false); + setSelectedShootPass(undefined); + }; + + const stationNameById = useCallback( + (stationId: number) => + stationDtos.find((s) => s.number === stationId)?.name ?? `Станция ${stationId}`, + [stationDtos] + ); + + // Метка съёмки по id режима — для перечня сбрасываемых маршрутов в инспекторе сброса. + const surveyModeLabel = useCallback( + (surveyModeId: number) => { + const mode = planModes.find( + (m): m is PlanSurveyMode => m.type === "SURVEY" && m.id === surveyModeId + ); + return mode ? `Съёмка · виток ${mode.revolution}` : `Маршрут #${surveyModeId}`; + }, + [planModes] + ); + + const addDropWork = async () => { + if (!selectedPlan || !selectedZone || selectedDropSurveyIds.size === 0) return; + const numericNorad = noradId ? Number(noradId) : NaN; + if (!Number.isFinite(numericNorad)) { + setDropError("Не задан NORAD-идентификатор КА."); + return; + } + + const zoneStartMsVal = localDateTimeMs(selectedZone.onStart.time); + const zoneStopMsVal = localDateTimeMs(selectedZone.onStop.time); + + // Правка существующего сброса: его исходную версию удаляем при сохранении (замена). + const editId = editingWorkId; + const editWork = editId ? displayWorks.find((item) => item.id === editId) : undefined; + const draftExistedBefore = draftPlanId != null; + + setDropSaving(true); + setDropError(undefined); + let savedDropId: number | undefined; + let savedDropVolumeMb: number | undefined; + try { + // Ленивое создание черновика на первую правку; адресуем сброс в DRAFT, а не в живой план. + const draftId = await ensureDraft(); + // Заменяем: сносим исходный сброс редактируемого включения перед сохранением нового. + if (editWork) await deleteEditedModeFromDraft(draftId, draftExistedBefore, editWork, "DROP"); + const savedDrop = await saveDropRoute(draftId, { + satelliteId: numericNorad, + station: String(selectedZone.stationId), + revolution: selectedZone.revolution, + // Зона — LocalDateTime без зоны (как вернула баллистика). + zoneStart: selectedZone.onStart.time.slice(0, 19), + zoneStop: selectedZone.onStop.time.slice(0, 19), + surveyModeIds: [...selectedDropSurveyIds], + // Окно выбранного плана — для автосоздания миссии/плана с корректным интервалом. + planStart: toLocalIso(selectedPlan.startMs), + planEnd: toLocalIso(selectedPlan.endMs) + }); + savedDropId = savedDrop.id; + savedDropVolumeMb = savedDrop.volumeMb; + // Перечитываем включения DRAFT (источник истины) и ревалидируем. + void refreshDraftModes(draftId); + } catch (error) { + setDropError(error instanceof Error ? error.message : "Не удалось сохранить сброс."); + return; + } finally { + setDropSaving(false); + } + + const stationNm = stationNameById(selectedZone.stationId); + const id = nextWorkId(idCounterRef); + changeDraft((current) => + addDownlink(editId ? deleteWork(current, editId) : current, { + id, + startMs: zoneStartMsVal, + endMs: zoneStopMsVal, + label: `Сброс · ${stationNm}`, + station: { id: String(selectedZone.stationId), name: stationNm }, + planModeId: savedDropId, + volumeMb: savedDropVolumeMb + }) + ); + setEditingWorkId(undefined); + setSelectedWorkId(id); + setRightMode("inspect"); + setSelectedZone(undefined); + setSelectedDropSurveyIds(new Set()); + }; + + // «Проверить»: если черновик уже создан — авторитетная бэкенд-валидация (Ф4); иначе локальное + // превью видимости (черновика на сервере ещё нет — правок, адресованных в DRAFT, не было). + const runCheck = async () => { + setChecking(true); + setApplyError(undefined); + try { + if (draftPlanId) { + const validation = await validateMission(draftPlanId); + setServiceConflicts(validationToConflicts(validation, planWorks)); + setNotice("Проверка выполнена на сервере (валидация черновика)."); + } else { + setServiceConflicts(checkVisibility(draft, { spacecraftId: selectedSpacecraftId, stations })); + setNotice("Проверка видимости выполнена для текущего черновика."); + } + } catch (error) { + setApplyError(error instanceof Error ? error.message : "Не удалось выполнить проверку."); + } finally { + setChecking(false); + } + }; + + // Запуск расчёта плана съёмки и сброса как одной операции (вкладка «Редактор»). Адресуем + // в DRAFT (ленивое создание на первую правку), затем перечитываем включения. Пересчёт на бэке + // сохраняет ручные маршруты съёмки (source=MANUAL) — они не затираются. Источник комплексного + // плана (snapshot этапа 4) бэкенд выбирает сам по интервалу плана. + const runPlanCalculation = async () => { + if (!selectedPlan || planCalcRunning) return; + setPlanCalcRunning(true); + setApplyError(undefined); + setNotice(undefined); + try { + const draftId = await ensureDraft(); + const surveyResult = await calculateSurveys(draftId); + await calculateDrops(draftId); + await refreshDraftModes(draftId); + setNotice( + `Расчёт выполнен: съёмок ${surveyResult.surveyModesCount} (ручных сохранено ${surveyResult.manualModesCount}); сбросы пересчитаны.` + ); + } catch (error) { + setApplyError(error instanceof Error ? error.message : "Не удалось рассчитать план."); + } finally { + setPlanCalcRunning(false); + } + }; + + // Очистка плана. Оператор выбирает в диалоге, что снести: всё (включая ручные маршруты) или + // только рассчитанное (ручные съёмки source=MANUAL остаются, как при пересчёте плана). Сбросы + // удаляются в любом случае. Меняем БД черновика и локальное представление. Необратимо. + const clearPlan = async (keepManual: boolean) => { + if (!selectedPlan || planCalcRunning) return; + setClearDialogOpen(false); + setPlanCalcRunning(true); + setApplyError(undefined); + setNotice(undefined); + try { + const draftId = await ensureDraft(); + await clearPlanModes(draftId, keepManual); + await refreshDraftModes(draftId); + changeDraft((current) => ({ ...current, works: [] })); + setSelectedWorkId(undefined); + setEditingWorkId(undefined); + setRightMode("none"); + setNotice( + keepManual + ? "Рассчитанные включения удалены. Ручные маршруты съёмки сохранены." + : "План полностью очищен." + ); + } catch (error) { + setApplyError(error instanceof Error ? error.message : "Не удалось очистить план."); + } finally { + setPlanCalcRunning(false); + } + }; + + // Удаление включения (правая панель, с подтверждением): персистнутые режимы (с planModeId) + // удаляются в БД черновика (Ф3, с каскадами drop↔survey), затем убираются из локального + // представления. Plan-origin включения адресуются по planModeId только когда черновик уже + // существует (draftPlanId): тогда включения принадлежат именно ему. Несохранённые (planModeId + // нет) — только локально. + const handleDeleteWork = async (workId: string) => { + const work = displayWorks.find((item) => item.id === workId); + if (!work) return; + const confirmMessage = + work.kind === "downlink" + ? "Удалить включение сброса? Действие необратимо." + : "Удалить маршрут съёмки? Действие необратимо."; + if (!globalThis.confirm(confirmMessage)) return; + if (work.planModeId != null && draftPlanId) { + try { + await deleteMode(work.planModeId); + await refreshDraftModes(draftPlanId); + } catch (error) { + setApplyError(error instanceof Error ? error.message : "Не удалось удалить включение."); + return; + } + } + changeDraft((current) => deleteWork(current, workId)); + setSelectedWorkId(undefined); + if (editingWorkId === workId) setEditingWorkId(undefined); + setRightMode("none"); + }; + + // Масштабирование окна относительно точки-якоря (центр окна для кнопок, точка под + // курсором для колеса мыши). Ширину зажимаем в [MIN_EDITOR_WINDOW_MS; ширина maxWindow], + // затем двигаем окно так, чтобы оно не вышло за план ± час. + const zoomAt = useCallback( + (factor: number, anchorMs: number) => { + setWindow((current) => { + const span = current.toMs - current.fromMs; + const maxSpan = maxWindow.toMs - maxWindow.fromMs; + const nextSpan = Math.min(Math.max(span * factor, MIN_EDITOR_WINDOW_MS), maxSpan); + const ratio = span > 0 ? (anchorMs - current.fromMs) / span : 0.5; + let fromMs = anchorMs - ratio * nextSpan; + let toMs = fromMs + nextSpan; + if (fromMs < maxWindow.fromMs) { + fromMs = maxWindow.fromMs; + toMs = fromMs + nextSpan; + } + if (toMs > maxWindow.toMs) { + toMs = maxWindow.toMs; + fromMs = toMs - nextSpan; + } + return { fromMs: Math.max(fromMs, maxWindow.fromMs), toMs }; + }); + }, + [maxWindow] + ); + + const zoom = useCallback( + (factor: number) => { + setWindow((current) => { + const center = (current.fromMs + current.toMs) / 2; + const span = current.toMs - current.fromMs; + const maxSpan = maxWindow.toMs - maxWindow.fromMs; + const nextSpan = Math.min(Math.max(span * factor, MIN_EDITOR_WINDOW_MS), maxSpan); + let fromMs = center - nextSpan / 2; + let toMs = center + nextSpan / 2; + if (fromMs < maxWindow.fromMs) { + fromMs = maxWindow.fromMs; + toMs = fromMs + nextSpan; + } + if (toMs > maxWindow.toMs) { + toMs = maxWindow.toMs; + fromMs = toMs - nextSpan; + } + return { fromMs: Math.max(fromMs, maxWindow.fromMs), toMs }; + }); + }, + [maxWindow] + ); + + // Горизонтальный сдвиг окна перетаскиванием: ширину окна не меняем, левую границу зажимаем + // в [maxWindow.fromMs; maxWindow.toMs − ширина], чтобы не выехать за план ± час. + const panTo = useCallback( + (fromMs: number) => { + setWindow((current) => { + const span = current.toMs - current.fromMs; + let from = Math.min(Math.max(fromMs, maxWindow.fromMs), maxWindow.toMs - span); + if (from < maxWindow.fromMs) from = maxWindow.fromMs; + return { fromMs: from, toMs: from + span }; + }); + }, + [maxWindow] + ); + + // Включение (съёмка/сброс), соответствующее объекту карты: контур маршрута (planWorks) или + // линия сброса (drop) → работа таймлайна по modeId ↔ planModeId. Прочие объекты → undefined. + const workForSelection = useCallback( + (selection: Tgu2DMapSelection): EditorWork | undefined => { + const modeId = + selection.type === "polygon" && selection.layer === "planWorks" + ? selection.modeId + : selection.type === "line" && selection.kind === "drop" + ? selection.modeId + : undefined; + if (modeId == null) return undefined; + return displayWorks.find((w) => w.planModeId === modeId); + }, + [displayWorks] + ); + + const clearMapPick = useCallback(() => { + setMapPickHits([]); + setMapPickActiveId(undefined); + }, []); + + const handleMapObjectSelect = useCallback((selection?: Tgu2DMapSelection) => { + if (rightMode === "shoot") return; + clearMapPick(); + if (!selection) { + if (rightMode === "mapInfo" || rightMode === "mapPick") { + setRightMode("none"); + setMapInfoSelection(undefined); + } + return; + } + // Клик по маршруту съёмки (контур) или сбросу (линия) на карте = клик по включению на + // таймлайне: открываем тот же инспектор включения (одинаковая информация в обе стороны), + // а не краткую карточку объекта. + const work = workForSelection(selection); + if (work) { + setMapInfoSelection(undefined); + setSelectedWorkId(work.id); + setRightMode("inspect"); + setNotice(undefined); + return; + } + setMapInfoSelection(selection); + setRightMode("mapInfo"); + setSelectedWorkId(undefined); + }, [rightMode, clearMapPick, workForSelection]); + + // Несколько объектов под точкой клика — показываем их списком в правой панели (оператор + // раскрывает нужный, чтобы увидеть его данные). Здесь только наполняем список. + const handleObjectsAtPoint = useCallback((selections: Tgu2DMapSelection[]) => { + if (rightMode === "shoot") return; + setMapPickHits(selections); + setMapPickActiveId(undefined); + setRightMode("mapPick"); + setSelectedWorkId(undefined); + setMapInfoSelection(undefined); + setNotice(undefined); + setMapFocus((prev) => ({ ...prev, objectId: undefined })); + }, [rightMode]); + + // Раскрытие/сворачивание элемента списка: подсвечиваем объект на карте (без перецентрирования) + // и выделяем соответствующее включение на таймлайне. + const togglePickItem = useCallback((selection: Tgu2DMapSelection) => { + setMapPickActiveId((current) => { + if (current === selection.id) { + setSelectedWorkId(undefined); + setMapFocus((prev) => ({ ...prev, objectId: undefined })); + return undefined; + } + setSelectedWorkId(workForSelection(selection)?.id); + setMapFocus((prev) => ({ ...prev, objectId: selection.id })); + return selection.id; + }); + }, [workForSelection]); + + const handleResizerDown = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + const startY = e.clientY; + const startRatio = mapRatio; + + const onMove = (ev: MouseEvent) => { + const el = centerRef.current; + if (!el) return; + const dy = ev.clientY - startY; + setMapRatio(Math.max(0.15, Math.min(0.86, startRatio + dy / el.clientHeight))); + }; + + const onUp = () => { + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + }; + + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + }, [mapRatio]); + + // Инспектор включения — общий и для одиночного выбора, и для раскрытого элемента в списке выбора. + const renderWorkInspector = (work: EditorWork, onClose: () => void) => ( + { void handleDeleteWork(workId); }} + onClose={onClose} + /> + ); + + // Детальная карточка объекта из списка выбора: включение (съёмка/сброс) → инспектор, + // прочее (заявка/станция) → карточка объекта карты. + const renderPickDetail = (selection: Tgu2DMapSelection) => { + const work = workForSelection(selection); + if (work) return renderWorkInspector(work, () => togglePickItem(selection)); + return ( + togglePickItem(selection)} + /> + ); + }; + + // Редактор включений работает только при выбранном плане (план == миссия, в которую кладутся + // включения). Без плана — плейсхолдер: иначе после удаления открытого черновика вкладка + // оставалась бы «активной» по выбранному КА (витки, режим съёмки), хотя редактировать нечего. + if (!selectedPlan) { + return ( +
+
Выберите КА и план, чтобы открыть редактор включений.
+
+ ); + } + + return ( +
+ { + setRightMode(mode); + setSelectedWorkId(undefined); + setEditingWorkId(undefined); + // Новая съёмка начинается с прицеливания (точки ещё нет); прочие режимы — без перекрестия. + setTargetPickActive(mode === "shoot"); + setTarget(undefined); + setMapInfoSelection(undefined); + clearMapPick(); + setNotice(undefined); + }} + onCheck={runCheck} + onCalculatePlan={() => { void runPlanCalculation(); }} + onClearPlan={() => setClearDialogOpen(true)} + onZoom={zoom} + /> + +
+ {notice &&
{notice}
} + {applyError &&
{applyError}
} +
+ +
+ { + setContextSpacecraftIds((current) => { + const next = new Set(current); + if (next.has(spacecraftId)) { + next.delete(spacecraftId); + } else { + next.add(spacecraftId); + } + return next; + }); + }} + /> + +
+
+ {rightMode === "shoot" && targetPickActive &&
Кликните точку съёмки на карте.
} + {orbitPasses.length > 0 ? ( + + ) : noradId ? ( +
+ Витки + + {flightLineLoading ? "Загрузка трассы…" : "Нет данных трассы на это окно"} + +
+ ) : null} + { + // Опорная точка прицеливания — всегда одна: каждый клик заменяет + // предыдущее перекрестие, цели не накапливаются. + targetCounterRef.current += 1; + setTarget({ + id: `target-${targetCounterRef.current}`, + name: "Опорная точка прицеливания", + lat: point.lat, + lon: point.lon + }); + setSelectedShootPass(undefined); + setModeId(DEFAULT_EDITOR_MODES[0].id); + setNotice(undefined); + // Прицеливание НЕ гасим: пока не выбран проход, точку можно свободно переставлять + // повторными кликами по карте. «Рука» включится при выборе строки прохождения. + }} + onObjectSelect={handleMapObjectSelect} + focusPoint={mapFocus.point} + focusToken={mapFocus.token} + selectedObjectId={mapFocus.objectId} + /> +
+
+ { + setSelectedWorkId(workId); + setRightMode("inspect"); + setNotice(undefined); + focusWorkOnMap(workId); + }} + onClearSelection={() => { + if (rightMode === "inspect") { + setSelectedWorkId(undefined); + setRightMode("none"); + setMapFocus((prev) => ({ ...prev, objectId: undefined })); + } + }} + onWheelZoom={zoomAt} + onPanTo={panTo} + /> +
+ + {rightMode !== "none" && ( + + )} +
+ + {clearDialogOpen && ( +
setClearDialogOpen(false)} + > +
e.stopPropagation()}> +
+

Очистить план

+
+

+ Что удалить из плана? Сбросы удаляются в любом случае. Действие необратимо. +

+
+ + +
+
+ +
+
+
+ )} +
+ ); +} + +function visibilityWindowFromParam(param: PointVisibilityParam): EditorVisibilityWindow { + const timeMs = parseLocal(param.time); + const half = SHOOT_WINDOW_MS / 2; + return { + startMs: timeMs - half, + endMs: timeMs + half, + quality: 0, + revolution: param.revolution, + rollDeg: param.gamma, + sunAngleDeg: param.sunAngle, + range: param.range, + revSign: param.revSign + }; +} + +function defaultEditorWindow( + selectedPlan: TguEditorTabProps["selectedPlan"], + appliedRange: TguEditorTabProps["appliedRange"] +): EditorTimelineWindow { + if (selectedPlan) { + return { + fromMs: selectedPlan.startMs - EDITOR_WINDOW_PAD_MS, + toMs: selectedPlan.endMs + EDITOR_WINDOW_PAD_MS + }; + } + + return { fromMs: appliedRange.fromMs, toMs: appliedRange.toMs }; +} + +function buildStations(selectedPlan: TguEditorTabProps["selectedPlan"]): EditorGroundStation[] { + if (!selectedPlan?.kppId) { + return []; + } + + return [{ id: selectedPlan.kppId, name: selectedPlan.kppId }]; +} + +/** + * LocalDateTime без зоны от бэка трактуем как локальное настенное время (как и поле `time` + * в ballisticsApi): зоны ЗРВ приходят без таймзоны, времена съёмок — с offset (его учитываем); + * приводим к одной шкале в локальной зоне браузера. + */ +function localDateTimeMs(s: string): number { + return parseLocal(s); +} + +function buildVisibilityTracks( + target: EditorTarget | undefined, + shootPasses: EditorVisibilityWindow[], + selectedWork: DraftHistory["draft"]["works"][number] | undefined, + stations: EditorGroundStation[], + spacecraftId: string | undefined, + window: EditorTimelineWindow +): EditorVisibilityTrack[] { + const tracks: EditorVisibilityTrack[] = []; + if (target) { + tracks.push({ + id: "target", + label: target.name, + kind: "target", + color: "var(--now)", + windows: shootPasses + }); + } + + if (selectedWork?.kind === "downlink" && selectedWork.stationId) { + const station = stations.find((item) => item.id === selectedWork.stationId); + if (station?.lat !== undefined && station.lon !== undefined) { + tracks.push({ + id: "station", + label: station.name, + kind: "station", + color: "var(--accent)", + windows: stationPasses({ spacecraftId }, { lat: station.lat, lon: station.lon }, { fromMs: window.fromMs, toMs: window.toMs }) + }); + } + } + + return tracks; +} + +const MAP_TRACK_WINDOW_MS = 24 * 60 * 60 * 1000; + +function buildEditorMapScene( + spacecraftId: string | undefined, + flightLineData: FlightLinePoint[], + orbitPasses: OrbitPassInfo[], + stationDtos: StationDto[], + requestItems: RequestMapItem[], + planModes: PlanMode[], + fromIndex: number, + toIndex: number, + surveyRoute?: SurveyRoute +): Tgu2DMapScene { + const visibleRevolutions = new Set( + orbitPasses.filter((p) => p.index >= fromIndex && p.index <= toIndex).map((p) => p.revolution!) + ); + + const byRevolution = new Map(); + for (const pt of flightLineData) { + if (!visibleRevolutions.has(pt.revolution)) continue; + const arr = byRevolution.get(pt.revolution) ?? []; + arr.push(pt); + byRevolution.set(pt.revolution, arr); + } + + const lines: MapLine[] = []; + const polygons: MapPolygon[] = []; + let zIndex = 0; + + for (const [revolution, pts] of byRevolution) { + const passIdx = orbitPasses.find((p) => p.revolution === revolution)?.index ?? 0; + + lines.push({ + id: `${spacecraftId}:track:${revolution}`, + layer: "tracks", + points: pts.map((p) => ({ lat: p.lat, lon: p.long })), + spacecraftId, + passIndex: passIdx, + zIndex: zIndex++ + }); + + const leftEdge = pts.map((p) => ({ lat: p.latLeft, lon: p.longLeft })); + const rightEdge = [...pts].reverse().map((p) => ({ lat: p.latRight, lon: p.longRight })); + polygons.push({ + id: `${spacecraftId}:swath:${revolution}`, + name: `Полоса обзора витка ${revolution}`, + kind: "swath", + layer: "swath", + points: [...leftEdge, ...rightEdge], + zIndex: zIndex++ + }); + } + + for (const mode of planModes) { + if (mode.type !== "SURVEY" || !mode.contourWkt) continue; + const rings = parseWktPolygon(mode.contourWkt); + for (const ring of rings) { + if (ring.length < 3) continue; + polygons.push({ + id: `planWork:${mode.id}:${zIndex}`, + name: `Съёмка витка ${mode.revolution}`, + kind: "planWork", + layer: "planWorks", + points: ring, + status: "PLANNED", + modeId: mode.id, + zIndex: zIndex++ + }); + } + } + + // Сбросы (DROP) — яркая толстая линия на трассе: берём точки трассы за интервал сеанса + // [timeStart, timeStart + duration]. Это и есть участок витка, над которым идёт сброс. + for (const mode of planModes) { + if (mode.type !== "DROP" || mode.timeStart == null) continue; + const startMs = localDateTimeMs(mode.timeStart); + const endMs = startMs + mode.duration * 1000; + const pts = flightLineData + .map((p) => ({ t: parseLocal(p.time), lat: p.lat, lon: p.long })) + .filter((p) => p.t >= startMs && p.t <= endMs) + .sort((a, b) => a.t - b.t) + .map((p) => ({ lat: p.lat, lon: p.lon })); + if (pts.length < 2) continue; + lines.push({ + id: `drop:${mode.id}`, + name: `Сброс · виток ${mode.revolution}`, + layer: "tracks", + kind: "drop", + modeId: mode.id, + points: pts, + zIndex: zIndex++ + }); + } + + for (const item of requestItems) { + 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++ + }); + } + } + + // Построенный (но ещё не сохранённый) маршрут съёмки — показываем поверх работ плана + // отдельным цветом (статус ISSUING). Рендерится в слое planWorks, поэтому виден без + // дополнительной настройки слоёв. + if (surveyRoute?.contourWkt) { + const rings = parseWktPolygon(surveyRoute.contourWkt); + for (const ring of rings) { + if (ring.length < 3) continue; + polygons.push({ + id: `surveyRoute:${zIndex}`, + name: `Маршрут съёмки (виток ${surveyRoute.revolution})`, + kind: "planWork", + layer: "planWorks", + points: ring, + status: "ISSUING", + zIndex: zIndex++ + }); + } + } + + const markers = buildStationMarkers(stationDtos); + + return { polygons, lines, markers }; +} + +function platformLabel(platforms: TguEditorTabProps["platforms"], spacecraftId?: string): string { + if (!spacecraftId) { + return "КА не выбран"; + } + const platform = platforms.find((item) => item.spacecraftId === spacecraftId); + return platform?.name ?? platform?.noradId ?? spacecraftId; +} + +function nextWorkId(ref: MutableRefObject): string { + ref.current += 1; + return `NW-${String(ref.current).padStart(4, "0")}`; +} + +/** + * Объект карты, соответствующий включению, и точка для центрирования: + * - съёмка → контур (planWorks) по modeId либо только что построенный маршрут; точка — центр контура; + * - сброс → яркая линия сброса (drop) по modeId; точка — середина линии. + */ +function workMapFocus(work: EditorWork | undefined, scene: Tgu2DMapScene): { point?: GeoPoint; objectId?: string } { + if (!work) return {}; + if (work.kind === "shoot") { + const polygon = + work.planModeId != null + ? scene.polygons.find((p) => p.modeId === work.planModeId) + : scene.polygons.find((p) => p.id.startsWith("surveyRoute:")); + const point: GeoPoint | undefined = + work.centerLat != null && work.centerLon != null + ? { lat: work.centerLat, lon: work.centerLon } + : polygon + ? polygonCentroid(polygon.points) + : undefined; + return { point, objectId: polygon?.id }; + } + + const line = + work.planModeId != null + ? scene.lines.find((l) => l.kind === "drop" && l.modeId === work.planModeId) + : undefined; + const point = line && line.points.length > 0 ? line.points[Math.floor(line.points.length / 2)] : undefined; + return { point, objectId: line?.id }; +} + +function polygonCentroid(points: GeoPoint[]): GeoPoint | undefined { + if (points.length === 0) return undefined; + let lat = 0; + let lon = 0; + for (const p of points) { + lat += p.lat; + lon += p.lon; + } + return { lat: lat / points.length, lon: lon / points.length }; +} diff --git a/services/pcp-tgu-ui-service/src/features/tgu-editor/VisibilityTracks.tsx b/services/pcp-tgu-ui-service/src/features/tgu-editor/VisibilityTracks.tsx new file mode 100644 index 0000000..d631b41 --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-editor/VisibilityTracks.tsx @@ -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 ( +
+
+ + + {track.label} + + {track.kind === "target" ? "цель · видимость" : "станция · видимость"} +
+
+ {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 ( +
+ ); + })} +
+
+ ); + })} + + ); +} diff --git a/services/pcp-tgu-ui-service/src/features/tgu-editor/editorApi.ts b/services/pcp-tgu-ui-service/src/features/tgu-editor/editorApi.ts new file mode 100644 index 0000000..ceab45e --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-editor/editorApi.ts @@ -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 { + 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; +} diff --git a/services/pcp-tgu-ui-service/src/features/tgu-editor/editorConflicts.test.ts b/services/pcp-tgu-ui-service/src/features/tgu-editor/editorConflicts.test.ts new file mode 100644 index 0000000..57c29a7 --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-editor/editorConflicts.test.ts @@ -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"] + }); + }); +}); diff --git a/services/pcp-tgu-ui-service/src/features/tgu-editor/editorConflicts.ts b/services/pcp-tgu-ui-service/src/features/tgu-editor/editorConflicts.ts new file mode 100644 index 0000000..fb79498 --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-editor/editorConflicts.ts @@ -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; + +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]; +} diff --git a/services/pcp-tgu-ui-service/src/features/tgu-editor/editorDraft.test.ts b/services/pcp-tgu-ui-service/src/features/tgu-editor/editorDraft.test.ts new file mode 100644 index 0000000..041f186 --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-editor/editorDraft.test.ts @@ -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([]); + }); +}); diff --git a/services/pcp-tgu-ui-service/src/features/tgu-editor/editorDraft.ts b/services/pcp-tgu-ui-service/src/features/tgu-editor/editorDraft.ts new file mode 100644 index 0000000..1665d11 --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-editor/editorDraft.ts @@ -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, 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 { + return typeof value === "object" && value !== null; +} diff --git a/services/pcp-tgu-ui-service/src/features/tgu-editor/editorPresentation.ts b/services/pcp-tgu-ui-service/src/features/tgu-editor/editorPresentation.ts new file mode 100644 index 0000000..629e22d --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-editor/editorPresentation.ts @@ -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"); +} diff --git a/services/pcp-tgu-ui-service/src/features/tgu-editor/editorValidation.test.ts b/services/pcp-tgu-ui-service/src/features/tgu-editor/editorValidation.test.ts new file mode 100644 index 0000000..be34e76 --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-editor/editorValidation.test.ts @@ -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({}); + }); +}); diff --git a/services/pcp-tgu-ui-service/src/features/tgu-editor/editorValidation.ts b/services/pcp-tgu-ui-service/src/features/tgu-editor/editorValidation.ts new file mode 100644 index 0000000..a5e7e5e --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-editor/editorValidation.ts @@ -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(); + 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; +} diff --git a/services/pcp-tgu-ui-service/src/features/tgu-editor/model/editorTypes.ts b/services/pcp-tgu-ui-service/src/features/tgu-editor/model/editorTypes.ts new file mode 100644 index 0000000..59fc57c --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-editor/model/editorTypes.ts @@ -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> & + Partial> & + Partial>; + +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[]; +}; diff --git a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/Tgu2DMapDetails.tsx b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/Tgu2DMapDetails.tsx new file mode 100644 index 0000000..c4d7937 --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/Tgu2DMapDetails.tsx @@ -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 ( +
+
+
План
+
{selectedPlan?.planId || "-"}
+
+
+
КА
+
+ {selectedPlan ? platformLabel(selectedPlan.platform, selectedPlan.spacecraftId) : "-"} +
+
+
+
Статус
+
+ {status ? ( + + + {status.label} + + ) : "-"} +
+
+
+
Объект карты
+
{selectedObject?.name || selectedObject?.id || "-"}
+
+
+
Работы
+
Работы плана
+
+
+ ); +} + diff --git a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/Tgu2DMapLayers.ts b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/Tgu2DMapLayers.ts new file mode 100644 index 0000000..53eec8a --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/Tgu2DMapLayers.ts @@ -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>; + +export type Tgu2DMapSceneInput = { + range: TimelineRange; + selectedPlan?: TguPlanUi; + platforms: TguPlatformUi[]; + hiddenSpacecraftIds?: ReadonlySet; + 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; +} diff --git a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/Tgu2DMapRequestsPanel.tsx b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/Tgu2DMapRequestsPanel.tsx new file mode 100644 index 0000000..29cac64 --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/Tgu2DMapRequestsPanel.tsx @@ -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 ( + + ); +} + +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}`; +} diff --git a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/Tgu2DMapSidebar.tsx b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/Tgu2DMapSidebar.tsx new file mode 100644 index 0000000..b957970 --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/Tgu2DMapSidebar.tsx @@ -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; + 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 ( + + ); +} diff --git a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/Tgu2DMapStationPanel.tsx b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/Tgu2DMapStationPanel.tsx new file mode 100644 index 0000000..fab4765 --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/Tgu2DMapStationPanel.tsx @@ -0,0 +1,43 @@ +import type { Tgu2DMapSelection } from "./model/mapSelectionTypes"; + +type StationSelection = Extract & { layer: "stations" }; + +type Props = { + selection: StationSelection; + onClose: () => void; +}; + +export function Tgu2DMapStationPanel({ selection, onClose }: Props) { + return ( + + ); +} diff --git a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/Tgu2DMapTab.tsx b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/Tgu2DMapTab.tsx new file mode 100644 index 0000000..b02477a --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/Tgu2DMapTab.tsx @@ -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(DEFAULT_TGU_2D_MAP_LAYERS); + const [search, setSearch] = useState(""); + const [redrawToken, setRedrawToken] = useState(0); + const [selectedObject, setSelectedObject] = useState(); + const [hiddenIds, setHiddenIds] = useState>(new Set()); + const [requestsState, setRequestsState] = useState(() => buildInitialRequestsState(range)); + const [stations, setStations] = useState([]); + const [stationsFetchFailed, setStationsFetchFailed] = useState(false); + const [requestsAtPoint, setRequestsAtPoint] = useState([]); + const [selectedRequestPolygonId, setSelectedRequestPolygonId] = useState(); + const [hoveredRequestPolygonId, setHoveredRequestPolygonId] = useState(); + 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 ( +
+ setRedrawToken((value) => value + 1)} + onToggleLayer={toggleLayer} + /> + +
0 || stationPanel != null) ? " has-requests-panel" : ""}`}> + +
+ {overlayMessage &&
{overlayMessage}
} + +
+ {requestsAtPoint.length > 0 && ( + + )} + {stationPanel != null && ( + + )} +
+ +
+ {Object.entries(layerAvailability).map(([layer, message]) => + message ? ( +
+ {layerLabel(layer as Tgu2DMapLayerKey)}: {message} +
+ ) : null + )} +
+ + +
+ ); +} + +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 "Заявки на съёмку"; + } +} diff --git a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/Tgu2DMapToolbar.tsx b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/Tgu2DMapToolbar.tsx new file mode 100644 index 0000000..9057610 --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/Tgu2DMapToolbar.tsx @@ -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 ( +
+
+
Карта
+
+ {satelliteLabel} · {intervalLabel} +
+
+
+ {TGU_2D_MAP_LAYERS.map((layer) => ( + + ))} +
+ +
+ ); +} + diff --git a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/Tgu2DMapView.tsx b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/Tgu2DMapView.tsx new file mode 100644 index 0000000..ac200bb --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/Tgu2DMapView.tsx @@ -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(null); + const canvasRef = useRef(null); + const dragRef = useRef(undefined); + const hasDraggedRef = useRef(false); + const hoverRef = useRef(undefined); + const hoverMarkerRef = useRef(undefined); + const selectedMarkerRef = useRef(undefined); + const [size, setSize] = useState({ width: 1000, height: 560 }); + const [view, setView] = useState(INITIAL_VIEW); + const [tileVersion, setTileVersion] = useState(0); + const handleTileLoad = useCallback(() => setTileVersion((v) => v + 1), []); + const [tooltip, setTooltip] = useState(); + // Гео-координаты под курсором — показываем по центру внизу карты. + const [cursorGeo, setCursorGeo] = useState(); + const selectedPolygonRef = useRef(undefined); + const selectedLineRef = useRef(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 ( +
{ + dragRef.current = undefined; + hoverRef.current = undefined; + hoverMarkerRef.current = undefined; + setTooltip(undefined); + setCursorGeo(undefined); + }} + onWheel={onWheel} + > + + + {tooltip && ( +
+
{tooltip.title}
+ {tooltip.subtitle} +
+ )} + {cursorGeo &&
{formatLatLon(cursorGeo)}
} +
© OpenStreetMap contributors · © CARTO
+
+ + + +
+
+ ); +} + +/** Подпись координат под курсором: широта/долгота с полушариями. */ +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, + 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[] { + const result: MapMarker[] = []; + const ids = new Set(); + 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[] { + const result: MapPolygon[] = []; + const ids = new Set(); + 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[] { + const result: MapLine[] = []; + const ids = new Set(); + 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, + 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)); +} diff --git a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/canvas/drawBaseMap.ts b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/canvas/drawBaseMap.ts new file mode 100644 index 0000000..059eedf --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/canvas/drawBaseMap.ts @@ -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(); +} diff --git a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/canvas/drawComplexPlan.ts b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/canvas/drawComplexPlan.ts new file mode 100644 index 0000000..367baf2 --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/canvas/drawComplexPlan.ts @@ -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(); +} diff --git a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/canvas/drawPlanWorks.ts b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/canvas/drawPlanWorks.ts new file mode 100644 index 0000000..e42ca6f --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/canvas/drawPlanWorks.ts @@ -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})`; +} + diff --git a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/canvas/drawRequests.ts b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/canvas/drawRequests.ts new file mode 100644 index 0000000..59671b6 --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/canvas/drawRequests.ts @@ -0,0 +1,69 @@ +import type { MapProjection } from "../geometry/mapProjection"; +import type { MapPolygon } from "../model/mapTypes"; + +const SURVEY_COLORS: Record = { + 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})`; +} diff --git a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/canvas/drawStations.ts b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/canvas/drawStations.ts new file mode 100644 index 0000000..48eb2d7 --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/canvas/drawStations.ts @@ -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(); +} + diff --git a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/canvas/drawSwath.ts b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/canvas/drawSwath.ts new file mode 100644 index 0000000..c2dee16 --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/canvas/drawSwath.ts @@ -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(); +} diff --git a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/canvas/drawTiles.ts b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/canvas/drawTiles.ts new file mode 100644 index 0000000..96e7a1b --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/canvas/drawTiles.ts @@ -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); + } + } +} diff --git a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/canvas/drawTracks.ts b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/canvas/drawTracks.ts new file mode 100644 index 0000000..07a58f0 --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/canvas/drawTracks.ts @@ -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(); +} diff --git a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/canvas/tileLoader.ts b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/canvas/tileLoader.ts new file mode 100644 index 0000000..4690c56 --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/canvas/tileLoader.ts @@ -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(); + +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; +} diff --git a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/geometry/bbox.ts b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/geometry/bbox.ts new file mode 100644 index 0000000..2b1dbaf --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/geometry/bbox.ts @@ -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; +} + diff --git a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/geometry/mapProjection.ts b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/geometry/mapProjection.ts new file mode 100644 index 0000000..cb19725 --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/geometry/mapProjection.ts @@ -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; +} + diff --git a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/geometry/passes.test.ts b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/geometry/passes.test.ts new file mode 100644 index 0000000..3a7a3a4 --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/geometry/passes.test.ts @@ -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); + }); +}); diff --git a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/geometry/passes.ts b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/geometry/passes.ts new file mode 100644 index 0000000..733bc17 --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/geometry/passes.ts @@ -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; +} diff --git a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/geometry/pointInPolygon.ts b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/geometry/pointInPolygon.ts new file mode 100644 index 0000000..c30d4a6 --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/geometry/pointInPolygon.ts @@ -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; +} + diff --git a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/geometry/simplifyGeometry.ts b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/geometry/simplifyGeometry.ts new file mode 100644 index 0000000..40a5c21 --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/geometry/simplifyGeometry.ts @@ -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; +} + diff --git a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/geometry/spatialIndex.test.ts b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/geometry/spatialIndex.test.ts new file mode 100644 index 0000000..4c2b25e --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/geometry/spatialIndex.test.ts @@ -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"]); + }); +}); + diff --git a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/geometry/spatialIndex.ts b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/geometry/spatialIndex.ts new file mode 100644 index 0000000..31463fb --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/geometry/spatialIndex.ts @@ -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; +} + diff --git a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/geometry/wktParser.ts b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/geometry/wktParser.ts new file mode 100644 index 0000000..4447386 --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/geometry/wktParser.ts @@ -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); +} diff --git a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/model/mapLayerTypes.ts b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/model/mapLayerTypes.ts new file mode 100644 index 0000000..6dff8ab --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/model/mapLayerTypes.ts @@ -0,0 +1,45 @@ +export type Tgu2DMapLayerKey = + | "tracks" + | "swath" + | "planWorks" + | "complexPlan" + | "stations" + | "spacecraftMarkers" + | "requests"; + +export type Tgu2DMapLayersState = Record; + +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 +}; + diff --git a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/model/mapSelectionTypes.ts b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/model/mapSelectionTypes.ts new file mode 100644 index 0000000..e2af403 --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/model/mapSelectionTypes.ts @@ -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; + }; + diff --git a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/model/mapTypes.ts b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/model/mapTypes.ts new file mode 100644 index 0000000..227bf17 --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/model/mapTypes.ts @@ -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[]; +}; + diff --git a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/tgu2DMapInterval.test.ts b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/tgu2DMapInterval.test.ts new file mode 100644 index 0000000..1d5f154 --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/tgu2DMapInterval.test.ts @@ -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 + }); + }); +}); + diff --git a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/tgu2DMapInterval.ts b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/tgu2DMapInterval.ts new file mode 100644 index 0000000..845d5d7 --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/tgu2DMapInterval.ts @@ -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 + }; +} + diff --git a/services/pcp-tgu-ui-service/src/features/tgu-planning/CreateChainPlanDialog.tsx b/services/pcp-tgu-ui-service/src/features/tgu-planning/CreateChainPlanDialog.tsx new file mode 100644 index 0000000..19f88fc --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-planning/CreateChainPlanDialog.tsx @@ -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([]); + const [zrvLoading, setZrvLoading] = useState(true); + const [zrvError, setZrvError] = useState(); + + // "" — без истории; при открытии берём план в фокусе, если он годится в базу (этот КА, не + // проекция/черновик), иначе остаёмся без истории. + const [basePlanId, setBasePlanId] = useState(() => + initialBasePlanId && + plans.some( + (plan) => + plan.planId === initialBasePlanId && + plan.spacecraftId === spacecraftId && + plan.status !== "PLANNED" && + plan.status !== "DRAFT" + ) + ? initialBasePlanId + : "" + ); + const [startId, setStartId] = useState(""); + const [endId, setEndId] = useState(""); + + const [creating, setCreating] = useState(false); + const [error, setError] = useState(); + const [result, setResult] = useState(); + + // Базы-предыстории: 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 ( +
+
+
+

Создать план

+ +
+ + {!result ? ( + <> + + + {zrvError &&
{zrvError}
} + {zrvLoading ? ( +
Загрузка ЗРВ…
+ ) : zrvOptions.length === 0 ? ( +
Нет будущих ЗРВ для этого КА.
+ ) : ( + <> + + + + + )} + + {error &&
{error}
} + +
+ + +
+ + ) : ( + <> +
+ Черновик плана создан ({result.draftPlanId}). +
+ +
+ + +
+
+ Откроется страница редактирования черновика. +
+ + )} +
+
+ ); +} diff --git a/services/pcp-tgu-ui-service/src/features/tgu-planning/TguLegend.tsx b/services/pcp-tgu-ui-service/src/features/tgu-planning/TguLegend.tsx new file mode 100644 index 0000000..b3a9b97 --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-planning/TguLegend.tsx @@ -0,0 +1,14 @@ +import { STATUS_STYLES } from "./tguStatus"; + +export function TguLegend() { + return ( +
+ {STATUS_STYLES.map((item) => ( + + + {item.label} + + ))} +
+ ); +} diff --git a/services/pcp-tgu-ui-service/src/features/tgu-planning/TguPlanDetails.tsx b/services/pcp-tgu-ui-service/src/features/tgu-planning/TguPlanDetails.tsx new file mode 100644 index 0000000..5de6831 --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-planning/TguPlanDetails.tsx @@ -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 ( + + ); + } + + 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 ( + + ); +} + +function Detail({ label, value, mono }: { label: string; value?: string; mono?: boolean }) { + return ( +
+
{label}
+
{value || "-"}
+
+ ); +} + +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} мин`; +} diff --git a/services/pcp-tgu-ui-service/src/features/tgu-planning/TguPlanningLayout.tsx b/services/pcp-tgu-ui-service/src/features/tgu-planning/TguPlanningLayout.tsx new file mode 100644 index 0000000..587ccb4 --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-planning/TguPlanningLayout.tsx @@ -0,0 +1,15 @@ +import type { ReactNode } from "react"; + +type TguPlanningLayoutProps = { + toolbar: ReactNode; + children: ReactNode; +}; + +export function TguPlanningLayout({ toolbar, children }: TguPlanningLayoutProps) { + return ( +
+ {toolbar} + {children} +
+ ); +} diff --git a/services/pcp-tgu-ui-service/src/features/tgu-planning/TguPlanningPage.tsx b/services/pcp-tgu-ui-service/src/features/tgu-planning/TguPlanningPage.tsx new file mode 100644 index 0000000..cb66811 --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-planning/TguPlanningPage.tsx @@ -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(initialRange); + const [rawPlatforms, setRawPlatforms] = useState([]); + const [plans, setPlans] = useState([]); + const [loading, setLoading] = useState(false); + const [pageError, setPageError] = useState(); + const [platformLoadFailed, setPlatformLoadFailed] = useState(false); + const [search, setSearch] = useState(""); + const [selectedSpacecraftId, setSelectedSpacecraftId] = useState(); + const [selectedPlanId, setSelectedPlanId] = useState(); + const [activeTab, setActiveTab] = useState("timeline"); + const [createChainOpen, setCreateChainOpen] = useState(false); + // Черновики (DRAFT) — план-кандидаты, скрытые из основного read-path; грузим отдельно. + const [drafts, setDrafts] = useState([]); + const [draftActionInFlight, setDraftActionInFlight] = useState(false); + // Тумблеры видимости «непринятого будущего» на схеме. + const [showDrafts, setShowDrafts] = useState(false); + const [showProjection, setShowProjection] = useState(true); + // ЗРВ выбранного КА — подсветка доступных зон на таймлайне (на какие зоны можно опереться). + const [zrvWindows, setZrvWindows] = useState([]); + // Выбор ЗРВ на таймлайне: пересекающиеся в точке зоны + индекс активной (панель справа). + const [zrvSelection, setZrvSelection] = useState<{ windows: ZrvWindow[]; index: number }>(); + // Вкладки рендерятся в режиме keep-alive: каждую монтируем при первом открытии + // и дальше держим смонтированной (скрытой через display:none), чтобы её + // внутреннее состояние — загруженные заявки, выбор, слои, скролл — не сбрасывалось + // при переключении между вкладками. + const [visitedTabs, setVisitedTabs] = useState>(() => new Set(["timeline"])); + const [decisionInFlight, setDecisionInFlight] = useState(false); + const [decisionNotice, setDecisionNotice] = useState(); + const [decisionError, setDecisionError] = useState(); + + 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 + // (внутренний
остаётся прямым 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(); + 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 ( + + } + > +
+
+ { + setSelectedSpacecraftId(spacecraftId); + setSelectedPlanId(undefined); + setDecisionNotice(undefined); + }} + /> +
+
+ + + +
+ { + 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 }); + }} + /> +
+ {zrvSelection ? ( + setZrvSelection((current) => (current ? { ...current, index } : current))} + onClose={() => setZrvSelection(undefined)} + /> + ) : ( + setSelectedPlanId(undefined)} + /> + )} +
+ {createChainOpen && selectedSpacecraftId && ( + setCreateChainOpen(false)} + onCreated={() => { + setShowDrafts(true); + void loadData(appliedRange); + }} + onEdit={(draftPlanId) => { + setCreateChainOpen(false); + setSelectedPlanId(draftPlanId); + setDecisionNotice(undefined); + setActiveTab("editor"); + }} + /> + )} +
+ + {visitedTabs.has("map") && ( +
+ +
+ )} + + {visitedTabs.has("complex") && ( +
+ +
+ )} + + {visitedTabs.has("requests") && ( +
+ +
+ )} + + {visitedTabs.has("constellation") && ( +
+ +
+ )} + + {visitedTabs.has("editor") && ( +
+ +
+ )} +
+ ); +} + +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); +} diff --git a/services/pcp-tgu-ui-service/src/features/tgu-planning/TguSidebar.tsx b/services/pcp-tgu-ui-service/src/features/tgu-planning/TguSidebar.tsx new file mode 100644 index 0000000..adffc70 --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-planning/TguSidebar.tsx @@ -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 ( + + ); +} diff --git a/services/pcp-tgu-ui-service/src/features/tgu-planning/TguTimeline.tsx b/services/pcp-tgu-ui-service/src/features/tgu-planning/TguTimeline.tsx new file mode 100644 index 0000000..ef44fbf --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-planning/TguTimeline.tsx @@ -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 = 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; + 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(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 ( +
+ {invalidRange ? ( +
Некорректный интервал: from должен быть меньше to.
+ ) : rows.length === 0 || segments.length === 0 ? ( +
Нет планов в выбранном интервале.
+ ) : ( + + + + + + + {ticks.map((tick) => { + const x = TIMELINE_LABEL_WIDTH + timeToX(tick, range, timelineWidth); + return ( + + + + {formatTickDate(tick)} + + + {formatTickTime(tick)} + + + ); + })} + + {nowX !== undefined && ( + + + + сейчас + + + )} + + {layout.rowLayouts.map(({ row, rowIndex, y, height: rowHeight, zrvBandY }) => { + return ( + + + + {platformLabel(row.platform, row.spacecraftId)} + + + NORAD {row.platform?.noradId || row.spacecraftId} + + {pausedSpacecraftIds?.has(row.spacecraftId) && ( + + ⏸ на паузе + Цепочка на паузе: план отклонён — создайте исправленный план («Создать план»). + + )} + {zrvBands?.spacecraftId === row.spacecraftId && ( + <> + + ЗРВ + + {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 ( + { + 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)); + }} + > + ЗРВ {zrv.kppId} · {formatLocalDateTime(zrv.startMs)} – {formatLocalDateTime(zrv.endMs)} + + ); + })} + + )} + + ); + })} + + {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 ( + onSelectPlan(segment.plan.planId)} + > + + {segment.plan.planId} · {segment.plan.status} + {dismissed ? " · снят с учёта" : ""} + {neighbor === "prev" ? " · предыдущий в цепочке" : neighbor === "next" ? " · следующий в цепочке" : ""} ·{" "} + {formatDateTime(segment.plan.startTime)} - {formatDateTime(segment.plan.endTime)} + + {neighbor && ( + + )} + {selected && } + + {labelVisible && ( + + {segment.plan.kppId} · {segment.plan.status} + + )} + + ); + })} + + )} +
+ ); +} + +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)); +} diff --git a/services/pcp-tgu-ui-service/src/features/tgu-planning/TguToolbar.tsx b/services/pcp-tgu-ui-service/src/features/tgu-planning/TguToolbar.tsx new file mode 100644 index 0000000..56cd096 --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-planning/TguToolbar.tsx @@ -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 ( +
+
+
+
+ +
+ + + + + + +
+ + + + + + + + +
+ {error &&
{error}
} +
+ ); +} diff --git a/services/pcp-tgu-ui-service/src/features/tgu-planning/TguZrvPanel.tsx b/services/pcp-tgu-ui-service/src/features/tgu-planning/TguZrvPanel.tsx new file mode 100644 index 0000000..6385cab --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-planning/TguZrvPanel.tsx @@ -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 ( + + ); +} + +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} с`; +} diff --git a/services/pcp-tgu-ui-service/src/features/tgu-planning/createChainWindows.test.ts b/services/pcp-tgu-ui-service/src/features/tgu-planning/createChainWindows.test.ts new file mode 100644 index 0000000..f35c17d --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-planning/createChainWindows.test.ts @@ -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(); + }); +}); diff --git a/services/pcp-tgu-ui-service/src/features/tgu-planning/createChainWindows.ts b/services/pcp-tgu-ui-service/src/features/tgu-planning/createChainWindows.ts new file mode 100644 index 0000000..88bc613 --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-planning/createChainWindows.ts @@ -0,0 +1,65 @@ +import { parseLocal, toLocalIso } from "../../utils/localTime"; +import type { CreateChainPlanRequest, ZrvWindowResponse } from "../../api/tguPlanApi"; + +/** + * Опция выбора ЗРВ в диалоге «Создать план». Из стартовой зоны берутся начало окна и КПП, из + * конечной — конец окна. `start`/`stop` — naive (ISO без зоны, локальное), как ждёт `LocalDateTime` бэка; + * `startMs`/`endMs` — для сортировки/сравнения и подсветки на таймлайне. + */ +export type ZrvOption = { + /** Детерминированный id зоны (для value в select и key). */ + id: string; + startMs: number; + endMs: number; + /** Naive (локальное) начала зоны. */ + start: string; + /** Naive (локальное) конца зоны. */ + stop: string; + kppId: string; +}; + +/** Будущие ЗРВ-зоны (строго позже nowMs), по возрастанию начала. */ +export function futureZrvOptions(windows: ZrvWindowResponse[], nowMs: number): ZrvOption[] { + return windows + .map((zrv) => { + const startMs = parseLocal(zrv.startTime); + const endMs = parseLocal(zrv.endTime); + return { + id: `${zrv.kppId}|${zrv.startTime}|${zrv.endTime}`, + startMs, + endMs, + start: toNaive(startMs), + stop: toNaive(endMs), + kppId: zrv.kppId + }; + }) + .filter((option) => option.startMs > nowMs) + .sort((a, b) => a.startMs - b.startMs); +} + +/** + * Собирает тело запроса создания плана новой цепочки из выбора оператора. Окно — от начала стартовой + * зоны до конца конечной; `basePlanId` — предыстория (или null для цепочки без истории). Бросает, + * если конец окна не позже начала (конечная зона раньше стартовой). + */ +export function buildChainPlanRequest( + spacecraftId: string, + basePlanId: string | null, + start: ZrvOption, + end: ZrvOption +): CreateChainPlanRequest { + if (end.endMs <= start.startMs) { + throw new Error("Конец окна должен быть позже начала: выберите конечную ЗРВ не раньше стартовой."); + } + return { + spacecraftId, + basePlanId, + windowStart: start.start, + windowStop: end.stop, + windowKppId: start.kppId + }; +} + +function toNaive(ms: number): string { + return toLocalIso(ms); +} diff --git a/services/pcp-tgu-ui-service/src/features/tgu-planning/tguStatus.ts b/services/pcp-tgu-ui-service/src/features/tgu-planning/tguStatus.ts new file mode 100644 index 0000000..c71c2f9 --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-planning/tguStatus.ts @@ -0,0 +1,67 @@ +import type { TguPlanStatus } from "../../model/tguTypes"; +import type { TimelineStatusStyle } from "../../model/timelineTypes"; + +// Порядок — по жизненному циклу плана (см. docs/architect/PCP_TGU_PLAN_STATUS_LIFECYCLE.md). +// ACCEPTED из enum бэка здесь намеренно отсутствует: приёмка переводит план сразу в AWAITING_LAYIN. +// LAID_IN возникает по событию подтверждения закладки (AWAITING_LAYIN → LAID_IN) и делает план базой +// цепочки; конкуренты на то же окно при этом уходят в SUPERSEDED. +export const STATUS_STYLES: TimelineStatusStyle[] = [ + { + status: "DRAFT", label: "ЧЕРНОВИК", color: "#a98bdb", className: "status-draft", + description: "Черновик: план в стадии правки, в выдачу пока не идёт." + }, + { + status: "PLANNED", label: "PLANNED", color: "#8d99a6", className: "status-planned", + description: "Возможный будущий план (проекция цепочки)." + }, + { + status: "ISSUING", label: "ISSUING", color: "#2f80ed", className: "status-issuing", + description: "Идёт выдача плана." + }, + { + status: "WAITING_DECISION", label: "WAITING_DECISION", color: "#f0ad2e", className: "status-waiting", + description: "План выдан, ожидается решение (принять / отклонить)." + }, + { + status: "AWAITING_LAYIN", label: "AWAITING_LAYIN", color: "#2f9e44", className: "status-awaiting-layin", + description: "План принят, ожидается подтверждение закладки." + }, + { + status: "LAID_IN", label: "LAID_IN", color: "#1b6b34", className: "status-laid-in", + description: "Получено подтверждение закладки плана." + }, + { + status: "START_AMBIGUOUS", label: "START_AMBIGUOUS", color: "#b42318", className: "status-ambiguous", + description: "Ошибка после старта процесса выдачи плана. Требуется оператор." + }, + { + status: "REJECTED", label: "REJECTED", color: "#d64545", className: "status-rejected", + description: "План отклонён, цепочка поставлена на паузу." + }, + { + status: "NOT_ACCEPTED", label: "NOT_ACCEPTED", color: "#e8590c", className: "status-not-accepted", + description: "План не принят, идёт срочная перевыдача на ближайшую ЗРВ." + }, + { + status: "LAYIN_FAILED", label: "LAYIN_FAILED", color: "#a61e4d", className: "status-layin-failed", + description: "План не заложен, идёт срочная перевыдача на ближайшую ЗРВ." + }, + { + status: "SUPERSEDED", label: "SUPERSEDED", color: "#6b7280", className: "status-superseded", + description: "Вытеснён: по тому же окну заложен другой план (коррекция). Терминальный." + } +]; + +const STATUS_BY_NAME = new Map(STATUS_STYLES.map((style) => [style.status, style])); + +export function statusStyle(status: TguPlanStatus): TimelineStatusStyle { + return ( + STATUS_BY_NAME.get(status) ?? { + status, + label: status || "UNKNOWN", + color: "#79828d", + className: "status-unknown", + description: "Неизвестный статус." + } + ); +} diff --git a/services/pcp-tgu-ui-service/src/features/tgu-planning/tguTimelineLayout.test.ts b/services/pcp-tgu-ui-service/src/features/tgu-planning/tguTimelineLayout.test.ts new file mode 100644 index 0000000..675a51d --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-planning/tguTimelineLayout.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from "vitest"; +import type { TguPlanUi, TimelineRow } from "../../model/timelineTypes"; +import { buildTimelineLayout, clipPlanToRange, timeToX } from "./tguTimelineLayout"; +import { parseLocal } from "../../utils/localTime"; + +const range = { + fromMs: parseLocal("2026-05-29T00:00:00"), + toMs: parseLocal("2026-05-30T00:00:00") +}; + +function plan( + id: string, + startTime: string, + endTime: string, + spacecraftId = "56756", + extra: Partial = {} +): TguPlanUi { + return { + planId: id, + spacecraftId, + startTime, + endTime, + kppId: "KPP-1", + status: "PLANNED", + startMs: parseLocal(startTime), + endMs: parseLocal(endTime), + ...extra + }; +} + +describe("tguTimelineLayout", () => { + it("maps time to x inside the selected interval", () => { + expect(timeToX(parseLocal("2026-05-29T12:00:00"), range, 1200)).toBe(600); + }); + + it("clips partially visible plans to the selected interval", () => { + const clipped = clipPlanToRange(plan("p1", "2026-05-28T23:00:00", "2026-05-29T02:00:00"), range); + + expect(clipped).toEqual({ + startMs: range.fromMs, + endMs: parseLocal("2026-05-29T02:00:00") + }); + }); + + it("keeps non-overlapping plans on a single lane", () => { + const rows: TimelineRow[] = [ + { + spacecraftId: "56756", + plans: [ + plan("p1", "2026-05-29T01:00:00", "2026-05-29T02:00:00"), + plan("p2", "2026-05-29T03:00:00", "2026-05-29T04:00:00"), + plan("p3", "2026-05-29T05:00:00", "2026-05-29T06:00:00") + ] + } + ]; + + const lanes = buildTimelineLayout(rows, range, 1200).segments.map((segment) => segment.lane); + expect(lanes).toEqual([0, 0, 0]); + }); + + it("stacks overlapping plans onto separate lanes", () => { + const rows: TimelineRow[] = [ + { + spacecraftId: "56756", + plans: [ + plan("d1", "2026-05-29T02:00:00", "2026-05-29T04:00:00"), + plan("d2", "2026-05-29T02:00:00", "2026-05-29T04:00:00"), + plan("d3", "2026-05-29T02:30:00", "2026-05-29T04:30:00") + ] + } + ]; + + const layout = buildTimelineLayout(rows, range, 1200); + // Все три пересекаются — каждый на своей дорожке. + expect(new Set(layout.segments.map((segment) => segment.lane))).toEqual(new Set([0, 1, 2])); + // Разные дорожки — разные вертикальные координаты. + expect(new Set(layout.segments.map((segment) => segment.centerY)).size).toBe(3); + // Строка с тремя дорожками выше строки с одной. + expect(layout.rowLayouts[0].laneCount).toBe(3); + }); + + it("grows row height with the deepest lane stack", () => { + const single: TimelineRow[] = [ + { spacecraftId: "56756", plans: [plan("p1", "2026-05-29T01:00:00", "2026-05-29T02:00:00")] } + ]; + const stacked: TimelineRow[] = [ + { + spacecraftId: "56756", + plans: [ + plan("d1", "2026-05-29T02:00:00", "2026-05-29T04:00:00"), + plan("d2", "2026-05-29T02:00:00", "2026-05-29T04:00:00") + ] + } + ]; + + expect(buildTimelineLayout(stacked, range, 1200).rowLayouts[0].height).toBeGreaterThan( + buildTimelineLayout(single, range, 1200).rowLayouts[0].height + ); + }); +}); diff --git a/services/pcp-tgu-ui-service/src/features/tgu-planning/tguTimelineLayout.ts b/services/pcp-tgu-ui-service/src/features/tgu-planning/tguTimelineLayout.ts new file mode 100644 index 0000000..812e60d --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-planning/tguTimelineLayout.ts @@ -0,0 +1,142 @@ +import type { + TguPlanUi, + TimelinePlanSegment, + TimelineRange, + TimelineRow, + TimelineRowLayout +} from "../../model/timelineTypes"; + +export const TIMELINE_LABEL_WIDTH = 220; +export const TIMELINE_AXIS_HEIGHT = 46; +export const TIMELINE_BOTTOM_PADDING = 18; + +/** + * Вертикальная раскладка строки КА. Планы, которые не пересекаются по горизонтали, лежат на одной + * дорожке (чистая цепочка — прямая линия). Если план пересёкся бы с уже размещённым, он уходит на + * следующую дорожку (выше/ниже), чтобы планы не сливались. Высота строки растёт под самый глубокий + * стек дорожек, а страйп ЗРВ опускается ниже планов. + */ +const PLAN_TOP_Y = 28; // центр верхней дорожки (lane 0) относительно верха строки +export const TIMELINE_LANE_STEP = 16; // шаг между дорожками пересекающихся планов +const ZRV_GAP = 16; // отступ от нижней дорожки планов до страйпа ЗРВ +const ZRV_BAND_MIN_Y = 52; // минимум: строка с одной дорожкой выглядит как раньше (высота 74) +export const TIMELINE_ZRV_BAND_HEIGHT = 14; +const ROW_BOTTOM_PAD = 8; +const SEGMENT_GAP_PX = 6; // минимальный горизонтальный зазор, иначе считаем планы пересекающимися + +export function timeToX(timeMs: number, range: TimelineRange, timelineWidth: number): number { + return ((timeMs - range.fromMs) / (range.toMs - range.fromMs)) * timelineWidth; +} + +export function clipPlanToRange(plan: TguPlanUi, range: TimelineRange): { startMs: number; endMs: number } | null { + if (plan.endMs < range.fromMs || plan.startMs > range.toMs) { + return null; + } + + return { + startMs: Math.max(plan.startMs, range.fromMs), + endMs: Math.min(plan.endMs, range.toMs) + }; +} + +type PlacedPlan = { plan: TguPlanUi; x: number; x2: number; width: number }; + +/** + * Жадная раскладка по дорожкам (interval graph): для каждого плана берём первую дорожку, чья правая + * граница уже освободилась (с зазором SEGMENT_GAP_PX). Так пересекающиеся по горизонтали планы + * гарантированно оказываются на разных дорожках. Планы должны быть отсортированы по x. + */ +function packLanes(items: PlacedPlan[]): number[] { + const laneRightEdge: number[] = []; + return items.map((item) => { + let lane = laneRightEdge.findIndex((edge) => item.x >= edge + SEGMENT_GAP_PX); + if (lane === -1) { + lane = laneRightEdge.length; + } + laneRightEdge[lane] = item.x + item.width; + return lane; + }); +} + +function zrvBandYFor(rowTop: number, laneCount: number): number { + return rowTop + Math.max(ZRV_BAND_MIN_Y, PLAN_TOP_Y + (laneCount - 1) * TIMELINE_LANE_STEP + ZRV_GAP); +} + +export type TimelineLayout = { + rowLayouts: TimelineRowLayout[]; + segments: TimelinePlanSegment[]; + height: number; +}; + +/** + * Строит полную раскладку таймлайна: переменная высота строк (по числу дорожек), абсолютные + * y-координаты планов и страйпов ЗРВ. Единственный источник правды по вертикали — компонент только + * рисует по готовым координатам. + */ +export function buildTimelineLayout( + rows: TimelineRow[], + range: TimelineRange, + timelineWidth: number +): TimelineLayout { + const rowLayouts: TimelineRowLayout[] = []; + const segments: TimelinePlanSegment[] = []; + let cursorY = TIMELINE_AXIS_HEIGHT; + + rows.forEach((row, rowIndex) => { + const placed: PlacedPlan[] = row.plans + .map((plan) => ({ plan, clipped: clipPlanToRange(plan, range) })) + .filter((item): item is { plan: TguPlanUi; clipped: { startMs: number; endMs: number } } => item.clipped !== null) + .map(({ plan, clipped }) => { + const x = timeToX(clipped.startMs, range, timelineWidth); + const x2 = timeToX(clipped.endMs, range, timelineWidth); + return { plan, x, x2, width: Math.max(3, x2 - x) }; + }) + .sort((a, b) => a.x - b.x || a.x2 - b.x2); + + const lanes = packLanes(placed); + const laneCount = lanes.length === 0 ? 1 : Math.max(...lanes) + 1; + const zrvBandY = zrvBandYFor(cursorY, laneCount); + const height = zrvBandY - cursorY + TIMELINE_ZRV_BAND_HEIGHT + ROW_BOTTOM_PAD; + + placed.forEach((item, planIndex) => { + segments.push({ + plan: item.plan, + rowIndex, + lane: lanes[planIndex], + centerY: cursorY + PLAN_TOP_Y + lanes[planIndex] * TIMELINE_LANE_STEP, + x: item.x, + x2: item.x2, + width: item.width + }); + }); + + rowLayouts.push({ row, rowIndex, y: cursorY, height, laneCount, zrvBandY }); + cursorY += height; + }); + + return { rowLayouts, segments, height: cursorY + TIMELINE_BOTTOM_PADDING }; +} + +export function buildTimeTicks(range: TimelineRange, targetCount = 8): number[] { + const span = range.toMs - range.fromMs; + const roughStep = span / targetCount; + const candidates = [ + 15 * 60_000, + 30 * 60_000, + 60 * 60_000, + 2 * 60 * 60_000, + 3 * 60 * 60_000, + 6 * 60 * 60_000, + 12 * 60 * 60_000, + 24 * 60 * 60_000 + ]; + const step = candidates.find((candidate) => candidate >= roughStep) ?? 24 * 60 * 60_000; + const first = Math.ceil(range.fromMs / step) * step; + const ticks: number[] = []; + + for (let tick = first; tick <= range.toMs; tick += step) { + ticks.push(tick); + } + + return ticks; +} diff --git a/services/pcp-tgu-ui-service/src/features/tgu-planning/tguTimelineMapper.test.ts b/services/pcp-tgu-ui-service/src/features/tgu-planning/tguTimelineMapper.test.ts new file mode 100644 index 0000000..f48f9ed --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-planning/tguTimelineMapper.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from "vitest"; +import type { TguPlatform, TguPlanStatus } from "../../model/tguTypes"; +import type { TguPlanUi } from "../../model/timelineTypes"; +import { + isOperationalPlatform, + operationalSpacecraftIds, + pausedSpacecraftIds, + unresolvedProblemSpacecraftIds +} from "./tguTimelineMapper"; +import { toLocalIso } from "../../utils/localTime"; + +function platform(overrides: Partial): TguPlatform { + return { id: "x", noradId: 1, status: "OPERATIONAL", ...overrides }; +} + +function uiPlan(spacecraftId: string, status: TguPlanStatus, startMs: number): TguPlanUi { + return { + planId: `${spacecraftId}-${status}-${startMs}`, + spacecraftId, + startTime: toLocalIso(startMs), + endTime: toLocalIso(startMs + 3_600_000), + kppId: "KPP-1", + status, + startMs, + endMs: startMs + 3_600_000 + }; +} + +describe("isOperationalPlatform", () => { + it("принимает только статус OPERATIONAL без учёта регистра и пробелов", () => { + expect(isOperationalPlatform(platform({ status: "OPERATIONAL" }))).toBe(true); + expect(isOperationalPlatform(platform({ status: " operational " }))).toBe(true); + expect(isOperationalPlatform(platform({ status: "STANDBY" }))).toBe(false); + expect(isOperationalPlatform(platform({ status: "LOST" }))).toBe(false); + expect(isOperationalPlatform(platform({ status: null }))).toBe(false); + expect(isOperationalPlatform(platform({ status: undefined }))).toBe(false); + }); +}); + +describe("operationalSpacecraftIds", () => { + it("собирает norad-id в виде строк и пропускает платформы без norad-id", () => { + const ids = operationalSpacecraftIds([ + platform({ noradId: 2 }), + platform({ noradId: 30 }), + platform({ noradId: null }), + platform({ noradId: undefined }) + ]); + expect(ids).toEqual(new Set(["2", "30"])); + }); +}); + +describe("pausedSpacecraftIds", () => { + const t0 = Date.UTC(2026, 5, 4, 0, 0, 0); + const hour = 3_600_000; + + it("помечает КА паузой, если есть REJECTED и нет продвинувшего цепочку плана после него", () => { + const paused = pausedSpacecraftIds([ + uiPlan("100", "REJECTED", t0), + // проекция и черновик паузу не снимают + uiPlan("100", "PLANNED", t0 + 2 * hour), + uiPlan("100", "DRAFT", t0) + ]); + expect(paused).toEqual(new Set(["100"])); + }); + + it("снимает паузу, если оператор отправил исправленный план (в полёте) на том же/более позднем окне", () => { + const paused = pausedSpacecraftIds([ + uiPlan("100", "REJECTED", t0), + uiPlan("100", "WAITING_DECISION", t0) + ]); + expect(paused.has("100")).toBe(false); + }); + + it("не помечает паузой, если цепочка ушла вперёд (ACCEPTED после отказа)", () => { + const paused = pausedSpacecraftIds([ + uiPlan("100", "REJECTED", t0), + uiPlan("100", "ACCEPTED", t0 + 5 * hour) + ]); + expect(paused.has("100")).toBe(false); + }); + + it("без REJECTED никого не помечает", () => { + expect(pausedSpacecraftIds([uiPlan("100", "WAITING_DECISION", t0)])).toEqual(new Set()); + }); +}); + +describe("unresolvedProblemSpacecraftIds (красный индикатор КА)", () => { + const t0 = Date.UTC(2026, 5, 4, 0, 0, 0); + const hour = 3_600_000; + + function dismissed(plan: TguPlanUi): TguPlanUi { + return { ...plan, dismissedAt: toLocalIso(t0) }; + } + + it("красит КА, если есть нерешённый REJECTED", () => { + expect(unresolvedProblemSpacecraftIds([uiPlan("100", "REJECTED", t0)])).toEqual(new Set(["100"])); + }); + + it("красит КА на START_AMBIGUOUS", () => { + expect(unresolvedProblemSpacecraftIds([uiPlan("100", "START_AMBIGUOUS", t0)])).toEqual(new Set(["100"])); + }); + + it("НЕ красит, если проблемный план снят с учёта", () => { + expect(unresolvedProblemSpacecraftIds([dismissed(uiPlan("100", "REJECTED", t0))])).toEqual(new Set()); + }); + + it("НЕ красит, если цепочка продвинулась за отказ (исправленный план в полёте)", () => { + const ids = unresolvedProblemSpacecraftIds([ + uiPlan("100", "REJECTED", t0), + uiPlan("100", "WAITING_DECISION", t0 + hour) + ]); + expect(ids.has("100")).toBe(false); + }); + + it("красит, если продвинувший план раньше отказа (отказ всё ещё актуален)", () => { + const ids = unresolvedProblemSpacecraftIds([ + uiPlan("100", "REJECTED", t0 + 5 * hour), + uiPlan("100", "AWAITING_LAYIN", t0) + ]); + expect(ids).toEqual(new Set(["100"])); + }); + + it("успешные/живые статусы сами по себе не красят", () => { + expect(unresolvedProblemSpacecraftIds([uiPlan("100", "LAID_IN", t0)])).toEqual(new Set()); + }); +}); diff --git a/services/pcp-tgu-ui-service/src/features/tgu-planning/tguTimelineMapper.ts b/services/pcp-tgu-ui-service/src/features/tgu-planning/tguTimelineMapper.ts new file mode 100644 index 0000000..52bf02d --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-planning/tguTimelineMapper.ts @@ -0,0 +1,248 @@ +import type { TguPlan, TguPlatform } from "../../model/tguTypes"; +import type { TguPlanUi, TguPlatformUi, TimelineRange, TimelineRow } from "../../model/timelineTypes"; +import { parseLocal } from "../../utils/localTime"; + +const PROBLEM_STATUSES = new Set(["REJECTED", "START_AMBIGUOUS"]); + +const OPERATIONAL_STATUS = "OPERATIONAL"; + +/** На морде показываем только эксплуатируемые КА — с остальными мы не работаем. */ +export function isOperationalPlatform(platform: TguPlatform): boolean { + return (platform.status ?? "").trim().toUpperCase() === OPERATIONAL_STATUS; +} + +/** norad-id эксплуатируемых КА; planId.spacecraftId сопоставляется именно с norad-id. */ +export function operationalSpacecraftIds(platforms: TguPlatform[]): Set { + const ids = new Set(); + for (const platform of platforms) { + if (platform.noradId !== null && platform.noradId !== undefined) { + ids.add(String(platform.noradId)); + } + } + return ids; +} + +export function mapPlans(plans: TguPlan[], platforms: TguPlatform[]): TguPlanUi[] { + const platformByNorad = buildPlatformByNorad(platforms); + + return plans + .map((plan) => { + const startMs = parseLocal(plan.startTime); + const endMs = parseLocal(plan.endTime); + const platform = platformByNorad.get(plan.spacecraftId); + return { + ...plan, + startMs, + endMs, + platform: platform ? toPlatformUi(plan.spacecraftId, platform, 0, false) : undefined + }; + }) + .filter((plan) => Number.isFinite(plan.startMs) && Number.isFinite(plan.endMs)); +} + +export function buildPlatformsForInterval( + platforms: TguPlatform[], + plans: TguPlanUi[], + range: TimelineRange +): TguPlatformUi[] { + const intervalPlans = filterPlansByRange(plans, range); + const platformByNorad = buildPlatformByNorad(platforms); + + // Красный индикатор КА = есть НЕразрешённый проблемный план (см. unresolvedProblemSpacecraftIds): + // отказ, который оператор ещё не обработал (цепочка не продвинулась) и не снял с учёта. Прежняя + // логика «есть хоть один REJECTED» красила КА вечно — даже после исправления или снятия с учёта. + const problemSpacecraft = unresolvedProblemSpacecraftIds(intervalPlans); + const planCount = new Map(); + for (const plan of intervalPlans) { + planCount.set(plan.spacecraftId, (planCount.get(plan.spacecraftId) ?? 0) + 1); + } + + const platformItems = Array.from(platformByNorad.entries()).map(([spacecraftId, platform]) => + toPlatformUi(spacecraftId, platform, planCount.get(spacecraftId) ?? 0, problemSpacecraft.has(spacecraftId)) + ); + + const knownSpacecraft = new Set(platformItems.map((platform) => platform.spacecraftId)); + for (const [spacecraftId, count] of planCount.entries()) { + if (!knownSpacecraft.has(spacecraftId)) { + platformItems.push({ + spacecraftId, + noradId: spacecraftId, + planCount: count, + hasProblemStatus: problemSpacecraft.has(spacecraftId) + }); + } + } + + return platformItems.sort((a, b) => platformLabel(a).localeCompare(platformLabel(b), "ru")); +} + +/** + * Статусы, означающие, что цепочка после отказа уже сдвинулась с места: оператор создал и отправил + * исправленный план (он в полёте) либо план уже принят/заложен. Проекция PLANNED и черновики DRAFT + * сюда НЕ входят — это ещё не действие оператора, пауза не снимается. + */ +const CHAIN_ADVANCED_STATUSES = new Set([ + "ISSUING", + "WAITING_DECISION", + "AWAITING_LAYIN", + "LAID_IN", + "ACCEPTED" +]); + +/** + * КА, у которых цепочка стоит на паузе после REJECTED: есть отклонённый план и НЕТ ни одного + * «продвинувшего цепочку» плана (см. [CHAIN_ADVANCED_STATUSES]) со стартом не раньше последнего + * отклонённого окна. Совпадает с серверной паузой ([IssueDuePlanWorker] не выдаёт окно с REJECTED) — + * оператор должен вручную создать исправленный план. + */ +/** + * КА с НЕразрешённой проблемой — для красного индикатора. Проблемный план (REJECTED/START_AMBIGUOUS) + * считается разрешённым и НЕ красит КА, если он либо снят с учёта (`dismissedAt`), либо цепочка уже + * продвинулась за него (есть [CHAIN_ADVANCED_STATUSES]-план со стартом не раньше). Так исправленные + * и снятые с учёта отказы гаснут сами, а реальные нерешённые — остаются красными. + */ +export function unresolvedProblemSpacecraftIds(plans: TguPlanUi[]): Set { + const latestAdvancedStart = new Map(); + for (const plan of plans) { + if (CHAIN_ADVANCED_STATUSES.has(plan.status)) { + const prev = latestAdvancedStart.get(plan.spacecraftId) ?? Number.NEGATIVE_INFINITY; + if (plan.startMs > prev) latestAdvancedStart.set(plan.spacecraftId, plan.startMs); + } + } + + const unresolved = new Set(); + for (const plan of plans) { + if (!PROBLEM_STATUSES.has(plan.status)) continue; + if (plan.dismissedAt) continue; // снят с учёта оператором + const advancedStart = latestAdvancedStart.get(plan.spacecraftId); + if (advancedStart !== undefined && advancedStart >= plan.startMs) continue; // цепочка ушла дальше + unresolved.add(plan.spacecraftId); + } + return unresolved; +} + +export function pausedSpacecraftIds(plans: TguPlanUi[]): Set { + const latestRejectedStart = new Map(); + for (const plan of plans) { + if (plan.status === "REJECTED") { + const prev = latestRejectedStart.get(plan.spacecraftId) ?? Number.NEGATIVE_INFINITY; + if (plan.startMs > prev) latestRejectedStart.set(plan.spacecraftId, plan.startMs); + } + } + + const paused = new Set(); + for (const [spacecraftId, rejectedStart] of latestRejectedStart) { + const advanced = plans.some( + (plan) => + plan.spacecraftId === spacecraftId && + CHAIN_ADVANCED_STATUSES.has(plan.status) && + plan.startMs >= rejectedStart + ); + if (!advanced) paused.add(spacecraftId); + } + return paused; +} + +export function buildTimelineRows( + plans: TguPlanUi[], + platforms: TguPlatformUi[], + range: TimelineRange, + selectedSpacecraftId?: string +): TimelineRow[] { + const platformBySpacecraft = new Map(platforms.map((platform) => [platform.spacecraftId, platform])); + const rowsBySpacecraft = new Map(); + + for (const plan of filterPlansByRange(plans, range)) { + if (selectedSpacecraftId && plan.spacecraftId !== selectedSpacecraftId) { + continue; + } + + const row = + rowsBySpacecraft.get(plan.spacecraftId) ?? + { + spacecraftId: plan.spacecraftId, + platform: platformBySpacecraft.get(plan.spacecraftId), + plans: [] + }; + + row.plans.push(plan); + rowsBySpacecraft.set(plan.spacecraftId, row); + } + + for (const platform of platforms) { + if (selectedSpacecraftId && platform.spacecraftId !== selectedSpacecraftId) { + continue; + } + if (!rowsBySpacecraft.has(platform.spacecraftId) && platform.planCount > 0) { + rowsBySpacecraft.set(platform.spacecraftId, { + spacecraftId: platform.spacecraftId, + platform, + plans: [] + }); + } + } + + return Array.from(rowsBySpacecraft.values()) + .map((row) => ({ + ...row, + platform: row.platform ?? platformBySpacecraft.get(row.spacecraftId), + plans: [...row.plans].sort((a, b) => a.startMs - b.startMs) + })) + .sort((a, b) => platformLabel(a.platform, a.spacecraftId).localeCompare(platformLabel(b.platform, b.spacecraftId), "ru")); +} + +export function filterPlatforms(platforms: TguPlatformUi[], query: string): TguPlatformUi[] { + const normalized = query.trim().toLowerCase(); + if (!normalized) return platforms; + + return platforms.filter((platform) => { + const searchText = [ + platform.name, + platform.noradId, + platform.spacecraftId, + platform.mission, + platform.status + ] + .filter(Boolean) + .join(" ") + .toLowerCase(); + + return searchText.includes(normalized); + }); +} + +export function filterPlansByRange(plans: TguPlanUi[], range: TimelineRange): TguPlanUi[] { + return plans.filter((plan) => plan.endMs >= range.fromMs && plan.startMs <= range.toMs); +} + +export function platformLabel(platform?: TguPlatformUi, fallback?: string): string { + return platform?.name || platform?.noradId || fallback || platform?.spacecraftId || "КА"; +} + +function buildPlatformByNorad(platforms: TguPlatform[]): Map { + const result = new Map(); + for (const platform of platforms) { + if (platform.noradId !== null && platform.noradId !== undefined) { + result.set(String(platform.noradId), platform); + } + } + return result; +} + +function toPlatformUi( + spacecraftId: string, + platform: TguPlatform, + planCount: number, + hasProblemStatus: boolean +): TguPlatformUi { + return { + spacecraftId, + name: platform.name ?? undefined, + noradId: platform.noradId === null || platform.noradId === undefined ? undefined : String(platform.noradId), + status: platform.status ?? undefined, + mission: platform.mission ?? undefined, + planCount, + hasProblemStatus, + platform + }; +} diff --git a/services/pcp-tgu-ui-service/src/features/tgu-requests/RequestCreatePanel.tsx b/services/pcp-tgu-ui-service/src/features/tgu-requests/RequestCreatePanel.tsx new file mode 100644 index 0000000..c6d7097 --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-requests/RequestCreatePanel.tsx @@ -0,0 +1,357 @@ +import { useState } from "react"; +import type { + OpticsParams, + OpticsResultType, + Polarisation, + RsaParams, + RsaResultType +} from "../../model/requestTypes"; +import { parseLocal } from "../../utils/localTime"; +import { DateTime24Field } from "../../components/timeFields"; + +export type RequestFormSubmit = { + name: string; + importance: number; + beginDateTime: string; + endDateTime: string; + kpp: number[]; + highPriorityTransmit: boolean; + optics?: OpticsParams; + rsa?: RsaParams; +}; + +type Props = { + vertexCount: number; + drawing: boolean; + defaultBegin: string; + defaultEnd: string; + submitting: boolean; + error?: string; + onUndoPoint: () => void; + onClear: () => void; + onFinishDrawing: () => void; + onRestartDrawing: () => void; + onSubmit: (form: RequestFormSubmit) => void; + onCancel: () => void; +}; + +const OPTICS_RESULT_TYPES: { value: OpticsResultType; label: string }[] = [ + { value: "PANCHROMATIC", label: "Панхроматическая" }, + { value: "MULTISPECTRAL", label: "Мультиспектральная" }, + { value: "PANSHARPENING", label: "Паншарпенинг" } +]; + +const RSA_RESULT_TYPES: { value: RsaResultType; label: string }[] = [ + { value: "SPOTLIGHT", label: "Spotlight" }, + { value: "STRIPMAP", label: "Stripmap" }, + { value: "SCANSAR", label: "ScanSAR" } +]; + +const POLARISATIONS: { value: Polarisation; label: string }[] = [ + { value: "HH", label: "HH" }, + { value: "VV", label: "VV" } +]; + +export function RequestCreatePanel({ + vertexCount, + drawing, + defaultBegin, + defaultEnd, + submitting, + error, + onUndoPoint, + onClear, + onFinishDrawing, + onRestartDrawing, + onSubmit, + onCancel +}: Props) { + const [name, setName] = useState(""); + const [importance, setImportance] = useState("1"); + const [begin, setBegin] = useState(defaultBegin); + const [end, setEnd] = useState(defaultEnd); + const [kpp, setKpp] = useState(""); + const [highPriority, setHighPriority] = useState(false); + + const [opticsEnabled, setOpticsEnabled] = useState(true); + const [opticsResultType, setOpticsResultType] = useState("PANCHROMATIC"); + const [opticsResolution, setOpticsResolution] = useState("1"); + const [sunAngleMin, setSunAngleMin] = useState("10"); + const [sunAngleMax, setSunAngleMax] = useState("90"); + const [clouds, setClouds] = useState("100"); + + const [rsaEnabled, setRsaEnabled] = useState(false); + const [rsaResultType, setRsaResultType] = useState("STRIPMAP"); + const [interferometry, setInterferometry] = useState(false); + const [polarisation, setPolarisation] = useState("HH"); + const [rsaResolution, setRsaResolution] = useState("1"); + + const importanceNum = Number(importance); + const beginMs = parseLocal(begin); + const endMs = parseLocal(end); + + const kppList = parseKpp(kpp); + const kppValid = kppList !== null; + + const geometryReady = vertexCount >= 3 && !drawing; + + const validationError = buildValidationError({ + geometryReady, + drawing, + vertexCount, + name, + importance, + importanceNum, + beginMs, + endMs, + kppValid, + opticsEnabled, + opticsResolution, + rsaEnabled, + rsaResolution + }); + + const handleSubmit = () => { + if (validationError || !kppList) return; + const optics: OpticsParams | undefined = opticsEnabled + ? { + resultType: opticsResultType, + resolution: Number(opticsResolution), + sunAngleMin: Number(sunAngleMin), + sunAngleMax: Number(sunAngleMax), + clouds: Number(clouds) + } + : undefined; + const rsa: RsaParams | undefined = rsaEnabled + ? { + resultType: rsaResultType, + interferometry, + polarisation, + resolution: Number(rsaResolution) + } + : undefined; + + onSubmit({ + name: name.trim(), + importance: importanceNum, + beginDateTime: begin, + endDateTime: end, + kpp: kppList, + highPriorityTransmit: highPriority, + optics, + rsa + }); + }; + + return ( + + ); +} + +function parseKpp(raw: string): number[] | null { + const trimmed = raw.trim(); + if (trimmed === "") return []; + const parts = trimmed.split(/[,\s]+/).filter(Boolean); + const result: number[] = []; + for (const part of parts) { + const n = Number(part); + if (!Number.isInteger(n) || n < 0) return null; + result.push(n); + } + return result; +} + +function buildValidationError(input: { + geometryReady: boolean; + drawing: boolean; + vertexCount: number; + name: string; + importance: string; + importanceNum: number; + beginMs: number; + endMs: number; + kppValid: boolean; + opticsEnabled: boolean; + opticsResolution: string; + rsaEnabled: boolean; + rsaResolution: string; +}): string | null { + if (input.drawing) return "Завершите рисование полигона (≥3 вершин)."; + if (!input.geometryReady) return "Задайте полигон минимум из 3 вершин."; + if (input.name.trim() === "") return "Укажите название заявки."; + if (input.importance.trim() === "" || !Number.isFinite(input.importanceNum) || input.importanceNum < 0) { + return "Важность должна быть числом ≥ 0."; + } + if (!Number.isFinite(input.beginMs) || !Number.isFinite(input.endMs) || input.beginMs >= input.endMs) { + return "Начало должно быть раньше конца."; + } + if (!input.kppValid) return "КПП — список целых чисел через запятую."; + if (!input.opticsEnabled && !input.rsaEnabled) { + return "Включите хотя бы один блок съёмки: Оптика или РСА."; + } + if (input.opticsEnabled && !(Number(input.opticsResolution) >= 0.1)) { + return "Разрешение оптики должно быть ≥ 0.1 м."; + } + if (input.rsaEnabled && !(Number(input.rsaResolution) >= 0.1)) { + return "Разрешение РСА должно быть ≥ 0.1 м."; + } + return null; +} diff --git a/services/pcp-tgu-ui-service/src/features/tgu-requests/RequestDetailsPanel.tsx b/services/pcp-tgu-ui-service/src/features/tgu-requests/RequestDetailsPanel.tsx new file mode 100644 index 0000000..a19d202 --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-requests/RequestDetailsPanel.tsx @@ -0,0 +1,172 @@ +import { useEffect, useState } from "react"; +import type { RequestAtPoint } from "../tgu-map-2d/Tgu2DMapRequestsPanel"; +import { formatLocalDateTime, parseLocal } from "../../utils/localTime"; + +type Props = { + requests: RequestAtPoint[]; + selectedPolygonId?: string; + deleting: boolean; + deleteError?: string; + onHover: (polygonId: string | undefined) => void; + onSelect: (polygonId: string) => void; + onDelete: (requestId: string) => void; + onClose: () => void; +}; + +export function RequestDetailsPanel({ + requests, + selectedPolygonId, + deleting, + deleteError, + onHover, + onSelect, + onDelete, + onClose +}: Props) { + const selectedEntry = selectedPolygonId + ? requests.find((r) => r.polygon.id === selectedPolygonId) + : requests.length === 1 + ? requests[0] + : undefined; + + // Второй шаг подтверждения удаления. Сбрасываем при смене выбранной заявки, + // чтобы открытый запрос на удаление не «переезжал» на другую заявку. + const [confirmingDelete, setConfirmingDelete] = useState(false); + useEffect(() => { + setConfirmingDelete(false); + }, [selectedEntry?.item.id]); + + return ( + + ); +} + +function formatDateTime(value: string): string { + return formatLocalDateTime(parseLocal(value)); +} + +function surveyTypeLabel(surveyType: string): string { + switch (surveyType) { + case "OPTICS": + return "Оптическая"; + case "RSA": + return "Радиолокационная (РСА)"; + case "COMBINED": + return "Комбинированная"; + default: + return surveyType; + } +} diff --git a/services/pcp-tgu-ui-service/src/features/tgu-requests/TguRequestsTab.tsx b/services/pcp-tgu-ui-service/src/features/tgu-requests/TguRequestsTab.tsx new file mode 100644 index 0000000..4e7b3ab --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-requests/TguRequestsTab.tsx @@ -0,0 +1,411 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { createRequest, deleteRequest, fetchRequestsForMap } from "../../api/requestApi"; +import { fetchStations, type StationDto } from "../../api/stationsApi"; +import type { RequestMapItem } from "../../model/requestTypes"; +import type { TguPlatformUi, TimelineRange } from "../../model/timelineTypes"; +import { buildTgu2DMapScene } from "../tgu-map-2d/Tgu2DMapLayers"; +import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView"; +import type { RequestAtPoint } from "../tgu-map-2d/Tgu2DMapRequestsPanel"; +import type { Tgu2DMapSelection } from "../tgu-map-2d/model/mapSelectionTypes"; +import type { GeoPoint, MapPolygon } from "../tgu-map-2d/model/mapTypes"; +import { generateUuid } from "../../utils/uuid"; +import { parseLocal, toLocalInputValue, toLocalIso } from "../../utils/localTime"; +import { DateTime24Field } from "../../components/timeFields"; +import { RequestCreatePanel, type RequestFormSubmit } from "./RequestCreatePanel"; +import { RequestDetailsPanel } from "./RequestDetailsPanel"; +import { polygonToWkt } from "./requestGeometry"; + +type TguRequestsTabProps = { + range: TimelineRange; + invalidRange: boolean; + platforms: TguPlatformUi[]; +}; + +type RequestsLoadState = { + fromValue: string; + toValue: string; + items: RequestMapItem[]; + loading: boolean; + loadedCount: number; + totalItems: number; + truncated: boolean; + error?: string; + loaded: boolean; +}; + +type PanelMode = "none" | "details" | "create"; + +function buildInitialLoadState(range: TimelineRange): RequestsLoadState { + return { + fromValue: toLocalInputValue(range.fromMs), + toValue: toLocalInputValue(range.toMs), + items: [], + loading: false, + loadedCount: 0, + totalItems: 0, + truncated: false, + loaded: false + }; +} + +export function TguRequestsTab({ range, invalidRange, platforms }: TguRequestsTabProps) { + const [requestsState, setRequestsState] = useState(() => buildInitialLoadState(range)); + const [stations, setStations] = useState([]); + const [showStations, setShowStations] = useState(true); + const [redrawToken, setRedrawToken] = useState(0); + + const [panelMode, setPanelMode] = useState("none"); + const [requestsAtPoint, setRequestsAtPoint] = useState([]); + const [selectedRequestPolygonId, setSelectedRequestPolygonId] = useState(); + const [hoveredRequestPolygonId, setHoveredRequestPolygonId] = useState(); + + const [draftPolygon, setDraftPolygon] = useState([]); + const [drawing, setDrawing] = useState(false); + const [creating, setCreating] = useState(false); + const [createError, setCreateError] = useState(); + + const [deleting, setDeleting] = useState(false); + const [deleteError, setDeleteError] = useState(); + const [notice, setNotice] = useState(); + + useEffect(() => { + fetchStations() + .then((items) => setStations(items)) + .catch(() => setStations([])); + }, []); + + 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 : "Не удалось загрузить заявки." + })); + } + }, []); + + useEffect(() => { + setRequestsState(buildInitialLoadState(range)); + }, [range]); + + const applyRange = useCallback(() => { + void loadRequests(requestsState.fromValue, requestsState.toValue); + }, [loadRequests, requestsState.fromValue, requestsState.toValue]); + + const scene = useMemo( + () => + buildTgu2DMapScene({ + range, + platforms, + requestItems: requestsState.items, + stationItems: showStations ? stations : [] + }), + [range, platforms, requestsState.items, showStations, stations] + ); + + const layers = useMemo( + () => ({ tracks: false, swath: false, planWorks: false, complexPlan: false, stations: showStations, spacecraftMarkers: false, requests: true }), + [showStations] + ); + + const closeAll = useCallback(() => { + setRequestsAtPoint([]); + setSelectedRequestPolygonId(undefined); + setHoveredRequestPolygonId(undefined); + setDeleteError(undefined); + }, []); + + const handleRequestsAtPoint = useCallback( + (polygons: MapPolygon[]) => { + if (panelMode === "create") return; + if (polygons.length === 0) { + closeAll(); + setPanelMode("none"); + return; + } + const joined = polygons.flatMap((polygon) => { + const item = requestsState.items.find((r) => r.id === polygon.requestId); + return item ? [{ polygon, item }] : []; + }); + setRequestsAtPoint(joined); + setSelectedRequestPolygonId(joined.length === 1 ? joined[0].polygon.id : undefined); + setHoveredRequestPolygonId(undefined); + setDeleteError(undefined); + setPanelMode(joined.length > 0 ? "details" : "none"); + }, + [panelMode, requestsState.items, closeAll] + ); + + // Детали заявок открываются через onRequestsAtPoint. onObjectSelect карта + // вызывает с undefined одновременно с onRequestsAtPoint, поэтому здесь no-op, + // чтобы не затирать только что открытую панель (станции/пустой клик уже + // закрывают панель через onRequestsAtPoint([])). + const handleObjectSelect = useCallback((_selection?: Tgu2DMapSelection) => {}, []); + + const startCreate = useCallback(() => { + closeAll(); + setDraftPolygon([]); + setDrawing(true); + setCreateError(undefined); + setNotice(undefined); + setPanelMode("create"); + }, [closeAll]); + + const cancelCreate = useCallback(() => { + setDraftPolygon([]); + setDrawing(false); + setCreateError(undefined); + setPanelMode("none"); + }, []); + + const handleDrawAddPoint = useCallback( + (point: GeoPoint) => { + if (panelMode !== "create" || !drawing) return; + setDraftPolygon((current) => [...current, point]); + }, + [panelMode, drawing] + ); + + const submitCreate = useCallback( + async (form: RequestFormSubmit) => { + let geometry: string; + try { + geometry = polygonToWkt(draftPolygon); + } catch (error) { + setCreateError(error instanceof Error ? error.message : "Некорректная геометрия."); + return; + } + + setCreating(true); + setCreateError(undefined); + try { + const id = generateUuid(); + await createRequest({ + id, + name: form.name, + geometry, + importance: form.importance, + beginDateTime: toLocalIso(parseLocal(form.beginDateTime)), + endDateTime: toLocalIso(parseLocal(form.endDateTime)), + kpp: form.kpp, + highPriorityTransmit: form.highPriorityTransmit, + optics: form.optics, + rsa: form.rsa + }); + setDraftPolygon([]); + setDrawing(false); + setPanelMode("none"); + setNotice(`Заявка «${form.name}» создана.`); + void loadRequests(requestsState.fromValue, requestsState.toValue); + } catch (error) { + setCreateError(error instanceof Error ? error.message : "Не удалось создать заявку."); + } finally { + setCreating(false); + } + }, + [draftPolygon, loadRequests, requestsState.fromValue, requestsState.toValue] + ); + + const handleDelete = useCallback( + async (requestId: string) => { + setDeleting(true); + setDeleteError(undefined); + try { + await deleteRequest(requestId); + closeAll(); + setPanelMode("none"); + setNotice("Заявка удалена."); + void loadRequests(requestsState.fromValue, requestsState.toValue); + } catch (error) { + setDeleteError(error instanceof Error ? error.message : "Не удалось удалить заявку."); + } finally { + setDeleting(false); + } + }, + [closeAll, loadRequests, requestsState.fromValue, requestsState.toValue] + ); + + const fromMs = parseLocal(requestsState.fromValue); + const toMs = parseLocal(requestsState.toValue); + const rangeInvalid = !Number.isFinite(fromMs) || !Number.isFinite(toMs) || fromMs >= toMs; + + const hasPanel = panelMode !== "none"; + + return ( +
+
+
+
Заявки
+
+ {requestsState.loaded ? `Загружено: ${requestsState.items.length}` : "Заявки не загружены"} +
+
+
+ + +
+ + +
+ +
+ + +
+ {invalidRange &&
Некорректный интервал: from должен быть меньше to.
} + {panelMode === "create" && drawing && ( +
+ Кликайте по карте, чтобы добавить вершины полигона. Нажмите «Завершить» (≥3 вершин). +
+ )} + +
+ + {panelMode === "details" && ( + { + closeAll(); + setPanelMode("none"); + }} + /> + )} + + {panelMode === "create" && ( + setDraftPolygon((cur) => cur.slice(0, -1))} + onClear={() => setDraftPolygon([])} + onFinishDrawing={() => setDrawing(false)} + onRestartDrawing={() => { + setDraftPolygon([]); + setDrawing(true); + }} + onSubmit={submitCreate} + onCancel={cancelCreate} + /> + )} +
+
+ ); +} diff --git a/services/pcp-tgu-ui-service/src/features/tgu-requests/requestGeometry.test.ts b/services/pcp-tgu-ui-service/src/features/tgu-requests/requestGeometry.test.ts new file mode 100644 index 0000000..4469fab --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-requests/requestGeometry.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { polygonToWkt } from "./requestGeometry"; + +describe("polygonToWkt", () => { + it("формирует WKT POLYGON с замыканием кольца", () => { + const wkt = polygonToWkt([ + { lon: 10, lat: 20 }, + { lon: 30, lat: 20 }, + { lon: 30, lat: 40 } + ]); + expect(wkt).toBe("POLYGON((10 20, 30 20, 30 40, 10 20))"); + }); + + it("не дублирует замыкающую точку, если кольцо уже замкнуто", () => { + const wkt = polygonToWkt([ + { lon: 0, lat: 0 }, + { lon: 5, lat: 0 }, + { lon: 5, lat: 5 }, + { lon: 0, lat: 0 } + ]); + expect(wkt).toBe("POLYGON((0 0, 5 0, 5 5, 0 0))"); + }); + + it("бросает ошибку при менее чем 3 вершинах", () => { + expect(() => polygonToWkt([{ lon: 0, lat: 0 }, { lon: 1, lat: 1 }])).toThrow(); + }); +}); diff --git a/services/pcp-tgu-ui-service/src/features/tgu-requests/requestGeometry.ts b/services/pcp-tgu-ui-service/src/features/tgu-requests/requestGeometry.ts new file mode 100644 index 0000000..f85be6a --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-requests/requestGeometry.ts @@ -0,0 +1,27 @@ +import type { GeoPoint } from "../tgu-map-2d/model/mapTypes"; + +/** + * Преобразует набор вершин полигона в WKT POLYGON. + * Порядок координат WKT — "lon lat". Кольцо замыкается первой вершиной, + * если оно ещё не замкнуто. Требуется минимум 3 вершины. + */ +export function polygonToWkt(points: GeoPoint[]): string { + if (points.length < 3) { + throw new Error("Полигон должен содержать минимум 3 вершины."); + } + + const ring = [...points]; + const first = ring[0]; + const last = ring[ring.length - 1]; + if (first.lon !== last.lon || first.lat !== last.lat) { + ring.push(first); + } + + const coords = ring.map((p) => `${formatCoord(p.lon)} ${formatCoord(p.lat)}`).join(", "); + return `POLYGON((${coords}))`; +} + +function formatCoord(value: number): string { + // ограничиваем разрядность, убираем хвостовые нули + return Number(value.toFixed(6)).toString(); +} diff --git a/services/pcp-tgu-ui-service/src/main.tsx b/services/pcp-tgu-ui-service/src/main.tsx new file mode 100644 index 0000000..2e3828b --- /dev/null +++ b/services/pcp-tgu-ui-service/src/main.tsx @@ -0,0 +1,19 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import App from './App' +import { AppAuthProvider, AuthTokenBridge } from './auth/appAuth' +// Стили сервиса ТГУ: тёмная тема на :root + токены экранов. @nstart/ui НЕ +// подключён, поэтому конфликта тем нет — theme.css ведущий и единственный. +import './styles/theme.css' +import './styles/editor-tokens.css' +import './styles/tgu-planning.css' +import './styles/tgu-editor.css' + +createRoot(document.getElementById('root')!).render( + + + + + + , +) diff --git a/services/pcp-tgu-ui-service/src/model/requestTypes.ts b/services/pcp-tgu-ui-service/src/model/requestTypes.ts new file mode 100644 index 0000000..fb916c4 --- /dev/null +++ b/services/pcp-tgu-ui-service/src/model/requestTypes.ts @@ -0,0 +1,106 @@ +export type RequestStatus = "ACCEPTED" | "ACTIVE" | "COMPLETED" | "EXPIRED" | "DELETED"; +export type SurveyType = "OPTICS" | "RSA" | "COMBINED"; + +export type RequestSummary = { + id: string; + name: string; + status: RequestStatus; + surveyType: SurveyType; + importance: number; + beginDateTime: string; + endDateTime: string; + kpp?: number[]; + highPriorityTransmit: boolean; + coveragePercent: number; + createdAt: string; + updatedAt: string; +}; + +export type RequestDetail = { + id: string; + name: string; + status: RequestStatus; + surveyType: SurveyType; + geometry: string; + importance: number; + beginDateTime: string; + endDateTime: string; + kpp?: number[]; + highPriorityTransmit: boolean; + createdAt: string; + updatedAt: string; +}; + +export type RequestListResponse = { + items: RequestSummary[]; + page: number; + size: number; + totalItems: number; + totalPages: number; +}; + +export type RequestFilter = { + beginFrom?: string; + beginTo?: string; + endFrom?: string; + endTo?: string; + status?: RequestStatus; + surveyType?: SurveyType; +}; + +export type RequestMapItem = { + id: string; + name: string; + status: RequestStatus; + surveyType: SurveyType; + geometry: string; + importance: number; + beginDateTime: string; + endDateTime: string; + kpp?: number[]; + highPriorityTransmit: boolean; + coveragePercent: number; + createdAt: string; + updatedAt: string; +}; + +export type OpticsResultType = "PANCHROMATIC" | "MULTISPECTRAL" | "PANSHARPENING"; +export type RsaResultType = "SPOTLIGHT" | "STRIPMAP" | "SCANSAR"; +export type Polarisation = "HH" | "VV"; + +export type OpticsParams = { + resultType: OpticsResultType; + resolution: number; + sunAngleMin: number; + sunAngleMax: number; + clouds: number; +}; + +export type RsaParams = { + resultType: RsaResultType; + interferometry: boolean; + polarisation: Polarisation; + resolution: number; +}; + +export type CreateRequestPayload = { + id: string; + name: string; + geometry: string; + importance: number; + beginDateTime: string; + endDateTime: string; + kpp: number[]; + highPriorityTransmit: boolean; + optics?: OpticsParams; + rsa?: RsaParams; +}; + +export type CreateRequestResult = { + id: string; + name: string; + status: RequestStatus; + surveyType: SurveyType; + importance: number; + message?: string; +}; diff --git a/services/pcp-tgu-ui-service/src/model/tguTypes.ts b/services/pcp-tgu-ui-service/src/model/tguTypes.ts new file mode 100644 index 0000000..48a5363 --- /dev/null +++ b/services/pcp-tgu-ui-service/src/model/tguTypes.ts @@ -0,0 +1,66 @@ +export type TguPlanStatus = + | "ACCEPTED" + | "REJECTED" + | "WAITING_DECISION" + | "PLANNED" + | "ISSUING" + | "START_AMBIGUOUS" + | string; + +export type TguPlanDecision = "ACCEPTED" | "REJECTED"; + +export type TguPlan = { + planId: string; + spacecraftId: string; + startTime: string; + endTime: string; + kppId: string; + status: TguPlanStatus; + /** + * Предшественник плана в цепочке. `null`/отсутствует — у плана нет предыстории (старт новой + * цепочки «без истории» или самый первый план): на таймлайне в него не ведут стрелку. + */ + basePlanId?: string | null; + /** `true` — вычисленная проекция будущего (не persisted); цепочку продолжает, но стартом не является. */ + projected?: boolean; + /** + * Время «снятия с учёта» (ISO без зоны) терминально-негативного плана. `null`/отсутствует — план + * на учёте. Снятый план гасится на таймлайне и не считается проблемой в индикаторе КА. + */ + dismissedAt?: string | null; +}; + +export type TguPlatform = { + id?: string | null; + businessKey?: string | null; + name?: string | null; + status?: string | null; + noradId?: number | string | null; + mission?: string | null; + validFrom?: string | null; + validTo?: string | null; +}; + +export type TguPlanDecisionMessage = { + eventId: string; + spacecraftId: string; + planId: string; + attemptId: string; + decision: TguPlanDecision; + decisionTime: string; + reason?: string | null; +}; + +export type TguApiErrorKind = "network" | "http"; + +export class TguApiError extends Error { + readonly kind: TguApiErrorKind; + readonly status?: number; + + constructor(message: string, kind: TguApiErrorKind, status?: number) { + super(message); + this.name = "TguApiError"; + this.kind = kind; + this.status = status; + } +} diff --git a/services/pcp-tgu-ui-service/src/model/timelineTypes.ts b/services/pcp-tgu-ui-service/src/model/timelineTypes.ts new file mode 100644 index 0000000..d0c0cbe --- /dev/null +++ b/services/pcp-tgu-ui-service/src/model/timelineTypes.ts @@ -0,0 +1,64 @@ +import type { TguPlan, TguPlanStatus, TguPlatform } from "./tguTypes"; + +export type TguPlatformUi = { + spacecraftId: string; + name?: string; + noradId?: string; + status?: string; + mission?: string; + planCount: number; + hasProblemStatus: boolean; + platform?: TguPlatform; +}; + +export type TguPlanUi = TguPlan & { + startMs: number; + endMs: number; + platform?: TguPlatformUi; +}; + +export type TimelineRange = { + fromMs: number; + toMs: number; +}; + +export type TguActiveTab = "timeline" | "map" | "complex" | "editor" | "requests" | "constellation"; + +export type TimelineRow = { + spacecraftId: string; + platform?: TguPlatformUi; + plans: TguPlanUi[]; +}; + +export type TimelinePlanSegment = { + plan: TguPlanUi; + rowIndex: number; + /** Индекс дорожки внутри строки КА (0 — верхняя); пересекающиеся планы получают разные дорожки. */ + lane: number; + /** Абсолютная вертикальная координата центра линии плана. */ + centerY: number; + x: number; + x2: number; + width: number; +}; + +export type TimelineRowLayout = { + row: TimelineRow; + rowIndex: number; + /** Верх строки (абсолютный y). */ + y: number; + height: number; + /** Сколько дорожек занято планами (>=1). */ + laneCount: number; + /** Абсолютный y верхней границы страйпа ЗРВ. */ + zrvBandY: number; +}; + +export type TimelineStatusStyle = { + status: TguPlanStatus; + label: string; + color: string; + className: string; + /** Человеческое пояснение статуса — тултип в легенде. */ + description: string; +}; diff --git a/services/pcp-tgu-ui-service/src/stubs/nstart-ui.tsx b/services/pcp-tgu-ui-service/src/stubs/nstart-ui.tsx new file mode 100644 index 0000000..a4cb43d --- /dev/null +++ b/services/pcp-tgu-ui-service/src/stubs/nstart-ui.tsx @@ -0,0 +1,34 @@ +// Сборочная заглушка @nstart/ui. +// +// @nstart/auth в barrel-экспорте статически реэкспортирует ProfilePage/UserPanel, +// которые тянут компоненты @nstart/ui (а с ними — lottie-анимации ~6 МБ и их CSS). +// Эти экраны в сервисе НЕ используются (свой UI ТГУ), но без резолва импортов +// сборка падает, а с реальным @nstart/ui — в dist утекают орфан-ассеты. +// +// Поэтому на этапе сборки @nstart/ui алиасится сюда (vite.config.ts resolve.alias): +// заглушка отдаёт ровно те имена, что импортирует @nstart/auth, и ничего не тянет. +// Код заглушек никогда не выполняется (ProfilePage/UserPanel не рендерятся). +// ТИПЫ для tsc берутся из реального @nstart/ui в node_modules (alias видит только vite). +// +// Если обновлённый @nstart/auth начнёт импортировать новые имена из @nstart/ui — +// сборка укажет MISSING_EXPORT, и сюда нужно добавить недостающий стаб. + +/** Универсальный стаб-компонент: ничего не рендерит. */ +const StubComponent = (): null => null + +/** Универсальный стаб-хук: возвращает пустой объект. */ +const stubHook = (): Record => ({}) + +export const Alert = StubComponent +export const Badge = StubComponent +export const Button = StubComponent +export const ConfirmDialog = StubComponent +export const FormGrid = StubComponent +export const IconButton = StubComponent +export const Modal = StubComponent +export const SectionCard = StubComponent +export const SingleSelect = StubComponent +export const Skeleton = StubComponent +export const TextInput = StubComponent +export const useNStartUiLocale = stubHook +export const usePageChrome = stubHook diff --git a/services/pcp-tgu-ui-service/src/styles/editor-tokens.css b/services/pcp-tgu-ui-service/src/styles/editor-tokens.css new file mode 100644 index 0000000..c24f05e --- /dev/null +++ b/services/pcp-tgu-ui-service/src/styles/editor-tokens.css @@ -0,0 +1,20 @@ +:root { + --bg-0: var(--bg-base); + --bg-1: var(--bg-panel); + --bg-2: var(--bg-panel-2); + --bg-3: var(--bg-elevated); + --ink: var(--text); + --ink-1: var(--text); + --ink-2: var(--text-muted); + --ink-3: var(--text-dim); + --grid: var(--line-soft); + --now: var(--warning); + --warn: var(--warning); + --mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace; + --t-optical: var(--accent); + --t-optical-dim: rgba(104, 195, 189, 0.18); + --t-radar: var(--warning); + --t-radar-dim: rgba(240, 173, 46, 0.16); + --t-combo: var(--danger); + --t-combo-dim: rgba(214, 69, 69, 0.16); +} diff --git a/services/pcp-tgu-ui-service/src/styles/tgu-editor.css b/services/pcp-tgu-ui-service/src/styles/tgu-editor.css new file mode 100644 index 0000000..7f86e9f --- /dev/null +++ b/services/pcp-tgu-ui-service/src/styles/tgu-editor.css @@ -0,0 +1,1223 @@ +.tgu-editor-workspace { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; + overflow: hidden; + background: #121516; +} + +.tgu-editor-toolbar { + display: flex; + align-items: center; + gap: 0.65rem; + min-width: 0; + min-height: 3.55rem; + overflow-x: auto; + border-bottom: 1px solid var(--line); + padding: 0.65rem 0.8rem; + background: var(--bg-panel); +} + +.tgu-editor-toolbar__identity { + display: flex; + align-items: center; + gap: 0.55rem; + min-width: 12rem; +} + +.tgu-editor-toolbar__title { + overflow: hidden; + max-width: 14rem; + color: var(--text); + font-size: 0.88rem; + font-weight: 700; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tgu-editor-toolbar__meta, +.tgu-editor-rail__meta, +.tgu-editor-panel__hint, +.tgu-editor-panel__id { + color: var(--text-dim); + font-family: var(--mono); + font-size: 0.68rem; +} + +.tgu-editor-panel__list { + margin: 0; + padding-left: 1.1rem; + display: flex; + flex-direction: column; + gap: 0.2rem; + font-size: 0.72rem; +} + +.tgu-editor-toolbar__group { + display: flex; + gap: 0.35rem; +} + +.tgu-editor-toolbar__spacer { + flex: 1; + min-width: 1rem; +} + +.tgu-editor-target-zrv { + display: inline-flex; + align-items: center; + gap: 0.4rem; + white-space: nowrap; +} + +.tgu-editor-target-zrv__label { + color: var(--text-dim); + font-size: 0.68rem; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.tgu-select { + height: 1.95rem; + max-width: 14rem; + border: 1px solid var(--line); + border-radius: 7px; + padding: 0 0.5rem; + background: #101214; + color: var(--text); + font-size: 0.78rem; +} + +.tgu-select:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.tgu-editor-type-dot { + width: 0.62rem; + height: 0.62rem; + border-radius: 3px; + background: var(--t-optical); + box-shadow: 0 0 12px rgba(104, 195, 189, 0.4); +} + +.tgu-editor-window-control { + display: inline-flex; + align-items: center; + gap: 0.12rem; + border: 1px solid var(--line); + border-radius: 7px; + padding: 0.12rem; + background: #101214; +} + +.tgu-editor-window-control button { + display: grid; + place-items: center; + width: 1.7rem; + height: 1.7rem; + border: 0; + border-radius: 5px; + background: transparent; + color: var(--text-muted); +} + +.tgu-editor-window-control span { + min-width: 4.2rem; + color: var(--text-dim); + font-family: var(--mono); + font-size: 0.64rem; + text-align: center; +} + +.tgu-editor-button--warning { + color: #ffe7b5; +} + +.tgu-editor-notices:empty { + display: none; +} + +.tgu-editor-workspace > .tgu-empty { + flex: 1; +} + +.tgu-editor-notice { + margin: 0.55rem 0.8rem 0; +} + +.tgu-editor-layout { + display: grid; + flex: 1; + min-width: 0; + min-height: 0; +} + +.tgu-editor-rail { + min-width: 0; + min-height: 0; + overflow: auto; + border-right: 1px solid var(--line); + background: var(--bg-panel); +} + +.tgu-editor-rail__section { + display: grid; + gap: 0.65rem; + padding: 0.8rem; + border-bottom: 1px solid var(--line-soft); +} + +.tgu-editor-eyebrow { + color: var(--text-dim); + font-family: var(--mono); + font-size: 0.62rem; + font-weight: 700; + text-transform: uppercase; +} + +.tgu-editor-rail__title { + overflow-wrap: anywhere; + color: var(--text); + font-size: 0.9rem; + font-weight: 700; +} + +.tgu-editor-rail__plan-dates { + display: grid; + grid-template-columns: auto 1fr; + gap: 0.2rem 0.6rem; + margin: 0; +} + +.tgu-editor-rail__plan-dates dt { + color: var(--text-dim); + font-size: 0.66rem; +} + +.tgu-editor-rail__plan-dates dd { + margin: 0; + color: var(--text); + font-size: 0.7rem; + text-align: right; +} + +.tgu-editor-stats { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.55rem; +} + +.tgu-editor-stat { + display: grid; + gap: 0.1rem; +} + +.tgu-editor-stat span { + color: var(--text); + font-family: var(--mono); + font-size: 1rem; + font-weight: 700; +} + +.tgu-editor-stat small { + color: var(--text-dim); + font-size: 0.62rem; + text-transform: uppercase; +} + +.tgu-editor-context-list { + display: grid; + gap: 0.28rem; + max-height: 21rem; + overflow: auto; +} + +.tgu-editor-context-item { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + gap: 0.45rem; + align-items: center; + width: 100%; + border: 0; + border-radius: 6px; + padding: 0.42rem 0.45rem; + background: transparent; + color: var(--text-muted); + text-align: left; +} + +.tgu-editor-context-item.is-enabled { + background: var(--bg-panel-2); + color: var(--text); +} + +.tgu-editor-context-item__check { + display: grid; + place-items: center; + width: 0.95rem; + height: 0.95rem; + border: 1px solid var(--line); + border-radius: 4px; + color: var(--accent); + font-size: 0.68rem; +} + +.tgu-editor-context-item span:nth-child(2) { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tgu-editor-context-item small { + color: var(--text-dim); + font-family: var(--mono); + font-size: 0.65rem; +} + +.tgu-editor-center { + display: grid; + /* grid-template-rows is set via inline style to support drag-resize */ + min-width: 0; + min-height: 0; + border-right: 1px solid var(--line); +} + +.tgu-editor-map-stage { + position: relative; + min-height: 6rem; + overflow: hidden; + border-bottom: 1px solid var(--line); + background: #0b0d0e; +} + +.map-pass-selector { + position: absolute; + top: 0.55rem; + left: 0.55rem; + z-index: 20; + width: 28rem; + border: 1px solid var(--line); + border-radius: 8px; + background: rgba(23, 26, 28, 0.93); + backdrop-filter: blur(6px); + font-size: 0.78rem; + user-select: none; +} + +.map-pass-selector--empty { + display: flex; + align-items: baseline; + gap: 0.5rem; + padding: 0.4rem 0.65rem; + width: auto; + max-width: 28rem; +} + +.map-pass-selector__empty-note { + color: var(--text-muted, #98a2ac); + font-size: 0.74rem; +} + +.map-pass-selector__header { + display: flex; + align-items: baseline; + gap: 0.45rem; + padding: 0.4rem 0.65rem; +} + +.map-pass-selector__title { + font-weight: 600; + color: var(--text); + flex-shrink: 0; +} + +.map-pass-selector__nums { + color: var(--accent); + font-family: var(--mono); + font-size: 0.72rem; + font-weight: 600; + flex-shrink: 0; +} + +.map-pass-selector__times { + color: var(--text-dim); + font-family: var(--mono); + font-size: 0.66rem; + flex: 1; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.map-pass-selector__reset { + flex-shrink: 0; + border: 1px solid var(--line); + border-radius: 4px; + padding: 0.06rem 0.4rem; + background: transparent; + color: var(--text-muted); + font-size: 0.66rem; + line-height: 1.4; +} + +.map-pass-selector__reset:hover { + background: var(--bg-elevated); + color: var(--text); +} + +.map-pass-selector__body { + padding: 0.1rem 1rem 0.55rem; + cursor: pointer; +} + +/* Track line */ +.map-pass-selector__track { + position: relative; + height: 4px; + border-radius: 2px; + background: var(--line); + margin: 0.85rem 0 0; +} + +.map-pass-selector__fill { + position: absolute; + top: 0; + height: 100%; + background: var(--accent); + border-radius: 2px; + pointer-events: none; +} + +/* Orbit tick marks */ +.map-pass-selector__tick { + position: absolute; + top: -3px; + width: 2px; + height: 10px; + margin-left: -1px; + border-radius: 1px; + background: var(--line-soft); + pointer-events: none; + transition: background 0.1s; +} + +.map-pass-selector__tick.is-active { + background: rgba(104, 195, 189, 0.4); +} + +/* Draggable handles */ +.map-pass-selector__thumb { + position: absolute; + top: 50%; + width: 14px; + height: 14px; + margin-left: -7px; + margin-top: -7px; + border-radius: 50%; + background: var(--accent); + border: 2px solid var(--bg-panel); + box-shadow: 0 0 0 1px var(--accent); + cursor: grab; + z-index: 2; + transition: transform 0.08s; +} + +.map-pass-selector__thumb:active { + cursor: grabbing; + transform: scale(1.18); +} + +/* Handle labels */ +.map-pass-selector__labels { + position: relative; + height: 2.2rem; + margin-top: 0.25rem; + pointer-events: none; +} + +.map-pass-selector__label { + position: absolute; + transform: translateX(-50%); + display: flex; + flex-direction: column; + align-items: center; + gap: 0.05rem; +} + +.map-pass-selector__label--right { + transform: translateX(-50%); +} + +.map-pass-selector__label-num { + font-weight: 700; + font-size: 0.72rem; + color: var(--accent); + line-height: 1; +} + +.map-pass-selector__label-time { + font-family: var(--mono); + font-size: 0.6rem; + color: var(--text-dim); + white-space: nowrap; + line-height: 1; +} + +.tgu-editor-map-stage .tgu-2d-map { + min-height: 6rem; +} + +.tgu-editor-map-resizer { + position: absolute; + bottom: -4px; + left: 0; + right: 0; + height: 8px; + cursor: row-resize; + z-index: 10; +} + +.tgu-editor-map-resizer::after { + content: ""; + position: absolute; + top: 3px; + left: 50%; + width: 3rem; + height: 2px; + border-radius: 1px; + background: var(--line-soft); + transform: translateX(-50%); + transition: background 0.15s; +} + +.tgu-editor-map-resizer:hover::after { + background: var(--accent); +} + +.tgu-2d-map.is-picking { + cursor: crosshair; +} + +.tgu-2d-map__target circle { + fill: rgba(240, 173, 46, 0.18); + stroke: var(--now); + stroke-width: 2; +} + +.tgu-2d-map__target line { + stroke: var(--now); + stroke-width: 1.4; +} + +.tgu-2d-map__target text { + fill: var(--text); + font-family: var(--mono); + font-size: 11px; + paint-order: stroke; + stroke: rgba(8, 16, 15, 0.85); + stroke-width: 3px; +} + +.tgu-2d-map__target--confirmed circle { + fill: var(--t-optical-dim); + stroke: var(--t-optical); +} + +.tgu-2d-map__target--confirmed line { + stroke: var(--t-optical); +} + +.tgu-2d-map__target--confirmed.is-selected circle { + fill: rgba(104, 195, 189, 0.35); + stroke: var(--t-optical); + stroke-width: 2.5; + r: 8; +} + +.tgu-2d-map__target--confirmed.is-selected line { + stroke-width: 2; +} + +.tgu-editor-timeline { + min-width: 0; + min-height: 0; + overflow: auto; + background: var(--bg-base); +} + +.tgu-editor-timeline__surface { + position: relative; + min-width: 46rem; +} + +.tgu-editor-timeline__axis-label, +.tgu-editor-timeline__axis, +.tgu-editor-timeline__lane-label { + position: absolute; + border-bottom: 1px solid var(--line); + background: var(--bg-panel); +} + +.tgu-editor-timeline__axis-label { + left: 0; + top: 0; + height: 40px; + border-right: 1px solid var(--line); + padding: 1.05rem 0.65rem 0; + color: var(--text-dim); + font-family: var(--mono); + font-size: 0.62rem; + text-transform: uppercase; +} + +.tgu-editor-timeline__axis { + top: 0; + height: 40px; +} + +.tgu-editor-timeline__day, +.tgu-editor-timeline__tick-label { + position: absolute; + border-left: 1px solid var(--line); + padding-left: 0.28rem; +} + +.tgu-editor-timeline__day { + top: 0.18rem; + display: flex; + gap: 0.25rem; + align-items: center; + height: 1rem; +} + +.tgu-editor-timeline__day strong { + color: var(--text); + font-size: 0.68rem; +} + +.tgu-editor-timeline__day span, +.tgu-editor-timeline__tick-label { + color: var(--text-dim); + font-family: var(--mono); + font-size: 0.58rem; +} + +.tgu-editor-timeline__tick-label { + top: 1.2rem; + height: 1.15rem; +} + +.tgu-editor-timeline__grid { + position: absolute; + bottom: 0; + pointer-events: none; +} + +.tgu-editor-timeline__grid > div { + position: absolute; + top: 0; + bottom: 0; + border-left: 1px solid var(--grid); +} + +.tgu-editor-timeline__bound { + position: absolute; + bottom: 0; + z-index: 8; + border-left: 1.5px solid var(--accent); + pointer-events: none; +} + +.tgu-editor-timeline__bound span { + position: absolute; + top: 0; + border-radius: 3px; + padding: 0 0.24rem; + background: var(--accent); + color: var(--bg-base); + font-family: var(--mono); + font-size: 0.55rem; + font-weight: 700; + text-transform: uppercase; + white-space: nowrap; +} + +.tgu-editor-timeline__bound--start span { + left: 0.18rem; +} + +.tgu-editor-timeline__bound--end span { + right: 0.18rem; +} + +.tgu-editor-timeline__lane { + position: absolute; + left: 0; + right: 0; +} + +.tgu-editor-timeline__lane-label { + left: 0; + top: 0; + bottom: 0; + display: flex; + flex-direction: column; + justify-content: center; + gap: 0.12rem; + border-right: 1px solid var(--line); + padding: 0 0.65rem; +} + +.tgu-editor-timeline__lane-label span { + overflow: hidden; + color: var(--text); + font-size: 0.76rem; + font-weight: 700; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tgu-editor-timeline__lane-label small { + color: var(--accent); + font-size: 0.62rem; +} + +.tgu-editor-timeline__lane-body { + position: absolute; + top: 0; + bottom: 0; + border-bottom: 1px solid var(--line-soft); +} + +.tgu-editor-work { + position: absolute; + top: 0.75rem; + display: flex; + align-items: start; + gap: 0.28rem; + height: calc(100% - 1.5rem); + overflow: hidden; + appearance: none; + border: 1px solid var(--line); + border-left: 3px solid var(--accent); + border-radius: 5px; + padding: 0.25rem 0.45rem; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; + user-select: none; +} + +.tgu-editor-work.is-selected { + outline: 1.5px solid var(--accent); + box-shadow: 0 0 0 3px rgba(104, 195, 189, 0.18); +} + +.tgu-editor-work.has-conflict { + border-color: var(--now); +} + +.tgu-editor-work--context { + top: 0.45rem; + height: calc(100% - 0.9rem); + border-top: 0; + border-right: 0; + border-bottom: 0; + opacity: 0.62; + pointer-events: none; +} + +/* Активная дорожка разбита на два ряда: верхний — съёмка, нижний — сброс. + Бар центрируется по своему ряду и растёт вместе с ним, но не выше потолка — + чтобы узкие включения не растягивались в непропорционально высокие полоски. */ +.tgu-editor-work--shoot, +.tgu-editor-work--downlink { + height: min(56px, calc(50% - 0.55rem)); + transform: translateY(-50%); +} + +.tgu-editor-work--shoot { + top: 25%; +} + +.tgu-editor-work--downlink { + top: 75%; +} + +.tgu-editor-timeline__row-hint { + position: absolute; + right: 0.65rem; + font-style: normal; + font-size: 0.56rem; + font-weight: 700; + letter-spacing: 0.02em; + text-transform: uppercase; +} + +.tgu-editor-timeline__row-hint--shoot { + top: 25%; + transform: translateY(-50%); + color: var(--t-optical); +} + +.tgu-editor-timeline__row-hint--downlink { + top: 75%; + transform: translateY(-50%); + color: var(--accent); +} + +.tgu-editor-work__dot { + flex: none; + width: 0.42rem; + height: 0.42rem; + margin-top: 0.2rem; + border-radius: 2px; +} + +.tgu-editor-work__label { + overflow: hidden; + color: var(--text); + font-family: var(--mono); + font-size: 0.62rem; + font-weight: 700; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tgu-editor-work small { + position: absolute; + left: 0.5rem; + bottom: 0.2rem; + color: var(--text-dim); + font-family: var(--mono); + font-size: 0.56rem; +} + +.tgu-editor-work__new { + flex: none; + border: 1px solid var(--now); + border-radius: 3px; + padding: 0 0.12rem; + color: var(--now); + font-family: var(--mono); + font-size: 0.48rem; +} + +.tgu-editor-visibility-label { + display: flex; + align-items: center; + gap: 0.35rem; +} + +.tgu-editor-visibility-label i { + flex: none; + width: 0.5rem; + height: 0.5rem; + border-radius: 2px; +} + +.tgu-editor-visibility-window { + position: absolute; + top: 0.32rem; + height: calc(100% - 0.64rem); + border: 1px solid var(--accent); + border-radius: 4px; +} + +.tgu-editor-context-divider { + position: absolute; + left: 0; + height: 22px; + border-top: 1px solid var(--line); + border-bottom: 1px solid var(--line); + padding: 0.34rem 0.65rem 0; + background: var(--bg-base); + color: var(--text-dim); + font-family: var(--mono); + font-size: 0.56rem; + text-transform: uppercase; +} + +.tgu-editor-side-panel { + min-width: 0; + min-height: 0; + overflow: auto; + background: var(--bg-panel); + padding: 0.9rem; +} + +.tgu-editor-panel { + position: relative; + display: grid; + gap: 0.9rem; +} + +.tgu-editor-panel__head, +.tgu-editor-panel__section-head, +.tgu-editor-panel__actions { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.6rem; +} + +.tgu-editor-panel__actions > .tgu-button { + flex: 1 1 0; + min-width: 0; + white-space: nowrap; +} + +.tgu-editor-panel__head h2 { + margin: 0; + font-size: 0.95rem; +} + +.tgu-editor-panel__section { + display: grid; + gap: 0.55rem; +} + +.tgu-editor-panel__placeholder, +.tgu-editor-target { + border: 1px dashed var(--line); + border-radius: 7px; + padding: 0.65rem; + background: var(--bg-panel-2); + color: var(--text-muted); + font-size: 0.78rem; + line-height: 1.45; +} + +.tgu-editor-target { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + gap: 0.5rem; + align-items: center; + border-style: solid; +} + +.tgu-editor-target i { + width: 0.6rem; + height: 0.6rem; + border: 2px solid var(--now); + border-radius: 50%; +} + +.tgu-editor-target span { + overflow: hidden; + color: var(--text); + font-weight: 700; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tgu-editor-target small { + color: var(--text-dim); + font-family: var(--mono); + font-size: 0.64rem; +} + +.tgu-editor-chip-row { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; +} + +.tgu-editor-chip-row button, +.tgu-editor-pass-list button, +.tgu-editor-station button { + border: 1px solid var(--line); + border-radius: 6px; + background: var(--bg-panel-2); + color: var(--text-muted); +} + +.tgu-editor-chip-row button { + padding: 0.38rem 0.55rem; + font-size: 0.72rem; + font-weight: 700; +} + +.tgu-editor-chip-row button.is-selected, +.tgu-editor-pass-list button.is-selected, +.tgu-editor-station button.is-selected { + border-color: rgba(104, 195, 189, 0.58); + background: rgba(104, 195, 189, 0.14); + color: var(--text); +} + +.tgu-editor-pass-list, +.tgu-editor-station-list { + display: grid; + gap: 0.35rem; + max-height: 14rem; + overflow: auto; +} + +.tgu-editor-pass-list button, +.tgu-editor-station button { + display: flex; + justify-content: space-between; + gap: 0.5rem; + padding: 0.45rem 0.55rem; + text-align: left; +} + +/* Узкая правая панель: строка прохода не помещается в один ряд — раскладываем + подпись и параметры видимости в столбец, чтобы текст не обрезался. */ +.tgu-editor-pass-list button { + flex-direction: column; + align-items: stretch; + gap: 0.2rem; +} + +.tgu-editor-pass-list small { + white-space: normal; +} + +.tgu-editor-pass-list span, +.tgu-editor-station span { + color: var(--text); + font-family: var(--mono); + font-size: 0.68rem; +} + +.tgu-editor-pass-list small, +.tgu-editor-station small { + color: var(--text-dim); + font-family: var(--mono); + font-size: 0.62rem; +} + +.tgu-editor-station { + overflow: hidden; + border: 1px solid var(--line-soft); + border-radius: 7px; +} + +.tgu-editor-station__head { + display: flex; + justify-content: space-between; + gap: 0.5rem; + padding: 0.5rem 0.6rem; + background: var(--bg-panel-2); +} + +.tgu-editor-panel select { + width: 100%; + border: 1px solid var(--line); + border-radius: 6px; + padding: 0.45rem 0.5rem; + background: #101214; + color: var(--text); +} + +.tgu-editor-time-stepper { + display: grid; + gap: 0.25rem; +} + +.tgu-editor-time-stepper > span { + color: var(--text-dim); + font-family: var(--mono); + font-size: 0.6rem; +} + +.tgu-editor-time-stepper div { + display: flex; + align-items: center; + gap: 0.25rem; +} + +.tgu-editor-time-stepper button { + flex: none; + border: 1px solid var(--line); + border-radius: 5px; + padding: 0.32rem 0.38rem; + background: var(--bg-panel-2); + color: var(--text-muted); + font-family: var(--mono); + font-size: 0.62rem; +} + +.tgu-editor-time-stepper output, +.tgu-editor-time-stepper input { + flex: 1; + min-width: 0; + border: 1px solid var(--line); + border-radius: 5px; + padding: 0.34rem 0.4rem; + background: #101214; + color: var(--text); + font-family: var(--mono); + font-size: 0.66rem; + text-align: center; +} + +.tgu-editor-time-stepper input::-webkit-calendar-picker-indicator { + filter: invert(1); + opacity: 0.5; + cursor: pointer; +} + +/* Секундный степпер окна маршрута: поле ввода на своей строке, кнопки ± — ниже, + чтобы помещаться в узкую правую панель. */ +.tgu-editor-time-stepper--seconds { + gap: 0.3rem; +} + +.tgu-editor-time-stepper--seconds > input { + width: 100%; +} + +.tgu-editor-time-stepper__steps { + display: flex; + gap: 0.25rem; +} + +.tgu-editor-time-stepper__steps button { + flex: 1; +} + +.tgu-editor-panel__color { + position: absolute; + top: 0.2rem; + left: -0.9rem; + width: 0.18rem; + height: 1.4rem; + border-radius: 999px; +} + +@media (max-width: 1180px) { + .tgu-editor-layout { + grid-template-columns: 1fr !important; + } + + .tgu-editor-rail, + .tgu-editor-center, + .tgu-editor-side-panel { + border-right: 0; + border-bottom: 1px solid var(--line); + } + + .tgu-editor-rail { + max-height: 16rem; + } + + .tgu-editor-side-panel { + max-height: 24rem; + } +} + +/* Список объектов под точкой клика на карте (раскрывающиеся карточки). */ +.tgu-editor-pick { + display: grid; + gap: 0.35rem; +} + +.tgu-editor-pick__row { + display: grid; + grid-template-columns: auto 1fr auto; + grid-template-rows: auto auto; + column-gap: 0.5rem; + align-items: center; + width: 100%; + border: 1px solid var(--line); + border-radius: 6px; + padding: 0.45rem 0.55rem; + background: var(--bg-panel-2); + color: var(--text); + text-align: left; + cursor: pointer; +} + +.tgu-editor-pick__row:hover { + border-color: rgba(104, 195, 189, 0.45); +} + +.tgu-editor-pick__item.is-open .tgu-editor-pick__row { + border-color: rgba(104, 195, 189, 0.58); + background: rgba(104, 195, 189, 0.14); +} + +.tgu-editor-pick__swatch { + grid-row: 1 / span 2; + width: 10px; + height: 10px; + border-radius: 2px; + background: var(--text-dim); +} + +.tgu-editor-pick__swatch--requests { background: #ffa040; } +.tgu-editor-pick__swatch--planWorks { background: #4fd2b0; } +.tgu-editor-pick__swatch--drop { background: #ffcf3f; } +.tgu-editor-pick__swatch--station { background: #d5e2e2; } +.tgu-editor-pick__swatch--swath { background: #6fb7ff; } + +.tgu-editor-pick__title { + font-size: 0.74rem; + font-weight: 600; +} + +.tgu-editor-pick__sub { + grid-column: 2; + color: var(--text-dim); + font-size: 0.62rem; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.tgu-editor-pick__chevron { + grid-row: 1 / span 2; + color: var(--text-dim); + font-size: 0.7rem; +} + +/* Вложенная карточка детали внутри раскрытого элемента — без двойной рамки/отступов. */ +.tgu-editor-pick__detail { + padding: 0.35rem 0 0.1rem; +} + +.tgu-editor-pick__detail .tgu-editor-panel { + border: 0; + padding: 0; +} + +/* Диалог выбора режима очистки плана (всё / оставить ручные съёмки). */ +.tgu-editor-clear-dialog { + width: min(26rem, calc(100vw - 2rem)); +} + +.tgu-editor-clear-dialog__text { + margin: 0; + font-size: 0.82rem; + color: var(--text-muted, #9fb0b3); +} + +.tgu-editor-clear-dialog__choices { + display: flex; + flex-direction: column; + gap: 0.55rem; +} + +.tgu-editor-clear-dialog__choices > .tgu-button { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.15rem; + width: 100%; + padding: 0.6rem 0.75rem; + text-align: left; +} + +.tgu-editor-clear-dialog__choices > .tgu-button > small { + font-weight: 400; + font-size: 0.72rem; + opacity: 0.85; +} diff --git a/services/pcp-tgu-ui-service/src/styles/tgu-planning.css b/services/pcp-tgu-ui-service/src/styles/tgu-planning.css new file mode 100644 index 0000000..cd56a02 --- /dev/null +++ b/services/pcp-tgu-ui-service/src/styles/tgu-planning.css @@ -0,0 +1,1599 @@ +.tgu-app-shell { + display: grid; + grid-template-rows: auto minmax(0, 1fr); + height: 100%; + background: + linear-gradient(180deg, rgba(104, 195, 189, 0.06), transparent 22rem), + var(--bg-base); +} + +.tgu-toolbar { + border-bottom: 1px solid var(--line); + background: rgba(23, 26, 28, 0.96); + box-shadow: var(--shadow); + z-index: 2; +} + +.tgu-toolbar__row { + display: flex; + align-items: center; + gap: 0.75rem; + min-height: 4.4rem; + padding: 0.75rem 1rem 0.65rem; + overflow-x: auto; +} + +.tgu-toolbar__brand { + display: flex; + align-items: center; + gap: 0.65rem; + min-width: 13.5rem; + padding-right: 0.5rem; +} + +.tgu-toolbar__mark { + width: 1.5rem; + height: 1.5rem; + border: 2px solid var(--accent); + border-radius: 50%; + box-shadow: 0 0 18px rgba(104, 195, 189, 0.42); +} + +.tgu-toolbar__title { + font-size: 0.96rem; + font-weight: 700; +} + +.tgu-toolbar__subtitle { + margin-top: 0.1rem; + color: var(--text-dim); + font-family: "SFMono-Regular", Consolas, monospace; + font-size: 0.68rem; + text-transform: uppercase; +} + +.tgu-field { + display: flex; + align-items: center; + gap: 0.45rem; + color: var(--text-muted); + font-size: 0.72rem; +} + +.tgu-field input, +.tgu-search input { + min-height: 2rem; + border: 1px solid var(--line); + border-radius: 6px; + background: #101214; + color: var(--text); + outline: none; +} + +.tgu-field input { + width: 12.8rem; + padding: 0.38rem 0.55rem; +} + +.tgu-field input:focus, +.tgu-search input:focus { + border-color: var(--accent); + box-shadow: 0 0 0 2px rgba(104, 195, 189, 0.16); +} + +.tgu-button { + min-height: 2rem; + border: 1px solid var(--line); + border-radius: 6px; + padding: 0.38rem 0.72rem; + background: var(--bg-panel-2); + color: var(--text); + white-space: nowrap; +} + +.tgu-button:hover:not(:disabled) { + border-color: #4e5a5d; + background: var(--bg-elevated); +} + +.tgu-button--primary { + border-color: rgba(104, 195, 189, 0.58); + background: #214341; + color: #eafffd; +} + +.tgu-button.is-active { + border-color: rgba(104, 195, 189, 0.58); + background: rgba(104, 195, 189, 0.16); + color: var(--accent-strong); +} + +.tgu-button--accept { + border-color: rgba(47, 158, 68, 0.65); + background: rgba(47, 158, 68, 0.16); +} + +.tgu-button--reject { + border-color: rgba(214, 69, 69, 0.7); + background: rgba(214, 69, 69, 0.14); +} + +.tgu-tabs { + display: inline-flex; + gap: 0.15rem; + border: 1px solid var(--line); + border-radius: 7px; + padding: 0.18rem; + background: #101214; +} + +.tgu-tabs button { + min-height: 1.85rem; + border: 0; + border-radius: 5px; + padding: 0.28rem 0.75rem; + background: transparent; + color: var(--text-muted); +} + +.tgu-tabs button.is-active { + background: var(--bg-elevated); + color: var(--accent-strong); +} + +.tgu-legend { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.48rem 0.82rem; + padding: 0 1rem 0.75rem; + color: var(--text-muted); + font-size: 0.74rem; +} + +.tgu-legend__item { + display: inline-flex; + align-items: center; + gap: 0.35rem; + white-space: nowrap; + cursor: help; +} + +.tgu-legend__swatch { + width: 1.05rem; + height: 0.46rem; + border-radius: 999px; + background: #79828d; +} + +.status-awaiting-layin { + background-color: #2f9e44; +} + +.status-laid-in { + background-color: #1b6b34; +} + +.status-rejected { + background-color: #d64545; +} + +.status-not-accepted { + background-color: #e8590c; +} + +.status-layin-failed { + background-color: #a61e4d; +} + +.status-superseded { + background-color: #6b7280; +} + +.status-waiting { + background-color: #f0ad2e; +} + +.status-planned { + background-color: #8d99a6; +} + +.status-issuing { + background-color: #2f80ed; +} + +.status-ambiguous { + background-color: #b42318; + outline: 1px dashed #ffd1d1; +} + +.status-unknown { + background-color: #79828d; +} + +.status-draft { + background-color: #a98bdb; + outline: 1px dashed #e7dbff; +} + +.tgu-workspace { + display: grid; + grid-template-columns: 18.5rem minmax(0, 1fr) 22rem; + min-width: 0; + min-height: 0; +} + +.tgu-sidebar, +.tgu-details { + min-height: 0; + background: var(--bg-panel); +} + +.tgu-sidebar { + display: flex; + flex-direction: column; + border-right: 1px solid var(--line); +} + +.tgu-sidebar__legend { + border-top: 1px solid var(--line); + margin-top: auto; +} + +.tgu-sidebar__legend .tgu-legend { + flex-direction: column; + align-items: flex-start; + padding: 0.75rem 1rem; +} + +.tgu-panel-heading { + display: flex; + justify-content: space-between; + align-items: center; + min-height: 3rem; + padding: 0.75rem 0.9rem; + border-bottom: 1px solid var(--line-soft); + font-size: 0.82rem; + font-weight: 700; + text-transform: uppercase; +} + +.tgu-link-button { + border: 0; + background: transparent; + color: var(--accent); + font-size: 0.78rem; +} + +.tgu-search { + padding: 0.75rem 0.8rem; +} + +.tgu-search input { + width: 100%; + padding: 0.42rem 0.65rem; +} + +.tgu-spacecraft-list { + display: grid; + align-content: start; + gap: 0.35rem; + min-height: 0; + overflow: auto; + padding: 0 0.55rem 0.9rem; +} + +.tgu-spacecraft { + display: grid; + grid-template-columns: 0.58rem minmax(0, 1fr) auto; + gap: 0.55rem; + align-items: center; + width: 100%; + min-height: 4.35rem; + border: 1px solid transparent; + border-radius: 7px; + padding: 0.55rem 0.62rem; + background: transparent; + color: var(--text); + text-align: left; +} + +.tgu-spacecraft:hover, +.tgu-spacecraft.is-selected { + border-color: var(--line); + background: var(--bg-panel-2); +} + +.tgu-spacecraft.is-selected { + border-color: rgba(104, 195, 189, 0.6); +} + +.tgu-spacecraft__problem { + width: 0.48rem; + height: 0.48rem; + border-radius: 50%; + background: #465154; +} + +.tgu-spacecraft__problem.is-problem { + background: var(--danger); + box-shadow: 0 0 12px rgba(214, 69, 69, 0.55); +} + +.tgu-spacecraft__body { + display: grid; + min-width: 0; + gap: 0.18rem; +} + +.tgu-spacecraft__name { + overflow: hidden; + font-size: 0.86rem; + font-weight: 650; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tgu-spacecraft__meta, +.tgu-spacecraft__status { + overflow: hidden; + color: var(--text-dim); + font-size: 0.72rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tgu-spacecraft__count { + display: grid; + place-items: center; + min-width: 1.75rem; + height: 1.5rem; + border-radius: 5px; + background: #101214; + color: var(--accent-strong); + font-family: "SFMono-Regular", Consolas, monospace; + font-size: 0.78rem; +} + +.tgu-center { + min-width: 0; + min-height: 0; + overflow: auto; + background: #121516; +} + +.tgu-timeline { + min-width: 48rem; + min-height: 100%; + padding: 0.85rem; +} + +.tgu-timeline__svg { + display: block; + width: 100%; + border: 1px solid var(--line); + border-radius: 8px; + background: #151819; +} + +.tgu-timeline__background { + fill: #151819; +} + +.tgu-timeline__labels-background { + fill: #181c1e; +} + +.tgu-timeline__axis, +.tgu-timeline__grid { + stroke: #343c3f; + stroke-width: 1; +} + +.tgu-timeline__grid { + stroke-dasharray: 3 5; +} + +.tgu-timeline__tick-date { + fill: #d2dddd; + font-size: 12px; + font-weight: 650; +} + +.tgu-timeline__tick-time, +.tgu-timeline__row-meta { + fill: #79878a; + font-size: 11px; +} + +.tgu-timeline__paused-badge { + fill: #f0a73d; + font-size: 11px; + font-weight: 600; + cursor: help; +} + +.tgu-timeline__now { + stroke: #f46e4f; + stroke-width: 1.7; + stroke-dasharray: 5 5; +} + +.tgu-timeline__now-label { + fill: #f46e4f; + font-size: 12px; + font-weight: 700; +} + +.tgu-timeline__row { + fill: #151819; + stroke: #22282a; + stroke-width: 1; +} + +.tgu-timeline__row--alt { + fill: #171b1d; +} + +.tgu-timeline__row-title { + fill: #e2e9e9; + font-size: 13px; + font-weight: 700; +} + +.tgu-timeline-plan { + cursor: pointer; + transition: opacity 0.12s ease; +} + +/* Снят с учёта: приглушаем план и делаем штриховку — виден, но явно «второстепенный». + Объявлен ВЫШЕ is-dimmed, чтобы при активном выборе сильное выбор-затемнение перебивало это. */ +.tgu-timeline-plan.is-dismissed-plan { + opacity: 0.4; +} +.tgu-timeline-plan.is-dismissed-plan .tgu-timeline-plan__bar { + stroke-dasharray: 3 4; +} + +/* При активном выборе непричастные планы гасим до еле заметных — на их фоне читаются + яркие рамки текущего/прошлого/следующего звена цепочки. */ +.tgu-timeline-plan.is-dimmed { + opacity: 0.16; +} + +.tgu-timeline-plan__bar { + stroke-linecap: round; + stroke-width: 13; +} + +/* Черновик на таймлайне — пунктирная линия, чтобы отличить кандидата от реальных планов. */ +.tgu-timeline-plan__bar.status-draft { + stroke-dasharray: 7 5; +} + +/* Яркая рамка ТЕКУЩЕГО (выбранного) плана — blue-600. */ +.tgu-timeline-plan__selection { + stroke: #2563eb; + stroke-linecap: round; + stroke-width: 19; + opacity: 1; +} + +/* Яркая рамка соседнего по цепочке плана: прошлый — slate-400, будущий — amber-500. */ +.tgu-timeline-plan__neighbor { + stroke-linecap: round; + stroke-width: 19; + opacity: 1; +} + +.tgu-timeline-plan__neighbor--prev { + stroke: #94a3b8; +} + +.tgu-timeline-plan__neighbor--next { + stroke: #f59e0b; +} + +.tgu-timeline-plan__label { + fill: #101214; + font-size: 11px; + font-weight: 700; + pointer-events: none; +} + +.tgu-details { + display: flex; + flex-direction: column; + gap: 0.9rem; + overflow: auto; + border-left: 1px solid var(--line); + padding: 1rem; +} + +.tgu-details--empty { + align-items: center; + justify-content: center; +} + +.tgu-details__placeholder { + display: grid; + justify-items: center; + gap: 0.75rem; + color: var(--text-dim); + text-align: center; +} + +.tgu-details__placeholder-icon { + width: 2.2rem; + height: 1rem; + border: 2px solid var(--line); + border-radius: 999px; +} + +.tgu-details__header { + display: flex; + align-items: start; + justify-content: space-between; + gap: 0.8rem; +} + +.tgu-details__eyebrow { + color: var(--text-dim); + font-size: 0.68rem; + text-transform: uppercase; +} + +.tgu-details h2 { + margin: 0.25rem 0 0; + overflow-wrap: anywhere; + font-size: 1rem; + line-height: 1.25; +} + +.tgu-icon-button { + display: grid; + place-items: center; + width: 1.8rem; + height: 1.8rem; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--bg-panel-2); + color: var(--text-muted); +} + +.tgu-status-pill { + align-self: flex-start; + border-radius: 999px; + padding: 0.22rem 0.65rem; + color: #101214; + font-family: "SFMono-Regular", Consolas, monospace; + font-size: 0.72rem; + font-weight: 800; +} + +.tgu-details__pills { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; +} + +/* Бейдж «снят с учёта» рядом со статусом: нейтральный серый, чтобы не путать со статусом. */ +.tgu-status-pill--dismissed { + background: #475569; + color: #e2e8f0; +} + +.tgu-details-grid { + display: grid; + gap: 0.72rem; + margin: 0; +} + +.tgu-details-grid dt { + margin-bottom: 0.18rem; + color: var(--text-dim); + font-size: 0.68rem; + text-transform: uppercase; +} + +.tgu-details-grid dd { + margin: 0; + overflow-wrap: anywhere; + color: var(--text); + font-size: 0.84rem; +} + +.tgu-details__actions { + display: flex; + gap: 0.6rem; + padding-top: 0.35rem; +} + +/* Действия над черновиком — переносим на несколько строк, чтобы три кнопки помещались. */ +.tgu-details__actions--draft { + flex-wrap: wrap; +} + +/* Тестовый блок — отделён сверху, с предупреждающей подписью «ТЕСТОВЫЙ ФУНКЦИОНАЛ». */ +.tgu-details__test-block { + display: flex; + flex-direction: column; + gap: 0.4rem; + margin-top: 0.9rem; + padding-top: 0.7rem; + border-top: 1px dashed var(--line); +} + +.tgu-details__test-label { + font-size: 0.66rem; + font-weight: 700; + letter-spacing: 0.08em; + color: #d97706; +} + +.tgu-details__test-hint { + font-size: 0.74rem; + line-height: 1.3; + color: var(--muted, #79828d); +} + +.tgu-alert { + border: 1px solid var(--line); + border-radius: 7px; + padding: 0.62rem 0.72rem; + font-size: 0.8rem; + line-height: 1.35; +} + +.tgu-toolbar > .tgu-alert { + margin: 0 1rem 0.75rem; +} + +.tgu-alert--danger { + border-color: rgba(214, 69, 69, 0.48); + background: rgba(214, 69, 69, 0.12); + color: #ffd9d9; +} + +.tgu-alert--warning { + border-color: rgba(240, 173, 46, 0.48); + background: rgba(240, 173, 46, 0.12); + color: #ffe7b5; +} + +.tgu-alert--success { + border-color: rgba(47, 158, 68, 0.48); + background: rgba(47, 158, 68, 0.12); + color: #c9f7d2; +} + +.tgu-empty { + display: grid; + place-items: center; + min-height: 18rem; + padding: 2rem; + color: var(--text-dim); + text-align: center; +} + +.tgu-empty--compact { + min-height: 5rem; + padding: 1rem; + font-size: 0.82rem; +} + +.tgu-map-workspace { + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto auto; + min-width: 0; + min-height: 0; + background: #121516; +} + +.tgu-map-toolbar { + display: flex; + align-items: center; + gap: 0.85rem; + min-height: 3.4rem; + padding: 0.7rem 0.9rem; + border-bottom: 1px solid var(--line); + background: var(--bg-panel); +} + +.tgu-map-toolbar__title { + font-size: 0.92rem; + font-weight: 700; +} + +.tgu-map-toolbar__subtitle { + margin-top: 0.12rem; + color: var(--text-dim); + font-size: 0.75rem; +} + +.tgu-map-toolbar__toggles { + display: flex; + flex: 1; + flex-wrap: wrap; + gap: 0.35rem; +} + +.tgu-map-toolbar__toggles button { + min-height: 1.8rem; + border: 1px solid var(--line); + border-radius: 999px; + padding: 0.25rem 0.7rem; + background: #101214; + color: var(--text-muted); +} + +.tgu-map-toolbar__toggles button.is-active { + border-color: rgba(104, 195, 189, 0.58); + background: rgba(104, 195, 189, 0.16); + color: var(--accent-strong); +} + +.tgu-map-content { + display: grid; + grid-template-columns: 19rem minmax(0, 1fr); + min-width: 0; + min-height: 0; +} + +.tgu-map-panel { + min-height: 0; + overflow: auto; + border-right: 1px solid var(--line); + background: var(--bg-panel); +} + +.tgu-map-panel__section { + display: grid; + gap: 0.45rem; + padding: 0.85rem 0.9rem; + border-bottom: 1px solid var(--line-soft); +} + +.tgu-map-panel__label { + color: var(--text-dim); + font-size: 0.68rem; + font-weight: 700; + text-transform: uppercase; +} + +.tgu-map-panel__value { + overflow-wrap: anywhere; + color: var(--text); + font-size: 0.84rem; +} + +.tgu-map-layer-list { + display: grid; + gap: 0.45rem; +} + +.tgu-map-layer { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 0.55rem; + align-items: start; + padding: 0.55rem 0.6rem; + border: 1px solid var(--line-soft); + border-radius: 7px; + background: #111416; +} + +.tgu-map-layer input { + margin-top: 0.18rem; + accent-color: var(--accent); +} + +.tgu-map-layer__title, +.tgu-map-layer__description { + display: block; +} + +.tgu-map-layer__title { + font-size: 0.82rem; + font-weight: 650; +} + +.tgu-map-layer__description { + margin-top: 0.14rem; + color: var(--text-dim); + font-size: 0.72rem; + line-height: 1.3; +} + +.tgu-map-layer__warning { + display: block; + margin-top: 0.24rem; + color: #ffe0a3; + font-size: 0.7rem; + line-height: 1.3; +} + +.tgu-map-spacecraft-list { + display: grid; + gap: 0.35rem; + max-height: 18rem; + overflow: auto; + padding-top: 0.55rem; +} + +.tgu-map-spacecraft { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + gap: 0.55rem; + align-items: center; + border: 1px solid var(--line-soft); + border-radius: 6px; + padding: 0.5rem 0.55rem; + background: #111416; + color: var(--text-muted); + cursor: pointer; + transition: opacity 0.15s; +} + +.tgu-map-spacecraft input { + accent-color: var(--accent); + flex-shrink: 0; +} + +.tgu-map-spacecraft.is-hidden { + opacity: 0.4; +} + +.tgu-map-spacecraft__name, +.tgu-map-spacecraft__meta { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tgu-map-spacecraft__name { + font-size: 0.8rem; + font-weight: 650; +} + +.tgu-map-spacecraft__meta { + margin-top: 0.12rem; + color: var(--text-dim); + font-size: 0.68rem; +} + +.tgu-map-status-legend { + display: grid; + gap: 0.32rem; + color: var(--text-muted); + font-size: 0.72rem; +} + +.tgu-map-status-legend span { + display: inline-flex; + align-items: center; + gap: 0.4rem; +} + +.tgu-map-status-legend i { + display: inline-block; + width: 1rem; + height: 0.42rem; + border-radius: 999px; +} + +.tgu-map-stage { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: #0b0d0e; +} + +.tgu-2d-map { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + min-height: 28rem; + cursor: grab; + overflow: hidden; + user-select: none; + touch-action: none; +} + +.tgu-2d-map:active { + cursor: grabbing; +} + +.tgu-2d-map__canvas, +.tgu-2d-map__overlay { + position: absolute; + inset: 0; +} + +.tgu-2d-map__canvas { + width: 100%; + height: 100%; +} + +.tgu-2d-map__overlay { + overflow: visible; + pointer-events: none; +} + +.tgu-2d-map__overlay polygon { + fill: rgba(104, 195, 189, 0.16); + stroke: rgba(130, 222, 216, 0.9); + stroke-width: 1.6; +} + +.tgu-2d-map__overlay polygon.is-selected { + fill: rgba(240, 173, 46, 0.2); + stroke: #f0ad2e; + stroke-width: 2; +} + +.tgu-2d-map__overlay polyline.is-selected { + stroke: #f0ad2e; + stroke-width: 7; + stroke-linecap: round; + stroke-linejoin: round; + opacity: 0.85; +} + +.tgu-2d-map__overlay circle { + fill: rgba(104, 195, 189, 0.12); + stroke: rgba(130, 222, 216, 0.9); + stroke-width: 2; +} + +.tgu-2d-map__overlay circle.is-selected { + fill: rgba(240, 173, 46, 0.15); + stroke: #f0ad2e; + stroke-width: 2.5; +} + +.tgu-2d-map__tooltip { + position: absolute; + z-index: 8; + max-width: 15rem; + border: 1px solid var(--line); + border-radius: 6px; + padding: 0.45rem 0.55rem; + background: rgba(16, 18, 20, 0.94); + box-shadow: var(--shadow); + color: var(--text); + pointer-events: none; +} + +.tgu-2d-map__tooltip div { + font-size: 0.78rem; + font-weight: 700; +} + +.tgu-2d-map__tooltip span { + display: block; + margin-top: 0.14rem; + color: var(--text-dim); + font-family: "SFMono-Regular", Consolas, monospace; + font-size: 0.64rem; + text-transform: uppercase; +} + +.tgu-2d-map__attribution { + position: absolute; + left: 0.55rem; + bottom: 0.5rem; + border-radius: 4px; + padding: 0.18rem 0.4rem; + background: rgba(8, 16, 15, 0.68); + color: var(--text-dim); + font-family: "SFMono-Regular", Consolas, monospace; + font-size: 0.62rem; + pointer-events: none; +} + +.tgu-2d-map__coords { + position: absolute; + left: 50%; + bottom: 0.5rem; + transform: translateX(-50%); + border-radius: 4px; + padding: 0.2rem 0.55rem; + background: rgba(8, 16, 15, 0.72); + color: var(--text); + font-family: "SFMono-Regular", Consolas, monospace; + font-size: 0.66rem; + letter-spacing: 0.02em; + white-space: nowrap; + pointer-events: none; +} + +.tgu-2d-map__zoom { + position: absolute; + right: 0.75rem; + bottom: 0.75rem; + display: grid; + gap: 0.25rem; +} + +.tgu-2d-map__zoom button { + display: grid; + place-items: center; + width: 1.9rem; + height: 1.9rem; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--bg-panel); + color: var(--text); + font-weight: 700; +} + +.tgu-map-overlay { + position: absolute; + z-index: 5; + top: 0.85rem; + left: 0.85rem; + max-width: 23rem; + border: 1px solid rgba(240, 173, 46, 0.42); + border-radius: 7px; + padding: 0.65rem 0.75rem; + background: rgba(17, 19, 21, 0.88); + color: #ffe7b5; + font-size: 0.82rem; + backdrop-filter: blur(6px); +} + +.tgu-map-errors { + display: grid; + gap: 0.4rem; + padding: 0.6rem 0.9rem 0; +} + +.tgu-map-errors:empty { + display: none; +} + +.tgu-map-details { + display: grid; + grid-template-columns: 1.4fr 1fr 0.9fr 1fr 0.8fr; + gap: 0.75rem; + padding: 0.75rem 0.9rem; + border-top: 1px solid var(--line); + background: var(--bg-panel); +} + +.tgu-map-details__label { + margin-bottom: 0.18rem; + color: var(--text-dim); + font-size: 0.66rem; + font-weight: 700; + text-transform: uppercase; +} + +.tgu-map-details__value { + overflow-wrap: anywhere; + color: var(--text); + font-size: 0.82rem; +} + +.tgu-map-status-pill { + display: inline-flex; + align-items: center; + gap: 0.35rem; +} + +.tgu-map-status-pill i { + width: 0.95rem; + height: 0.42rem; + border-radius: 999px; +} + +@media (max-width: 1100px) { + .tgu-workspace { + grid-template-columns: 16rem minmax(0, 1fr); + } + + .tgu-details { + grid-column: 1 / -1; + max-height: 18rem; + border-left: 0; + border-top: 1px solid var(--line); + } + + .tgu-map-content { + grid-template-columns: 1fr; + } + + .tgu-map-panel { + max-height: 18rem; + border-right: 0; + border-bottom: 1px solid var(--line); + } + + .tgu-map-details { + grid-template-columns: 1fr 1fr; + } +} + +/* ── Requests filter panel ─────────────────────────────── */ + +.tgu-button--sm { + min-height: 1.75rem; + padding: 0.28rem 0.55rem; + font-size: 0.78rem; +} + +.tgu-alert--compact { + padding: 0.42rem 0.58rem; + font-size: 0.76rem; +} + +.tgu-map-requests-filter { + display: grid; + gap: 0.55rem; +} + +.tgu-map-requests-filter .tgu-field { + display: grid; + grid-template-columns: 2rem minmax(0, 1fr); + align-items: center; +} + +.tgu-map-requests-filter .tgu-field input { + width: 100%; +} + +.tgu-map-requests-filter__actions { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; +} + +.tgu-map-requests-filter__status { + color: var(--text-dim); + font-size: 0.75rem; + line-height: 1.4; +} + +.tgu-map-requests-filter__truncated { + color: #ffe0a3; +} + +.tgu-map-requests-legend { + display: flex; + flex-wrap: wrap; + gap: 0.45rem 0.8rem; + padding-top: 0.2rem; + color: var(--text-dim); + font-size: 0.74rem; +} + +.tgu-map-requests-legend span { + display: inline-flex; + align-items: center; + gap: 0.38rem; +} + +.tgu-map-requests-legend i { + display: inline-block; + width: 1.1rem; + height: 0.42rem; + border-radius: 3px; + border: 1.5px dashed; +} + +.req-optics { + background: rgba(255, 160, 64, 0.25); + border-color: rgba(255, 160, 64, 0.85); +} + +.req-rsa { + background: rgba(64, 175, 255, 0.25); + border-color: rgba(64, 175, 255, 0.85); +} + +.req-combined { + background: rgba(192, 128, 255, 0.25); + border-color: rgba(192, 128, 255, 0.85); +} + +/* ── Requests-at-point panel ───────────────────────────── */ + +.tgu-map-content.has-requests-panel { + grid-template-columns: 19rem minmax(0, 1fr) 20rem; +} + +.tgu-map-requests-panel { + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; + border-left: 1px solid var(--line); + background: var(--bg-panel); +} + +.tgu-map-requests-list { + display: flex; + flex: 1; + flex-direction: column; + gap: 1px; + overflow-y: auto; + background: var(--line-soft); +} + +.tgu-map-request-row { + display: flex; + gap: 0.65rem; + align-items: flex-start; + width: 100%; + padding: 0.6rem 0.9rem; + border: none; + background: var(--bg-panel); + color: var(--text); + cursor: pointer; + text-align: left; +} + +.tgu-map-request-row:hover { + background: var(--bg-hover); +} + +.tgu-map-request-row.is-selected { + background: rgba(240, 173, 46, 0.1); + outline: 1px solid rgba(240, 173, 46, 0.4); + outline-offset: -1px; +} + +.tgu-map-request-row__type { + flex-shrink: 0; + align-self: center; + width: 1rem; + height: 0.4rem; + border-radius: 3px; + border: 1.5px dashed; +} + +.tgu-map-request-row__body { + display: flex; + flex-direction: column; + gap: 0.15rem; + min-width: 0; +} + +.tgu-map-request-row__name { + overflow: hidden; + color: var(--text); + font-size: 0.82rem; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tgu-map-request-row__meta { + color: var(--text-dim); + font-size: 0.72rem; + line-height: 1.4; +} + +.tgu-map-request-details { + display: flex; + flex-direction: column; + gap: 0.5rem; + overflow-y: auto; + border-top: 1px solid var(--line); + padding: 0.75rem 0.9rem; +} + +.tgu-map-request-details .tgu-map-panel__label { + margin-bottom: 0.1rem; +} + +.tgu-map-request-details__grid { + display: grid; + grid-template-columns: auto 1fr; + gap: 0.3rem 0.8rem; + margin: 0; +} + +.tgu-map-request-details__grid dt { + color: var(--text-dim); + font-size: 0.72rem; + font-weight: 600; + white-space: nowrap; + line-height: 1.5; +} + +.tgu-map-request-details__grid dd { + overflow-wrap: anywhere; + margin: 0; + color: var(--text); + font-size: 0.8rem; + line-height: 1.5; +} + +@media (max-width: 1100px) { + .tgu-map-content.has-requests-panel { + grid-template-columns: 1fr; + } + + .tgu-map-requests-panel { + max-height: 18rem; + border-left: 0; + border-top: 1px solid var(--line); + } +} + +/* ── Вкладка «Заявки» ───────────────────────────────────── */ + +.tgu-map-content.has-side-panel { + grid-template-columns: 19rem minmax(0, 1fr) 24rem; +} + +.tgu-map-overlay--hint { + border-color: rgba(104, 195, 189, 0.5); + color: #bdeee9; +} + +/* Черновой полигон при рисовании заявки */ +.tgu-2d-map__overlay .tgu-2d-map__draft-fill { + fill: rgba(240, 173, 46, 0.16); + stroke: #f0ad2e; + stroke-width: 2; + stroke-dasharray: 6 4; +} + +.tgu-2d-map__overlay .tgu-2d-map__draft-line { + fill: none; + stroke: #f0ad2e; + stroke-width: 2; + stroke-dasharray: 6 4; +} + +.tgu-2d-map__overlay .tgu-2d-map__draft-vertex { + fill: #f0ad2e; + stroke: #1a1c1e; + stroke-width: 1.5; +} + +/* Панель-форма создания заявки */ +.tgu-requests-form__body { + display: flex; + flex: 1; + flex-direction: column; + gap: 0.2rem; + overflow-y: auto; +} + +.tgu-requests-form__section { + display: grid; + gap: 0.5rem; + padding: 0.75rem 0.9rem; + border-bottom: 1px solid var(--line-soft); +} + +.tgu-requests-field { + display: grid; + grid-template-columns: 9rem minmax(0, 1fr); + gap: 0.5rem; + align-items: center; + font-size: 0.8rem; +} + +.tgu-requests-field > span { + color: var(--text-dim); + font-size: 0.74rem; +} + +.tgu-requests-field input, +.tgu-requests-field select { + width: 100%; + min-height: 1.9rem; + border: 1px solid var(--line); + border-radius: 6px; + padding: 0.25rem 0.45rem; + background: #101214; + color: var(--text); + font-size: 0.8rem; +} + +.tgu-requests-field input:focus, +.tgu-requests-field select:focus { + outline: none; + border-color: rgba(104, 195, 189, 0.6); +} + +.tgu-requests-checkbox { + display: flex; + gap: 0.5rem; + align-items: center; + color: var(--text); + font-size: 0.8rem; +} + +.tgu-requests-checkbox input { + accent-color: var(--accent); +} + +.tgu-requests-form__block-toggle { + font-weight: 650; +} + +.tgu-requests-form__block { + display: grid; + gap: 0.5rem; + padding: 0.55rem 0.6rem; + border: 1px solid var(--line-soft); + border-radius: 7px; + background: #111416; +} + +.tgu-requests-form__actions { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; +} + +.tgu-requests-confirm { + display: flex; + flex-direction: column; + gap: 0.45rem; + padding: 0.6rem; + border: 1px solid rgba(229, 115, 115, 0.45); + border-radius: 6px; + background: rgba(229, 115, 115, 0.08); +} + +.tgu-requests-confirm__title { + font-weight: 600; + color: var(--text); +} + +.tgu-requests-confirm__subtitle { + color: var(--text-dim); + font-size: 0.8rem; + word-break: break-word; +} + +.tgu-requests-form__hint { + color: var(--text-dim); + font-size: 0.75rem; + line-height: 1.4; +} + +.tgu-requests-form__geo-status { + color: var(--text); + font-size: 0.78rem; + font-weight: 650; +} + +.tgu-requests-form__footer { + padding: 0.75rem 0.9rem; + border-top: 1px solid var(--line); +} + +.tgu-requests-form__footer .tgu-button { + width: 100%; +} + +@media (max-width: 1100px) { + .tgu-map-content.has-side-panel { + grid-template-columns: 1fr; + } +} + +/* «Создать план»: кнопка над таймлайном + модальный диалог новой цепочки. */ +.tgu-center__actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 1rem; + padding: 0.5rem 0.75rem 0; +} + +.tgu-center__toggle { + display: inline-flex; + align-items: center; + gap: 0.4rem; + font-size: 0.82rem; + color: #c4ced0; + cursor: pointer; + user-select: none; +} + +.tgu-center__toggle input { + cursor: pointer; +} + +.tgu-chain-dialog__backdrop { + position: fixed; + inset: 0; + z-index: 50; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.45); + backdrop-filter: blur(2px); +} + +.tgu-chain-dialog { + width: min(34rem, calc(100vw - 2rem)); + max-height: calc(100vh - 3rem); + overflow: auto; + display: flex; + flex-direction: column; + gap: 0.8rem; + padding: 1rem 1.1rem 1.1rem; + border: 1px solid var(--line); + border-radius: 10px; + background: var(--bg-panel); + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.4); +} + +.tgu-chain-dialog__header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.tgu-chain-dialog__header h2 { + margin: 0; + font-size: 1.05rem; +} + +.tgu-chain-dialog__field { + display: flex; + flex-direction: column; + gap: 0.3rem; + font-size: 0.82rem; +} + +.tgu-chain-dialog__field > span { + color: var(--text-muted, #9fb0b3); +} + +.tgu-chain-dialog__actions { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + margin-top: 0.3rem; +} + +.tgu-chain-dialog__chain { + margin: 0; + padding-left: 1.1rem; + font-size: 0.8rem; + line-height: 1.5; + max-height: 12rem; + overflow: auto; +} + +.tgu-chain-dialog__hint { + font-size: 0.78rem; + color: var(--text-muted, #9fb0b3); +} + +/* ЗРВ выбранного КА на таймлайне — отдельный страйп снизу строки, не перекрывает планы. */ +.tgu-timeline__zrv { + fill: rgba(90, 170, 200, 0.22); + stroke: rgba(90, 170, 200, 0.6); + stroke-width: 1; + rx: 2; + pointer-events: all; +} + +.tgu-timeline__zrv.is-selected { + fill: rgba(240, 173, 46, 0.35); + stroke: #f0ad2e; + stroke-width: 1.6; +} + +/* Подпись страйпа ЗРВ слева — как имя КА над цепочкой планов. */ +.tgu-timeline__zrv-label { + fill: rgba(90, 170, 200, 0.85); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.04em; +} diff --git a/services/pcp-tgu-ui-service/src/styles/theme.css b/services/pcp-tgu-ui-service/src/styles/theme.css new file mode 100644 index 0000000..6b08e61 --- /dev/null +++ b/services/pcp-tgu-ui-service/src/styles/theme.css @@ -0,0 +1,57 @@ +:root { + font-family: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + color: #e7ecec; + background: #111315; + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + + --bg-base: #111315; + --bg-panel: #171a1c; + --bg-panel-2: #1f2325; + --bg-elevated: #262b2e; + --line: #30373a; + --line-soft: #252b2d; + --text: #e7ecec; + --text-muted: #a4b0b1; + --text-dim: #748183; + --accent: #68c3bd; + --accent-strong: #82ded8; + --danger: #d64545; + --warning: #f0ad2e; + --success: #2f9e44; + --shadow: 0 16px 42px rgba(0, 0, 0, 0.28); +} + +* { + box-sizing: border-box; +} + +html, +body, +#root { + min-width: 0; + min-height: 0; + width: 100%; + height: 100%; + margin: 0; +} + +button, +input { + font: inherit; +} + +button { + cursor: pointer; +} + +button:disabled { + cursor: not-allowed; + opacity: 0.62; +} + +.mono { + font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; +} diff --git a/services/pcp-tgu-ui-service/src/utils/dataVolume.ts b/services/pcp-tgu-ui-service/src/utils/dataVolume.ts new file mode 100644 index 0000000..dbd03be --- /dev/null +++ b/services/pcp-tgu-ui-service/src/utils/dataVolume.ts @@ -0,0 +1,16 @@ +/** + * Форматирование объёма данных режима (съёмка/сброс). Бэк отдаёт объём в мегабайтах + * (см. ModeDataRates в pcp-types-lib: накопление съёмки 120 Мбайт/с, сброс — канал 1200 Мбит/с ÷ 8). + */ + +/** До 1000 — в МБ, дальше — в ГБ (1 ГБ = 1000 МБ). Тут МБ = мегабайт. «—», если значения нет. */ +export function formatVolume(mb?: number | null): string { + if (mb == null || !Number.isFinite(mb) || mb <= 0) { + return "—"; + } + if (mb >= 1000) { + const gb = mb / 1000; + return `${gb >= 100 ? Math.round(gb) : gb.toFixed(1)} ГБ`; + } + return `${Math.round(mb)} МБ`; +} diff --git a/services/pcp-tgu-ui-service/src/utils/localTime.ts b/services/pcp-tgu-ui-service/src/utils/localTime.ts new file mode 100644 index 0000000..35e5364 --- /dev/null +++ b/services/pcp-tgu-ui-service/src/utils/localTime.ts @@ -0,0 +1,57 @@ +/** + * Единый разбор и форматирование времени в локальной зоне. + * + * Бэкенд (Kotlin `LocalDateTime`) отдаёт время как ISO-строку без зоны + * (например `"2026-05-29T01:00:00"`) — это настенные часы локальной зоны, в которой + * работают все сервисы, без UTC-семантики. Браузер по умолчанию трактует такую naive-строку + * как локальное время — это нам и нужно: разбор, показ и ввод идут в зоне браузера, а + * сериализация назад отдаёт те же настенные часы naive-строкой без зоны. + * + * Строки с явной зоной (`...Z`/офсет, напр. валидность платформ из НСИ) парсятся как + * абсолютный момент и показываются в локальной зоне. + */ + +function pad(n: number): string { + return String(n).padStart(2, "0"); +} + +/** Разбирает ISO-строку в эпоху-мс: naive — как локальное время, зонированную — как её момент. */ +export function parseLocal(value: string): number { + return Date.parse(value); +} + +/** Дата-время в локальной зоне, формат ru-RU. */ +export function formatLocalDateTime(ms: number): string { + return new Date(ms).toLocaleString("ru-RU", { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit" + }); +} + +/** Дата (дд.мм) в локальной зоне. */ +export function formatLocalDate(ms: number): string { + return new Date(ms).toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit" }); +} + +/** Время (чч:мм) в локальной зоне. */ +export function formatLocalTime(ms: number): string { + return new Date(ms).toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" }); +} + +/** Значение для пикеров (`YYYY-MM-DDTHH:mm`) из локальных компонент. */ +export function toLocalInputValue(ms: number): string { + const d = new Date(ms); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; +} + +/** + * naive `LocalDateTime` без зоны (`YYYY-MM-DDTHH:mm:ss`) из локальных компонент эпохи-мс. + * Все бэкенды ожидают `LocalDateTime` без зоны — настенные часы локальной зоны. + */ +export function toLocalIso(ms: number): string { + const d = new Date(ms); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; +} diff --git a/services/pcp-tgu-ui-service/src/utils/uuid.test.ts b/services/pcp-tgu-ui-service/src/utils/uuid.test.ts new file mode 100644 index 0000000..a4d4f4c --- /dev/null +++ b/services/pcp-tgu-ui-service/src/utils/uuid.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { generateUuid, uuidV4Fallback } from "./uuid"; + +const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +describe("generateUuid", () => { + it("возвращает валидный UUID v4", () => { + expect(generateUuid()).toMatch(UUID_V4); + }); +}); + +describe("uuidV4Fallback (insecure context)", () => { + it("даёт валидный UUID v4 без crypto.randomUUID", () => { + expect(uuidV4Fallback()).toMatch(UUID_V4); + }); + + it("генерирует разные значения", () => { + expect(uuidV4Fallback()).not.toBe(uuidV4Fallback()); + }); +}); diff --git a/services/pcp-tgu-ui-service/src/utils/uuid.ts b/services/pcp-tgu-ui-service/src/utils/uuid.ts new file mode 100644 index 0000000..0bd9dd9 --- /dev/null +++ b/services/pcp-tgu-ui-service/src/utils/uuid.ts @@ -0,0 +1,35 @@ +/** + * Генерация UUID v4. + * + * `crypto.randomUUID()` доступен только в secure context (https или localhost), + * поэтому при открытии фронта по сетевому адресу (http://:5174) он отсутствует + * и падает с «crypto.randomUUID is not a function». `crypto.getRandomValues`, наоборот, + * доступен и в insecure context — используем его как фолбэк (RFC 4122 v4). + */ +export function generateUuid(): string { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + return uuidV4Fallback(); +} + +/** RFC 4122 v4 на основе crypto.getRandomValues (работает в insecure context). */ +export function uuidV4Fallback(): string { + const bytes = new Uint8Array(16); + crypto.getRandomValues(bytes); + bytes[6] = (bytes[6] & 0x0f) | 0x40; // версия 4 + bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 10xx + + const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")); + return ( + hex.slice(0, 4).join("") + + "-" + + hex.slice(4, 6).join("") + + "-" + + hex.slice(6, 8).join("") + + "-" + + hex.slice(8, 10).join("") + + "-" + + hex.slice(10, 16).join("") + ); +} diff --git a/services/pcp-tgu-ui-service/src/vite-env.d.ts b/services/pcp-tgu-ui-service/src/vite-env.d.ts new file mode 100644 index 0000000..a331c6e --- /dev/null +++ b/services/pcp-tgu-ui-service/src/vite-env.d.ts @@ -0,0 +1,13 @@ +/// + +interface ImportMetaEnv { + readonly VITE_API_URL?: string + readonly VITE_AUTH_MODE?: string + readonly VITE_NEW_START_ID_URL?: string + readonly VITE_NEW_START_ID_REALM?: string + readonly VITE_NEW_START_ID_CLIENT_ID?: string +} + +interface ImportMeta { + readonly env: ImportMetaEnv +} diff --git a/services/pcp-tgu-ui-service/tsconfig.app.json b/services/pcp-tgu-ui-service/tsconfig.app.json new file mode 100644 index 0000000..21c6762 --- /dev/null +++ b/services/pcp-tgu-ui-service/tsconfig.app.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "allowJs": true, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx" + }, + "include": ["src"], + "comment": "Страж utcTime.guard.test.ts использует node:fs/process и проверяется только в vitest (node-окружение), а не при типизации приложения — иначе потребовался бы @types/node.", + "exclude": ["src/**/*.guard.test.ts"] +} diff --git a/services/pcp-tgu-ui-service/tsconfig.json b/services/pcp-tgu-ui-service/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/services/pcp-tgu-ui-service/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/services/pcp-tgu-ui-service/tsconfig.node.json b/services/pcp-tgu-ui-service/tsconfig.node.json new file mode 100644 index 0000000..eec5932 --- /dev/null +++ b/services/pcp-tgu-ui-service/tsconfig.node.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "moduleResolution": "Bundler", + "types": ["node"], + "allowSyntheticDefaultImports": true, + "strict": true, + "noEmit": true, + "skipLibCheck": true + }, + "include": ["vite.config.ts"] +} diff --git a/services/pcp-tgu-ui-service/vite.config.ts b/services/pcp-tgu-ui-service/vite.config.ts new file mode 100644 index 0000000..f03e87c --- /dev/null +++ b/services/pcp-tgu-ui-service/vite.config.ts @@ -0,0 +1,66 @@ +import { fileURLToPath } from 'node:url' +import { nstartAuth } from '@nstart/auth/vite' +import react from '@vitejs/plugin-react' +// vitest/config расширяет vite defineConfig — нужно для блока test ниже. +import { defineConfig } from 'vitest/config' + +// Сборочная заглушка @nstart/ui: @nstart/auth реэкспортирует ProfilePage/UserPanel +// (с lottie ~6 МБ), которые в сервисе не используются. Алиас отдаёт их импорты в +// лёгкий стаб, чтобы их UI не попадал в бандл. Типы tsc берёт из реального пакета. +const nstartUiStub = fileURLToPath(new URL('./src/stubs/nstart-ui.tsx', import.meta.url)) + +export default defineConfig({ + // nstartAuth() обслуживает /silent-check-sso.html (нужен части режимов входа); + // при loginMode=redirect безвреден. @nstart/ui-плагины и tailwind НЕ подключаем — + // их UI в сервисе не используется. + plugins: [nstartAuth(), react()], + resolve: { + dedupe: ['react', 'react-dom'], + alias: [{ find: /^@nstart\/ui$/, replacement: nstartUiStub }], + }, + server: { + host: '0.0.0.0', + port: 5174, + // ТГУ ходит в несколько бэкендов через префиксы. В проде эти маршруты + // повторяются в nginx.conf (зеркало) либо на gateway/ingress. + proxy: { + '/api/pcp-request': { + target: 'http://localhost:7005', + changeOrigin: true, + rewrite: (path: string) => path.replace(/^\/api\/pcp-request/, ''), + }, + '/api/pcp-ballistics': { + target: 'http://localhost:7003', + changeOrigin: true, + rewrite: (path: string) => path.replace(/^\/api\/pcp-ballistics/, ''), + }, + '/api/pcp-mission': { + target: 'http://localhost:7010', + changeOrigin: true, + rewrite: (path: string) => path.replace(/^\/api\/pcp-mission/, ''), + }, + '/api/pcp-tgu': { + target: 'http://localhost:7011', + changeOrigin: true, + rewrite: (path: string) => path.replace(/^\/api\/pcp-tgu/, ''), + }, + '/api/pcp-complex': { + target: 'http://localhost:7002', + changeOrigin: true, + rewrite: (path: string) => path.replace(/^\/api\/pcp-complex/, ''), + }, + '/api/stations': { + target: 'http://localhost:7009', + changeOrigin: true, + }, + '/api': { + target: 'http://localhost:7008', + changeOrigin: true, + }, + }, + }, + test: { + environment: 'jsdom', + globals: true, + }, +})