From f292291e0482a1d111ca196b11476905af6698f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9=20=D0=A1=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D1=8C=D0=B5=D0=B2?= Date: Tue, 9 Jun 2026 13:35:21 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A3=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20=D1=81=D1=82=D0=B0=D1=80=D0=BE=D0=B3=D0=BE=20pcp-tgu-o?= =?UTF-8?q?ps-ui:=20=D0=B7=D0=B0=D0=BC=D0=B5=D0=BD=D1=91=D0=BD=20=D0=BD?= =?UTF-8?q?=D0=B0=20pcp-tgu-ui-service?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- services/pcp-tgu-ops-ui/.dockerignore | 4 - services/pcp-tgu-ops-ui/Dockerfile | 14 - services/pcp-tgu-ops-ui/e2e-verify/editor.mjs | 60 - .../pcp-tgu-ops-ui/e2e-verify/keep-alive.mjs | 79 - .../pcp-tgu-ops-ui/e2e-verify/requests.mjs | 225 -- services/pcp-tgu-ops-ui/index.html | 12 - services/pcp-tgu-ops-ui/nginx.conf | 29 - services/pcp-tgu-ops-ui/package-lock.json | 2592 ----------------- services/pcp-tgu-ops-ui/package.json | 28 - services/pcp-tgu-ops-ui/src/App.tsx | 5 - .../pcp-tgu-ops-ui/src/api/ballisticsApi.ts | 176 -- .../pcp-tgu-ops-ui/src/api/complexPlanApi.ts | 198 -- .../src/api/missionPlanningApi.ts | 339 --- services/pcp-tgu-ops-ui/src/api/requestApi.ts | 102 - .../pcp-tgu-ops-ui/src/api/stationsApi.ts | 18 - services/pcp-tgu-ops-ui/src/api/tguApi.ts | 154 - services/pcp-tgu-ops-ui/src/api/tguPlanApi.ts | 230 -- .../src/components/timeFields.test.ts | 27 - .../src/components/timeFields.tsx | 135 - .../constellation/ConstellationDashboard.jsx | 226 -- .../constellation/ConstellationTab.tsx | 75 - .../src/features/constellation/MapView.jsx | 360 --- .../src/features/constellation/Panels.jsx | 182 -- .../src/features/constellation/Timeline.jsx | 163 -- .../constellation/assets/land-110m.json | 1 - .../src/features/constellation/dashboard.css | 184 -- .../src/features/constellation/data.js | 73 - .../src/features/constellation/index.js | 10 - .../src/features/constellation/realData.ts | 424 --- .../src/features/constellation/sim.js | 183 -- .../src/features/constellation/util.js | 9 - .../tgu-complex-plan/TguComplexPlanTab.tsx | 331 --- .../src/features/tgu-editor/EditorPanels.tsx | 830 ------ .../src/features/tgu-editor/EditorRail.tsx | 89 - .../features/tgu-editor/EditorTimeline.tsx | 298 -- .../src/features/tgu-editor/EditorToolbar.tsx | 115 - .../features/tgu-editor/MapPassSelector.tsx | 127 - .../src/features/tgu-editor/TguEditorTab.tsx | 1734 ----------- .../features/tgu-editor/VisibilityTracks.tsx | 58 - .../src/features/tgu-editor/editorApi.ts | 40 - .../tgu-editor/editorConflicts.test.ts | 97 - .../features/tgu-editor/editorConflicts.ts | 103 - .../features/tgu-editor/editorDraft.test.ts | 171 -- .../src/features/tgu-editor/editorDraft.ts | 275 -- .../features/tgu-editor/editorPresentation.ts | 113 - .../tgu-editor/editorValidation.test.ts | 49 - .../features/tgu-editor/editorValidation.ts | 29 - .../features/tgu-editor/model/editorTypes.ts | 133 - .../features/tgu-map-2d/Tgu2DMapDetails.tsx | 48 - .../src/features/tgu-map-2d/Tgu2DMapLayers.ts | 92 - .../tgu-map-2d/Tgu2DMapRequestsPanel.tsx | 116 - .../features/tgu-map-2d/Tgu2DMapSidebar.tsx | 202 -- .../tgu-map-2d/Tgu2DMapStationPanel.tsx | 43 - .../src/features/tgu-map-2d/Tgu2DMapTab.tsx | 324 --- .../features/tgu-map-2d/Tgu2DMapToolbar.tsx | 44 - .../src/features/tgu-map-2d/Tgu2DMapView.tsx | 762 ----- .../features/tgu-map-2d/canvas/drawBaseMap.ts | 26 - .../tgu-map-2d/canvas/drawComplexPlan.ts | 61 - .../tgu-map-2d/canvas/drawPlanWorks.ts | 70 - .../tgu-map-2d/canvas/drawRequests.ts | 69 - .../tgu-map-2d/canvas/drawStations.ts | 21 - .../features/tgu-map-2d/canvas/drawSwath.ts | 58 - .../features/tgu-map-2d/canvas/drawTiles.ts | 64 - .../features/tgu-map-2d/canvas/drawTracks.ts | 39 - .../features/tgu-map-2d/canvas/tileLoader.ts | 45 - .../src/features/tgu-map-2d/geometry/bbox.ts | 38 - .../tgu-map-2d/geometry/mapProjection.ts | 67 - .../tgu-map-2d/geometry/passes.test.ts | 34 - .../features/tgu-map-2d/geometry/passes.ts | 225 -- .../tgu-map-2d/geometry/pointInPolygon.ts | 21 - .../tgu-map-2d/geometry/simplifyGeometry.ts | 22 - .../tgu-map-2d/geometry/spatialIndex.test.ts | 72 - .../tgu-map-2d/geometry/spatialIndex.ts | 98 - .../features/tgu-map-2d/geometry/wktParser.ts | 54 - .../tgu-map-2d/model/mapLayerTypes.ts | 45 - .../tgu-map-2d/model/mapSelectionTypes.ts | 40 - .../src/features/tgu-map-2d/model/mapTypes.ts | 72 - .../tgu-map-2d/tgu2DMapInterval.test.ts | 61 - .../features/tgu-map-2d/tgu2DMapInterval.ts | 29 - .../tgu-planning/CreateChainPlanDialog.tsx | 208 -- .../src/features/tgu-planning/TguLegend.tsx | 14 - .../features/tgu-planning/TguPlanDetails.tsx | 315 -- .../tgu-planning/TguPlanningLayout.tsx | 15 - .../features/tgu-planning/TguPlanningPage.tsx | 623 ---- .../src/features/tgu-planning/TguSidebar.tsx | 79 - .../src/features/tgu-planning/TguTimeline.tsx | 299 -- .../src/features/tgu-planning/TguToolbar.tsx | 122 - .../src/features/tgu-planning/TguZrvPanel.tsx | 74 - .../tgu-planning/createChainWindows.test.ts | 64 - .../tgu-planning/createChainWindows.ts | 65 - .../src/features/tgu-planning/tguStatus.ts | 67 - .../tgu-planning/tguTimelineLayout.test.ts | 100 - .../tgu-planning/tguTimelineLayout.ts | 142 - .../tgu-planning/tguTimelineMapper.test.ts | 126 - .../tgu-planning/tguTimelineMapper.ts | 248 -- .../tgu-requests/RequestCreatePanel.tsx | 357 --- .../tgu-requests/RequestDetailsPanel.tsx | 172 -- .../features/tgu-requests/TguRequestsTab.tsx | 411 --- .../tgu-requests/requestGeometry.test.ts | 27 - .../features/tgu-requests/requestGeometry.ts | 27 - services/pcp-tgu-ops-ui/src/main.tsx | 13 - .../pcp-tgu-ops-ui/src/model/requestTypes.ts | 106 - services/pcp-tgu-ops-ui/src/model/tguTypes.ts | 66 - .../pcp-tgu-ops-ui/src/model/timelineTypes.ts | 64 - .../src/styles/editor-tokens.css | 20 - .../pcp-tgu-ops-ui/src/styles/tgu-editor.css | 1223 -------- .../src/styles/tgu-planning.css | 1599 ---------- services/pcp-tgu-ops-ui/src/styles/theme.css | 57 - .../pcp-tgu-ops-ui/src/utils/dataVolume.ts | 16 - .../pcp-tgu-ops-ui/src/utils/localTime.ts | 57 - .../pcp-tgu-ops-ui/src/utils/uuid.test.ts | 20 - services/pcp-tgu-ops-ui/src/utils/uuid.ts | 35 - services/pcp-tgu-ops-ui/tsconfig.app.json | 23 - services/pcp-tgu-ops-ui/tsconfig.json | 7 - services/pcp-tgu-ops-ui/tsconfig.node.json | 14 - services/pcp-tgu-ops-ui/vite.config.ts | 49 - 116 files changed, 20795 deletions(-) delete mode 100644 services/pcp-tgu-ops-ui/.dockerignore delete mode 100644 services/pcp-tgu-ops-ui/Dockerfile delete mode 100644 services/pcp-tgu-ops-ui/e2e-verify/editor.mjs delete mode 100644 services/pcp-tgu-ops-ui/e2e-verify/keep-alive.mjs delete mode 100644 services/pcp-tgu-ops-ui/e2e-verify/requests.mjs delete mode 100644 services/pcp-tgu-ops-ui/index.html delete mode 100644 services/pcp-tgu-ops-ui/nginx.conf delete mode 100644 services/pcp-tgu-ops-ui/package-lock.json delete mode 100644 services/pcp-tgu-ops-ui/package.json delete mode 100644 services/pcp-tgu-ops-ui/src/App.tsx delete mode 100644 services/pcp-tgu-ops-ui/src/api/ballisticsApi.ts delete mode 100644 services/pcp-tgu-ops-ui/src/api/complexPlanApi.ts delete mode 100644 services/pcp-tgu-ops-ui/src/api/missionPlanningApi.ts delete mode 100644 services/pcp-tgu-ops-ui/src/api/requestApi.ts delete mode 100644 services/pcp-tgu-ops-ui/src/api/stationsApi.ts delete mode 100644 services/pcp-tgu-ops-ui/src/api/tguApi.ts delete mode 100644 services/pcp-tgu-ops-ui/src/api/tguPlanApi.ts delete mode 100644 services/pcp-tgu-ops-ui/src/components/timeFields.test.ts delete mode 100644 services/pcp-tgu-ops-ui/src/components/timeFields.tsx delete mode 100644 services/pcp-tgu-ops-ui/src/features/constellation/ConstellationDashboard.jsx delete mode 100644 services/pcp-tgu-ops-ui/src/features/constellation/ConstellationTab.tsx delete mode 100644 services/pcp-tgu-ops-ui/src/features/constellation/MapView.jsx delete mode 100644 services/pcp-tgu-ops-ui/src/features/constellation/Panels.jsx delete mode 100644 services/pcp-tgu-ops-ui/src/features/constellation/Timeline.jsx delete mode 100644 services/pcp-tgu-ops-ui/src/features/constellation/assets/land-110m.json delete mode 100644 services/pcp-tgu-ops-ui/src/features/constellation/dashboard.css delete mode 100644 services/pcp-tgu-ops-ui/src/features/constellation/data.js delete mode 100644 services/pcp-tgu-ops-ui/src/features/constellation/index.js delete mode 100644 services/pcp-tgu-ops-ui/src/features/constellation/realData.ts delete mode 100644 services/pcp-tgu-ops-ui/src/features/constellation/sim.js delete mode 100644 services/pcp-tgu-ops-ui/src/features/constellation/util.js delete mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-complex-plan/TguComplexPlanTab.tsx delete mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorPanels.tsx delete mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorRail.tsx delete mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorTimeline.tsx delete mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorToolbar.tsx delete mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-editor/MapPassSelector.tsx delete mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx delete mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-editor/VisibilityTracks.tsx delete mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-editor/editorApi.ts delete mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-editor/editorConflicts.test.ts delete mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-editor/editorConflicts.ts delete mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-editor/editorDraft.test.ts delete mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-editor/editorDraft.ts delete mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-editor/editorPresentation.ts delete mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-editor/editorValidation.test.ts delete mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-editor/editorValidation.ts delete mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-editor/model/editorTypes.ts delete mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapDetails.tsx delete mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapLayers.ts delete mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapRequestsPanel.tsx delete mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapSidebar.tsx delete mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapStationPanel.tsx delete mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapTab.tsx delete mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapToolbar.tsx delete mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapView.tsx delete mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-map-2d/canvas/drawBaseMap.ts delete mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-map-2d/canvas/drawComplexPlan.ts delete mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-map-2d/canvas/drawPlanWorks.ts delete mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-map-2d/canvas/drawRequests.ts delete mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-map-2d/canvas/drawStations.ts delete mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-map-2d/canvas/drawSwath.ts delete mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-map-2d/canvas/drawTiles.ts delete mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-map-2d/canvas/drawTracks.ts delete mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-map-2d/canvas/tileLoader.ts delete mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-map-2d/geometry/bbox.ts delete mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-map-2d/geometry/mapProjection.ts delete mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-map-2d/geometry/passes.test.ts delete mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-map-2d/geometry/passes.ts delete mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-map-2d/geometry/pointInPolygon.ts delete mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-map-2d/geometry/simplifyGeometry.ts delete mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-map-2d/geometry/spatialIndex.test.ts delete mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-map-2d/geometry/spatialIndex.ts delete mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-map-2d/geometry/wktParser.ts delete mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-map-2d/model/mapLayerTypes.ts delete mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-map-2d/model/mapSelectionTypes.ts delete mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-map-2d/model/mapTypes.ts delete mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-map-2d/tgu2DMapInterval.test.ts delete mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-map-2d/tgu2DMapInterval.ts delete mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-planning/CreateChainPlanDialog.tsx delete mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-planning/TguLegend.tsx delete mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanDetails.tsx delete mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningLayout.tsx delete mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningPage.tsx delete mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-planning/TguSidebar.tsx delete mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-planning/TguTimeline.tsx delete mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-planning/TguToolbar.tsx delete mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-planning/TguZrvPanel.tsx delete mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-planning/createChainWindows.test.ts delete mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-planning/createChainWindows.ts delete mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-planning/tguStatus.ts delete mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-planning/tguTimelineLayout.test.ts delete mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-planning/tguTimelineLayout.ts delete mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-planning/tguTimelineMapper.test.ts delete mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-planning/tguTimelineMapper.ts delete mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-requests/RequestCreatePanel.tsx delete mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-requests/RequestDetailsPanel.tsx delete mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-requests/TguRequestsTab.tsx delete mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-requests/requestGeometry.test.ts delete mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-requests/requestGeometry.ts delete mode 100644 services/pcp-tgu-ops-ui/src/main.tsx delete mode 100644 services/pcp-tgu-ops-ui/src/model/requestTypes.ts delete mode 100644 services/pcp-tgu-ops-ui/src/model/tguTypes.ts delete mode 100644 services/pcp-tgu-ops-ui/src/model/timelineTypes.ts delete mode 100644 services/pcp-tgu-ops-ui/src/styles/editor-tokens.css delete mode 100644 services/pcp-tgu-ops-ui/src/styles/tgu-editor.css delete mode 100644 services/pcp-tgu-ops-ui/src/styles/tgu-planning.css delete mode 100644 services/pcp-tgu-ops-ui/src/styles/theme.css delete mode 100644 services/pcp-tgu-ops-ui/src/utils/dataVolume.ts delete mode 100644 services/pcp-tgu-ops-ui/src/utils/localTime.ts delete mode 100644 services/pcp-tgu-ops-ui/src/utils/uuid.test.ts delete mode 100644 services/pcp-tgu-ops-ui/src/utils/uuid.ts delete mode 100644 services/pcp-tgu-ops-ui/tsconfig.app.json delete mode 100644 services/pcp-tgu-ops-ui/tsconfig.json delete mode 100644 services/pcp-tgu-ops-ui/tsconfig.node.json delete mode 100644 services/pcp-tgu-ops-ui/vite.config.ts diff --git a/services/pcp-tgu-ops-ui/.dockerignore b/services/pcp-tgu-ops-ui/.dockerignore deleted file mode 100644 index fc8b165..0000000 --- a/services/pcp-tgu-ops-ui/.dockerignore +++ /dev/null @@ -1,4 +0,0 @@ -node_modules -dist -.vite -npm-debug.log* diff --git a/services/pcp-tgu-ops-ui/Dockerfile b/services/pcp-tgu-ops-ui/Dockerfile deleted file mode 100644 index 7b7cac9..0000000 --- a/services/pcp-tgu-ops-ui/Dockerfile +++ /dev/null @@ -1,14 +0,0 @@ -FROM node:22-alpine AS build -WORKDIR /app - -COPY package*.json ./ -RUN npm install - -COPY . . -RUN npm run build - -FROM nginx:1.27-alpine AS runtime -COPY nginx.conf /etc/nginx/conf.d/default.conf -COPY --from=build /app/dist /usr/share/nginx/html - -EXPOSE 80 diff --git a/services/pcp-tgu-ops-ui/e2e-verify/editor.mjs b/services/pcp-tgu-ops-ui/e2e-verify/editor.mjs deleted file mode 100644 index e5a1271..0000000 --- a/services/pcp-tgu-ops-ui/e2e-verify/editor.mjs +++ /dev/null @@ -1,60 +0,0 @@ -import { chromium } from "playwright"; - -const BASE = "http://localhost:5174/"; -const results = []; -const pass = (n, d = "") => { results.push(["PASS", n, d]); console.log("PASS ::", n, d); }; -const fail = (n, d = "") => { results.push(["FAIL", n, d]); console.log("FAIL ::", n, d); }; - -const b = await chromium.launch({ headless: true }); -const p = await (await b.newContext({ viewport: { width: 1600, height: 900 } })).newPage(); -const consoleErrors = []; -p.on("console", (m) => { if (m.type() === "error") consoleErrors.push(m.text()); }); -p.on("pageerror", (e) => consoleErrors.push("PAGEERROR: " + e.message)); - -try { - await p.goto(BASE, { waitUntil: "networkidle", timeout: 30000 }); - await p.waitForTimeout(800); - - // выбрать КА 56756 и открыть редактор - await p.getByText("NORAD 56756", { exact: false }).first().click(); - await p.waitForTimeout(500); - await p.getByRole("tab", { name: "Редактор", exact: true }).click(); - await p.waitForTimeout(700); - - // контролы редактора - const shoot = p.getByRole("button", { name: "+ Съёмка", exact: true }); - const drop = p.getByRole("button", { name: "+ Сброс", exact: true }); - (await shoot.count()) > 0 && (await drop.count()) > 0 - ? pass("editor:controls", "+ Съёмка / + Сброс присутствуют") - : fail("editor:controls"); - - // режим «Сброс» -> ЗРВ реальных станций - await drop.click(); - const rva = await p.waitForResponse((r) => /\/api\/satellites\/\d+\/rva/.test(r.url()), { timeout: 15000 }).catch(() => null); - await p.waitForTimeout(1000); - rva && rva.status() === 200 - ? pass("editor:drop-fetches-rva", `GET rva ${rva.status()}`) - : fail("editor:drop-fetches-rva", rva ? `status ${rva.status()}` : "не вызван"); - - const panelText = (await p.locator("aside, [class*='panel']").allInnerTexts().catch(() => [])).join(" ").replace(/\n+/g, " "); - const stationNames = ["Шпицберген", "Отрадное", "Байконур", "Ханты-Мансийск"]; - const found = stationNames.filter((s) => panelText.includes(s)); - found.length >= 2 - ? pass("editor:drop-real-stations", `зоны по станциям: ${found.join(", ")}`) - : fail("editor:drop-real-stations", `найдено станций: ${JSON.stringify(found)}`); - // зоны содержат виток + время (UTC-окно) - /виток\s*\d+/i.test(panelText) && /\d{2}:\d{2}[–-]\d{2}:\d{2}/.test(panelText) - ? pass("editor:drop-zone-format", "виток + временное окно показаны") - : fail("editor:drop-zone-format", panelText.slice(0, 200)); - - consoleErrors.length === 0 ? pass("no-console-errors") : fail("no-console-errors", JSON.stringify(consoleErrors.slice(0, 8))); -} catch (e) { - fail("SCRIPT", e.message); - console.log(e.stack); -} finally { - const npass = results.filter((r) => r[0] === "PASS").length; - const nfail = results.filter((r) => r[0] === "FAIL").length; - console.log(`\n=== EDITOR SUMMARY: ${npass} PASS / ${nfail} FAIL ===`); - await b.close(); - process.exit(nfail > 0 ? 1 : 0); -} diff --git a/services/pcp-tgu-ops-ui/e2e-verify/keep-alive.mjs b/services/pcp-tgu-ops-ui/e2e-verify/keep-alive.mjs deleted file mode 100644 index 49fd206..0000000 --- a/services/pcp-tgu-ops-ui/e2e-verify/keep-alive.mjs +++ /dev/null @@ -1,79 +0,0 @@ -// Проверка 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-ops-ui/e2e-verify/requests.mjs b/services/pcp-tgu-ops-ui/e2e-verify/requests.mjs deleted file mode 100644 index 2652dd8..0000000 --- a/services/pcp-tgu-ops-ui/e2e-verify/requests.mjs +++ /dev/null @@ -1,225 +0,0 @@ -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-ops-ui/index.html b/services/pcp-tgu-ops-ui/index.html deleted file mode 100644 index 223f74a..0000000 --- a/services/pcp-tgu-ops-ui/index.html +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - Планирование ТГУ Ops UI - - -
- - - diff --git a/services/pcp-tgu-ops-ui/nginx.conf b/services/pcp-tgu-ops-ui/nginx.conf deleted file mode 100644 index 10cc36e..0000000 --- a/services/pcp-tgu-ops-ui/nginx.conf +++ /dev/null @@ -1,29 +0,0 @@ -server { - listen 80; - server_name _; - - root /usr/share/nginx/html; - index index.html; - - location /api/pcp-request/ { - proxy_pass http://host.docker.internal:7005/; - proxy_http_version 1.1; - 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/ { - proxy_pass http://host.docker.internal:7008/api/; - proxy_http_version 1.1; - 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 / { - try_files $uri $uri/ /index.html; - } -} diff --git a/services/pcp-tgu-ops-ui/package-lock.json b/services/pcp-tgu-ops-ui/package-lock.json deleted file mode 100644 index 01facb3..0000000 --- a/services/pcp-tgu-ops-ui/package-lock.json +++ /dev/null @@ -1,2592 +0,0 @@ -{ - "name": "pcp-tgu-ops-ui", - "version": "0.1.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "pcp-tgu-ops-ui", - "version": "0.1.0", - "dependencies": { - "@vitejs/plugin-react": "^5.1.1", - "d3-geo": "^3.1.1", - "react": "^19.2.0", - "react-dom": "^19.2.0", - "topojson-client": "^3.1.0", - "typescript": "^5.9.3", - "vite": "^7.2.4" - }, - "devDependencies": { - "@types/react": "^19.2.7", - "@types/react-dom": "^19.2.3", - "jsdom": "^27.2.0", - "playwright": "^1.60.0", - "vitest": "^4.0.14" - } - }, - "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/css-color/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/@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/dom-selector/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/@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/code-frame": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", - "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", - "dependencies": { - "@babel/helper-validator-identifier": "^7.29.7", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/compat-data": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", - "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/core": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", - "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.7", - "@babel/helper-compilation-targets": "^7.29.7", - "@babel/helper-module-transforms": "^7.29.7", - "@babel/helpers": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/template": "^7.29.7", - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7", - "@jridgewell/remapping": "^2.3.5", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@babel/generator": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", - "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", - "dependencies": { - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "jsesc": "^3.0.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", - "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", - "dependencies": { - "@babel/compat-data": "^7.29.7", - "@babel/helper-validator-option": "^7.29.7", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-globals": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", - "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-imports": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", - "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", - "dependencies": { - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", - "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", - "dependencies": { - "@babel/helper-module-imports": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7", - "@babel/traverse": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", - "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", - "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", - "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-option": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", - "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helpers": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", - "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", - "dependencies": { - "@babel/template": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", - "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", - "dependencies": { - "@babel/types": "^7.29.7" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-self": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", - "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-source": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", - "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/template": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", - "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", - "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.7", - "@babel/helper-globals": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/template": "^7.29.7", - "@babel/types": "^7.29.7", - "debug": "^4.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/types": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", - "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", - "dependencies": { - "@babel/helper-string-parser": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7" - }, - "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.4", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.4.tgz", - "integrity": "sha512-wgsqt92b7C7tQhIdPNxj0n9zuUbQlvAuI1exyzeNrOKOi62SD7ren8zqszmpVREjAOqg8cD2FqYhQfAuKjk4sw==", - "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/@esbuild/aix-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", - "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", - "cpu": [ - "ppc64" - ], - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", - "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", - "cpu": [ - "arm" - ], - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", - "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", - "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", - "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", - "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", - "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", - "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", - "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", - "cpu": [ - "arm" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", - "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", - "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", - "cpu": [ - "ia32" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", - "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", - "cpu": [ - "loong64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", - "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", - "cpu": [ - "mips64el" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", - "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", - "cpu": [ - "ppc64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", - "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", - "cpu": [ - "riscv64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", - "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", - "cpu": [ - "s390x" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", - "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", - "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", - "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", - "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", - "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", - "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", - "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", - "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", - "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", - "cpu": [ - "ia32" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", - "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "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/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "engines": { - "node": ">=6.0.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==" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.3", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", - "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==" - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", - "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", - "cpu": [ - "arm" - ], - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", - "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", - "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", - "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", - "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", - "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", - "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", - "cpu": [ - "arm" - ], - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", - "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", - "cpu": [ - "arm" - ], - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", - "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", - "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", - "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", - "cpu": [ - "loong64" - ], - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", - "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", - "cpu": [ - "loong64" - ], - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", - "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", - "cpu": [ - "ppc64" - ], - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", - "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", - "cpu": [ - "ppc64" - ], - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", - "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", - "cpu": [ - "riscv64" - ], - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", - "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", - "cpu": [ - "riscv64" - ], - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", - "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", - "cpu": [ - "s390x" - ], - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", - "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", - "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", - "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", - "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", - "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", - "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", - "cpu": [ - "ia32" - ], - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", - "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", - "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "win32" - ] - }, - "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/@types/babel__core": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", - "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", - "dependencies": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" - } - }, - "node_modules/@types/babel__generator": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", - "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", - "dependencies": { - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__template": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", - "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", - "dependencies": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__traverse": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", - "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", - "dependencies": { - "@babel/types": "^7.28.2" - } - }, - "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.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==" - }, - "node_modules/@types/react": { - "version": "19.2.15", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz", - "integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==", - "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": "5.2.0", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", - "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", - "dependencies": { - "@babel/core": "^7.29.0", - "@babel/plugin-transform-react-jsx-self": "^7.27.1", - "@babel/plugin-transform-react-jsx-source": "^7.27.1", - "@rolldown/pluginutils": "1.0.0-rc.3", - "@types/babel__core": "^7.20.5", - "react-refresh": "^0.18.0" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/@vitest/expect": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.7.tgz", - "integrity": "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==", - "dev": true, - "dependencies": { - "@standard-schema/spec": "^1.1.0", - "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.7", - "@vitest/utils": "4.1.7", - "chai": "^6.2.2", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/mocker": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.7.tgz", - "integrity": "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==", - "dev": true, - "dependencies": { - "@vitest/spy": "4.1.7", - "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.7", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.7.tgz", - "integrity": "sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==", - "dev": true, - "dependencies": { - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.7.tgz", - "integrity": "sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==", - "dev": true, - "dependencies": { - "@vitest/utils": "4.1.7", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.7.tgz", - "integrity": "sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==", - "dev": true, - "dependencies": { - "@vitest/pretty-format": "4.1.7", - "@vitest/utils": "4.1.7", - "magic-string": "^0.30.21", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/spy": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.7.tgz", - "integrity": "sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==", - "dev": true, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/utils": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.7.tgz", - "integrity": "sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==", - "dev": true, - "dependencies": { - "@vitest/pretty-format": "4.1.7", - "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/baseline-browser-mapping": { - "version": "2.10.32", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.32.tgz", - "integrity": "sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==", - "bin": { - "baseline-browser-mapping": "dist/cli.cjs" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "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/browserslist": { - "version": "4.28.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", - "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "baseline-browser-mapping": "^2.10.12", - "caniuse-lite": "^1.0.30001782", - "electron-to-chromium": "^1.5.328", - "node-releases": "^2.0.36", - "update-browserslist-db": "^1.2.3" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001793", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", - "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ] - }, - "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/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==" - }, - "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/cssstyle/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/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/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "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/electron-to-chromium": { - "version": "1.5.364", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.364.tgz", - "integrity": "sha512-G/dYE3+AYhyHwzTwg8UbnXf7zqMERYh7l2jJ3QujhFsH8agSYwtnGAR2aZ7f0AakIKJXd5En/Hre4igIUrdlYw==" - }, - "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/esbuild": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", - "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", - "hasInstallScript": true, - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.7", - "@esbuild/android-arm": "0.27.7", - "@esbuild/android-arm64": "0.27.7", - "@esbuild/android-x64": "0.27.7", - "@esbuild/darwin-arm64": "0.27.7", - "@esbuild/darwin-x64": "0.27.7", - "@esbuild/freebsd-arm64": "0.27.7", - "@esbuild/freebsd-x64": "0.27.7", - "@esbuild/linux-arm": "0.27.7", - "@esbuild/linux-arm64": "0.27.7", - "@esbuild/linux-ia32": "0.27.7", - "@esbuild/linux-loong64": "0.27.7", - "@esbuild/linux-mips64el": "0.27.7", - "@esbuild/linux-ppc64": "0.27.7", - "@esbuild/linux-riscv64": "0.27.7", - "@esbuild/linux-s390x": "0.27.7", - "@esbuild/linux-x64": "0.27.7", - "@esbuild/netbsd-arm64": "0.27.7", - "@esbuild/netbsd-x64": "0.27.7", - "@esbuild/openbsd-arm64": "0.27.7", - "@esbuild/openbsd-x64": "0.27.7", - "@esbuild/openharmony-arm64": "0.27.7", - "@esbuild/sunos-x64": "0.27.7", - "@esbuild/win32-arm64": "0.27.7", - "@esbuild/win32-ia32": "0.27.7", - "@esbuild/win32-x64": "0.27.7" - } - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "engines": { - "node": ">=6" - } - }, - "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==", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "hasInstallScript": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "engines": { - "node": ">=6.9.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/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/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/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" - }, - "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/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dependencies": { - "yallist": "^3.0.2" - } - }, - "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==" - }, - "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==", - "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/node-releases": { - "version": "2.0.46", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz", - "integrity": "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==", - "engines": { - "node": ">=18" - } - }, - "node_modules/obug": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", - "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", - "dev": true, - "funding": [ - "https://github.com/sponsors/sxzz", - "https://opencollective.com/debug" - ] - }, - "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==" - }, - "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "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/playwright/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/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", - "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.6", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", - "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-dom": { - "version": "19.2.6", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", - "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", - "dependencies": { - "scheduler": "^0.27.0" - }, - "peerDependencies": { - "react": "^19.2.6" - } - }, - "node_modules/react-refresh": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", - "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", - "engines": { - "node": ">=0.10.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/rollup": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", - "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", - "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.60.4", - "@rollup/rollup-android-arm64": "4.60.4", - "@rollup/rollup-darwin-arm64": "4.60.4", - "@rollup/rollup-darwin-x64": "4.60.4", - "@rollup/rollup-freebsd-arm64": "4.60.4", - "@rollup/rollup-freebsd-x64": "4.60.4", - "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", - "@rollup/rollup-linux-arm-musleabihf": "4.60.4", - "@rollup/rollup-linux-arm64-gnu": "4.60.4", - "@rollup/rollup-linux-arm64-musl": "4.60.4", - "@rollup/rollup-linux-loong64-gnu": "4.60.4", - "@rollup/rollup-linux-loong64-musl": "4.60.4", - "@rollup/rollup-linux-ppc64-gnu": "4.60.4", - "@rollup/rollup-linux-ppc64-musl": "4.60.4", - "@rollup/rollup-linux-riscv64-gnu": "4.60.4", - "@rollup/rollup-linux-riscv64-musl": "4.60.4", - "@rollup/rollup-linux-s390x-gnu": "4.60.4", - "@rollup/rollup-linux-x64-gnu": "4.60.4", - "@rollup/rollup-linux-x64-musl": "4.60.4", - "@rollup/rollup-openbsd-x64": "4.60.4", - "@rollup/rollup-openharmony-arm64": "4.60.4", - "@rollup/rollup-win32-arm64-msvc": "4.60.4", - "@rollup/rollup-win32-ia32-msvc": "4.60.4", - "@rollup/rollup-win32-x64-gnu": "4.60.4", - "@rollup/rollup-win32-x64-msvc": "4.60.4", - "fsevents": "~2.3.2" - } - }, - "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/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "bin": { - "semver": "bin/semver.js" - } - }, - "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==", - "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/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.3", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.3.tgz", - "integrity": "sha512-g62dB+w1/OEFnPvmX0yd/HnetYITOL+1nJW7kitOycOeAvmbWC/nu0fwmmQ/kupNojqExzyC/T++pST/jRJ2mQ==", - "dev": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/tinyglobby": { - "version": "0.2.16", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", - "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", - "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.0", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.0.tgz", - "integrity": "sha512-yHBe+zVfzNZ3QfTPW/Z6KK1G2t340gFjMHqI/4KKSt/abzYydzuCnpqdaF5gCCABby+9Yfbj59oR5F2Fd5CBzg==", - "dev": true, - "dependencies": { - "tldts-core": "^7.4.0" - }, - "bin": { - "tldts": "bin/cli.js" - } - }, - "node_modules/tldts-core": { - "version": "7.4.1", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.1.tgz", - "integrity": "sha512-sc2nGvGbixlJRHwTh/qQdPXTxJU1UDJboGPQm4d/01YUJ9r/u6aeIulQvEaxUlvKDN7hb1qCLjax+jhVAPLa/g==", - "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/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, - "node_modules/vite": { - "version": "7.3.3", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.3.tgz", - "integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==", - "dependencies": { - "esbuild": "^0.27.0", - "fdir": "^6.5.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", - "tinyglobby": "^0.2.15" - }, - "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", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "lightningcss": "^1.21.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 - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "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/vitest": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.7.tgz", - "integrity": "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==", - "dev": true, - "dependencies": { - "@vitest/expect": "4.1.7", - "@vitest/mocker": "4.1.7", - "@vitest/pretty-format": "4.1.7", - "@vitest/runner": "4.1.7", - "@vitest/snapshot": "4.1.7", - "@vitest/spy": "4.1.7", - "@vitest/utils": "4.1.7", - "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.7", - "@vitest/browser-preview": "4.1.7", - "@vitest/browser-webdriverio": "4.1.7", - "@vitest/coverage-istanbul": "4.1.7", - "@vitest/coverage-v8": "4.1.7", - "@vitest/ui": "4.1.7", - "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/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/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==" - } - } -} diff --git a/services/pcp-tgu-ops-ui/package.json b/services/pcp-tgu-ops-ui/package.json deleted file mode 100644 index 2f18c72..0000000 --- a/services/pcp-tgu-ops-ui/package.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "name": "pcp-tgu-ops-ui", - "version": "0.1.0", - "private": true, - "type": "module", - "scripts": { - "dev": "vite", - "build": "tsc -b && vite build", - "test": "vitest run", - "preview": "vite preview" - }, - "dependencies": { - "@vitejs/plugin-react": "^5.1.1", - "d3-geo": "^3.1.1", - "react": "^19.2.0", - "react-dom": "^19.2.0", - "topojson-client": "^3.1.0", - "typescript": "^5.9.3", - "vite": "^7.2.4" - }, - "devDependencies": { - "@types/react": "^19.2.7", - "@types/react-dom": "^19.2.3", - "jsdom": "^27.2.0", - "playwright": "^1.60.0", - "vitest": "^4.0.14" - } -} diff --git a/services/pcp-tgu-ops-ui/src/App.tsx b/services/pcp-tgu-ops-ui/src/App.tsx deleted file mode 100644 index 8e53210..0000000 --- a/services/pcp-tgu-ops-ui/src/App.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { TguPlanningPage } from "./features/tgu-planning/TguPlanningPage"; - -export default function App() { - return ; -} diff --git a/services/pcp-tgu-ops-ui/src/api/ballisticsApi.ts b/services/pcp-tgu-ops-ui/src/api/ballisticsApi.ts deleted file mode 100644 index d9cf52e..0000000 --- a/services/pcp-tgu-ops-ui/src/api/ballisticsApi.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { TguApiError } from "../model/tguTypes"; -import { toLocalIso } from "../utils/localTime"; - -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). Возвращает по одному моменту видимости на виток. - */ -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 fetch(`${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). - */ -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 fetch(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. */ -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-ops-ui/src/api/complexPlanApi.ts b/services/pcp-tgu-ops-ui/src/api/complexPlanApi.ts deleted file mode 100644 index 2f379b6..0000000 --- a/services/pcp-tgu-ops-ui/src/api/complexPlanApi.ts +++ /dev/null @@ -1,198 +0,0 @@ -import { TguApiError } from "../model/tguTypes"; -import { toLocalIso } from "../utils/localTime"; - -/** - * Расчёт комплексного плана (этап 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’ов. Бэк отклоняет запуск, если по - * одному из КА уже идёт пересекающийся расчёт. - */ -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 fetch(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). - * Список отсортирован бэком по убыванию даты создания, поэтому первый завершённый элемент — - * это последний построенный комплексный план. - */ -export async function fetchComplexPlanRuns(): Promise { - const url = `${BASE}/api/com-plan/runs`; - let response: Response; - try { - response = await fetch(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). - */ -export async function fetchComplexPlanRunModes(runId: number): Promise { - const url = `${BASE}/api/com-plan/runs/${encodeURIComponent(runId)}/modes`; - let response: Response; - try { - response = await fetch(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-ops-ui/src/api/missionPlanningApi.ts b/services/pcp-tgu-ops-ui/src/api/missionPlanningApi.ts deleted file mode 100644 index 368632c..0000000 --- a/services/pcp-tgu-ops-ui/src/api/missionPlanningApi.ts +++ /dev/null @@ -1,339 +0,0 @@ -import { TguApiError } from "../model/tguTypes"; - -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-превью. - */ -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). - */ -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). - */ -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 fetch(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), затирая только пересчитываемые. - */ -export async function calculateSurveys(planId: string): Promise { - const url = `${BASE}/api/missions/${encodeURIComponent(planId)}/surveys/calculate`; - let response: Response; - try { - response = await fetch(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. - */ -export async function calculateDrops(planId: string): Promise { - const url = `${BASE}/api/missions/${encodeURIComponent(planId)}/drops/calculate`; - let response: Response; - try { - response = await fetch(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. - */ -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 fetch(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. - */ -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}). Для съёмки бэк снимает - * брони слотов и связи со сбросами, осиротевший сброс удаляется; для сброса — только его связи. - */ -export async function deleteMode(modeId: number): Promise { - const url = `${BASE}/api/missions/modes/${encodeURIComponent(modeId)}`; - let response: Response; - try { - response = await fetch(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. - */ -export async function validateMission(missionId: string): Promise { - const url = `${BASE}/api/missions/${encodeURIComponent(missionId)}/validate`; - let response: Response; - try { - response = await fetch(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 fetch(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-ops-ui/src/api/requestApi.ts b/services/pcp-tgu-ops-ui/src/api/requestApi.ts deleted file mode 100644 index 7d3e8d4..0000000 --- a/services/pcp-tgu-ops-ui/src/api/requestApi.ts +++ /dev/null @@ -1,102 +0,0 @@ -import type { CreateRequestPayload, CreateRequestResult, RequestFilter, RequestMapItem } from "../model/requestTypes"; - -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 fetch(`${BASE}/requests/map?${params}`, { - headers: { Accept: "application/json" } - }); - - if (!response.ok) { - throw new Error(`Ошибка загрузки заявок: ${response.status}`); - } - - return response.json() as Promise; -} - -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}`; -} - -export async function createRequest(payload: CreateRequestPayload): Promise { - const response = await fetch(`${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; -} - -export async function deleteRequest(id: string): Promise { - const response = await fetch(`${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-ops-ui/src/api/stationsApi.ts b/services/pcp-tgu-ops-ui/src/api/stationsApi.ts deleted file mode 100644 index aede019..0000000 --- a/services/pcp-tgu-ops-ui/src/api/stationsApi.ts +++ /dev/null @@ -1,18 +0,0 @@ -export type StationDto = { - id: string | null; - number: number; - name: string; - position: { lat: number; long: number; height: number }; - elevationMin: number; - elevationMax: number; -}; - -export async function fetchStations(): Promise { - const response = await fetch("/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-ops-ui/src/api/tguApi.ts b/services/pcp-tgu-ops-ui/src/api/tguApi.ts deleted file mode 100644 index 61384b8..0000000 --- a/services/pcp-tgu-ops-ui/src/api/tguApi.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { - TguApiError, - type TguPlan, - type TguPlanDecision, - type TguPlanDecisionMessage, - type TguPlatform -} from "../model/tguTypes"; -import { toLocalIso } from "../utils/localTime"; - -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"; -}; - -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}` - ); -} - -export async function fetchPlatforms(): Promise { - return fetchJson("/api/tgu-planning/platforms"); -} - -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); -} - -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 — статус не снимаемый. - */ -export async function dismissPlan(planId: string): Promise { - await fetchJson( - `/api/tgu-planning/plans/${encodeURIComponent(planId)}/dismiss`, - { method: "POST" } - ); -} - -/** «Вернуть на учёт» ранее снятый план. */ -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 мин»). - */ -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 fetch(url, { - headers: { Accept: "application/json" }, - ...init - }); - } catch (error) { - 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-ops-ui/src/api/tguPlanApi.ts b/services/pcp-tgu-ops-ui/src/api/tguPlanApi.ts deleted file mode 100644 index cf9abd4..0000000 --- a/services/pcp-tgu-ops-ui/src/api/tguPlanApi.ts +++ /dev/null @@ -1,230 +0,0 @@ -import { TguApiError } from "../model/tguTypes"; - -/** - * Вызовы версионирования/выдачи плана в 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). - */ -export async function fetchSpacecraftPlans(spacecraftId: string): Promise { - const url = `${BASE}/api/plans/${encodeURIComponent(spacecraftId)}`; - let response: Response; - try { - response = await fetch(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). - * Из них оператор набирает окно нового плана и видит доступные зоны на таймлайне. - */ -export async function fetchZrvWindows(spacecraftId: string): Promise { - const url = `${BASE}/api/plans/${encodeURIComponent(spacecraftId)}/zrv`; - let response: Response; - try { - response = await fetch(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 планов их отсекает, поэтому режим «Черновики» во вкладке «Планы» читает их отдельно. - */ -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 fetch(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 — окно не из ЗРВ / база чужого КА / нечисловой КА. - */ -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). - */ -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 — черновика нет. - */ -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. - */ -export async function abandonDraft(draftPlanId: string): Promise { - const url = `${BASE}/api/plans/${encodeURIComponent(draftPlanId)}`; - let response: Response; - try { - response = await fetch(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 fetch(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-ops-ui/src/components/timeFields.test.ts b/services/pcp-tgu-ops-ui/src/components/timeFields.test.ts deleted file mode 100644 index c597537..0000000 --- a/services/pcp-tgu-ops-ui/src/components/timeFields.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -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-ops-ui/src/components/timeFields.tsx b/services/pcp-tgu-ops-ui/src/components/timeFields.tsx deleted file mode 100644 index 916e58c..0000000 --- a/services/pcp-tgu-ops-ui/src/components/timeFields.tsx +++ /dev/null @@ -1,135 +0,0 @@ -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-ops-ui/src/features/constellation/ConstellationDashboard.jsx b/services/pcp-tgu-ops-ui/src/features/constellation/ConstellationDashboard.jsx deleted file mode 100644 index 97701f8..0000000 --- a/services/pcp-tgu-ops-ui/src/features/constellation/ConstellationDashboard.jsx +++ /dev/null @@ -1,226 +0,0 @@ -/* ============================================================ - 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-ops-ui/src/features/constellation/ConstellationTab.tsx b/services/pcp-tgu-ops-ui/src/features/constellation/ConstellationTab.tsx deleted file mode 100644 index 8e26e85..0000000 --- a/services/pcp-tgu-ops-ui/src/features/constellation/ConstellationTab.tsx +++ /dev/null @@ -1,75 +0,0 @@ -/* ============================================================ - 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-ops-ui/src/features/constellation/MapView.jsx b/services/pcp-tgu-ops-ui/src/features/constellation/MapView.jsx deleted file mode 100644 index e8952ef..0000000 --- a/services/pcp-tgu-ops-ui/src/features/constellation/MapView.jsx +++ /dev/null @@ -1,360 +0,0 @@ -/* ============================================================ - 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-ops-ui/src/features/constellation/Panels.jsx b/services/pcp-tgu-ops-ui/src/features/constellation/Panels.jsx deleted file mode 100644 index b1e86ea..0000000 --- a/services/pcp-tgu-ops-ui/src/features/constellation/Panels.jsx +++ /dev/null @@ -1,182 +0,0 @@ -/* ============================================================ - 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-ops-ui/src/features/constellation/Timeline.jsx b/services/pcp-tgu-ops-ui/src/features/constellation/Timeline.jsx deleted file mode 100644 index cbb6ba6..0000000 --- a/services/pcp-tgu-ops-ui/src/features/constellation/Timeline.jsx +++ /dev/null @@ -1,163 +0,0 @@ -/* ============================================================ - 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-ops-ui/src/features/constellation/assets/land-110m.json b/services/pcp-tgu-ops-ui/src/features/constellation/assets/land-110m.json deleted file mode 100644 index fbd897c..0000000 --- a/services/pcp-tgu-ops-ui/src/features/constellation/assets/land-110m.json +++ /dev/null @@ -1 +0,0 @@ -{"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-ops-ui/src/features/constellation/dashboard.css b/services/pcp-tgu-ops-ui/src/features/constellation/dashboard.css deleted file mode 100644 index f8d2a01..0000000 --- a/services/pcp-tgu-ops-ui/src/features/constellation/dashboard.css +++ /dev/null @@ -1,184 +0,0 @@ -/* 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-ops-ui/src/features/constellation/data.js b/services/pcp-tgu-ops-ui/src/features/constellation/data.js deleted file mode 100644 index d66b900..0000000 --- a/services/pcp-tgu-ops-ui/src/features/constellation/data.js +++ /dev/null @@ -1,73 +0,0 @@ -/* ============================================================ - 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-ops-ui/src/features/constellation/index.js b/services/pcp-tgu-ops-ui/src/features/constellation/index.js deleted file mode 100644 index ab53214..0000000 --- a/services/pcp-tgu-ops-ui/src/features/constellation/index.js +++ /dev/null @@ -1,10 +0,0 @@ -/* 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-ops-ui/src/features/constellation/realData.ts b/services/pcp-tgu-ops-ui/src/features/constellation/realData.ts deleted file mode 100644 index 3558a0e..0000000 --- a/services/pcp-tgu-ops-ui/src/features/constellation/realData.ts +++ /dev/null @@ -1,424 +0,0 @@ -/* ============================================================ - 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-ops-ui/src/features/constellation/sim.js b/services/pcp-tgu-ops-ui/src/features/constellation/sim.js deleted file mode 100644 index c247507..0000000 --- a/services/pcp-tgu-ops-ui/src/features/constellation/sim.js +++ /dev/null @@ -1,183 +0,0 @@ -/* ============================================================ - 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-ops-ui/src/features/constellation/util.js b/services/pcp-tgu-ops-ui/src/features/constellation/util.js deleted file mode 100644 index 0f91d08..0000000 --- a/services/pcp-tgu-ops-ui/src/features/constellation/util.js +++ /dev/null @@ -1,9 +0,0 @@ -/* 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-ops-ui/src/features/tgu-complex-plan/TguComplexPlanTab.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-complex-plan/TguComplexPlanTab.tsx deleted file mode 100644 index 0183bba..0000000 --- a/services/pcp-tgu-ops-ui/src/features/tgu-complex-plan/TguComplexPlanTab.tsx +++ /dev/null @@ -1,331 +0,0 @@ -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-ops-ui/src/features/tgu-editor/EditorPanels.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorPanels.tsx deleted file mode 100644 index e0794dd..0000000 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorPanels.tsx +++ /dev/null @@ -1,830 +0,0 @@ -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-ops-ui/src/features/tgu-editor/EditorRail.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorRail.tsx deleted file mode 100644 index b2bbef4..0000000 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorRail.tsx +++ /dev/null @@ -1,89 +0,0 @@ -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-ops-ui/src/features/tgu-editor/EditorTimeline.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorTimeline.tsx deleted file mode 100644 index 8aaca74..0000000 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorTimeline.tsx +++ /dev/null @@ -1,298 +0,0 @@ -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-ops-ui/src/features/tgu-editor/EditorToolbar.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorToolbar.tsx deleted file mode 100644 index 5668e4c..0000000 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorToolbar.tsx +++ /dev/null @@ -1,115 +0,0 @@ -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-ops-ui/src/features/tgu-editor/MapPassSelector.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-editor/MapPassSelector.tsx deleted file mode 100644 index a7eaaf3..0000000 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/MapPassSelector.tsx +++ /dev/null @@ -1,127 +0,0 @@ -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-ops-ui/src/features/tgu-editor/TguEditorTab.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx deleted file mode 100644 index a4335e6..0000000 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx +++ /dev/null @@ -1,1734 +0,0 @@ -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-ops-ui/src/features/tgu-editor/VisibilityTracks.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-editor/VisibilityTracks.tsx deleted file mode 100644 index d631b41..0000000 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/VisibilityTracks.tsx +++ /dev/null @@ -1,58 +0,0 @@ -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-ops-ui/src/features/tgu-editor/editorApi.ts b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorApi.ts deleted file mode 100644 index ceab45e..0000000 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorApi.ts +++ /dev/null @@ -1,40 +0,0 @@ -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-ops-ui/src/features/tgu-editor/editorConflicts.test.ts b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorConflicts.test.ts deleted file mode 100644 index 57c29a7..0000000 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorConflicts.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -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-ops-ui/src/features/tgu-editor/editorConflicts.ts b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorConflicts.ts deleted file mode 100644 index fb79498..0000000 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorConflicts.ts +++ /dev/null @@ -1,103 +0,0 @@ -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-ops-ui/src/features/tgu-editor/editorDraft.test.ts b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorDraft.test.ts deleted file mode 100644 index 041f186..0000000 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorDraft.test.ts +++ /dev/null @@ -1,171 +0,0 @@ -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-ops-ui/src/features/tgu-editor/editorDraft.ts b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorDraft.ts deleted file mode 100644 index 1665d11..0000000 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorDraft.ts +++ /dev/null @@ -1,275 +0,0 @@ -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-ops-ui/src/features/tgu-editor/editorPresentation.ts b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorPresentation.ts deleted file mode 100644 index 629e22d..0000000 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorPresentation.ts +++ /dev/null @@ -1,113 +0,0 @@ -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-ops-ui/src/features/tgu-editor/editorValidation.test.ts b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorValidation.test.ts deleted file mode 100644 index be34e76..0000000 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorValidation.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -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-ops-ui/src/features/tgu-editor/editorValidation.ts b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorValidation.ts deleted file mode 100644 index a5e7e5e..0000000 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorValidation.ts +++ /dev/null @@ -1,29 +0,0 @@ -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-ops-ui/src/features/tgu-editor/model/editorTypes.ts b/services/pcp-tgu-ops-ui/src/features/tgu-editor/model/editorTypes.ts deleted file mode 100644 index 59fc57c..0000000 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/model/editorTypes.ts +++ /dev/null @@ -1,133 +0,0 @@ -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-ops-ui/src/features/tgu-map-2d/Tgu2DMapDetails.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapDetails.tsx deleted file mode 100644 index c4d7937..0000000 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapDetails.tsx +++ /dev/null @@ -1,48 +0,0 @@ -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-ops-ui/src/features/tgu-map-2d/Tgu2DMapLayers.ts b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapLayers.ts deleted file mode 100644 index 53eec8a..0000000 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapLayers.ts +++ /dev/null @@ -1,92 +0,0 @@ -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-ops-ui/src/features/tgu-map-2d/Tgu2DMapRequestsPanel.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapRequestsPanel.tsx deleted file mode 100644 index 29cac64..0000000 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapRequestsPanel.tsx +++ /dev/null @@ -1,116 +0,0 @@ -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-ops-ui/src/features/tgu-map-2d/Tgu2DMapSidebar.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapSidebar.tsx deleted file mode 100644 index b957970..0000000 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapSidebar.tsx +++ /dev/null @@ -1,202 +0,0 @@ -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-ops-ui/src/features/tgu-map-2d/Tgu2DMapStationPanel.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapStationPanel.tsx deleted file mode 100644 index fab4765..0000000 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapStationPanel.tsx +++ /dev/null @@ -1,43 +0,0 @@ -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-ops-ui/src/features/tgu-map-2d/Tgu2DMapTab.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapTab.tsx deleted file mode 100644 index b02477a..0000000 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapTab.tsx +++ /dev/null @@ -1,324 +0,0 @@ -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-ops-ui/src/features/tgu-map-2d/Tgu2DMapToolbar.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapToolbar.tsx deleted file mode 100644 index 9057610..0000000 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapToolbar.tsx +++ /dev/null @@ -1,44 +0,0 @@ -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-ops-ui/src/features/tgu-map-2d/Tgu2DMapView.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapView.tsx deleted file mode 100644 index ac200bb..0000000 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapView.tsx +++ /dev/null @@ -1,762 +0,0 @@ -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-ops-ui/src/features/tgu-map-2d/canvas/drawBaseMap.ts b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/canvas/drawBaseMap.ts deleted file mode 100644 index 059eedf..0000000 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/canvas/drawBaseMap.ts +++ /dev/null @@ -1,26 +0,0 @@ -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-ops-ui/src/features/tgu-map-2d/canvas/drawComplexPlan.ts b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/canvas/drawComplexPlan.ts deleted file mode 100644 index 367baf2..0000000 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/canvas/drawComplexPlan.ts +++ /dev/null @@ -1,61 +0,0 @@ -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-ops-ui/src/features/tgu-map-2d/canvas/drawPlanWorks.ts b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/canvas/drawPlanWorks.ts deleted file mode 100644 index e42ca6f..0000000 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/canvas/drawPlanWorks.ts +++ /dev/null @@ -1,70 +0,0 @@ -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-ops-ui/src/features/tgu-map-2d/canvas/drawRequests.ts b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/canvas/drawRequests.ts deleted file mode 100644 index 59671b6..0000000 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/canvas/drawRequests.ts +++ /dev/null @@ -1,69 +0,0 @@ -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-ops-ui/src/features/tgu-map-2d/canvas/drawStations.ts b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/canvas/drawStations.ts deleted file mode 100644 index 48eb2d7..0000000 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/canvas/drawStations.ts +++ /dev/null @@ -1,21 +0,0 @@ -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-ops-ui/src/features/tgu-map-2d/canvas/drawSwath.ts b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/canvas/drawSwath.ts deleted file mode 100644 index c2dee16..0000000 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/canvas/drawSwath.ts +++ /dev/null @@ -1,58 +0,0 @@ -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-ops-ui/src/features/tgu-map-2d/canvas/drawTiles.ts b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/canvas/drawTiles.ts deleted file mode 100644 index 96e7a1b..0000000 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/canvas/drawTiles.ts +++ /dev/null @@ -1,64 +0,0 @@ -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-ops-ui/src/features/tgu-map-2d/canvas/drawTracks.ts b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/canvas/drawTracks.ts deleted file mode 100644 index 07a58f0..0000000 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/canvas/drawTracks.ts +++ /dev/null @@ -1,39 +0,0 @@ -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-ops-ui/src/features/tgu-map-2d/canvas/tileLoader.ts b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/canvas/tileLoader.ts deleted file mode 100644 index 4690c56..0000000 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/canvas/tileLoader.ts +++ /dev/null @@ -1,45 +0,0 @@ -// 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-ops-ui/src/features/tgu-map-2d/geometry/bbox.ts b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/geometry/bbox.ts deleted file mode 100644 index 2b1dbaf..0000000 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/geometry/bbox.ts +++ /dev/null @@ -1,38 +0,0 @@ -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-ops-ui/src/features/tgu-map-2d/geometry/mapProjection.ts b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/geometry/mapProjection.ts deleted file mode 100644 index cb19725..0000000 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/geometry/mapProjection.ts +++ /dev/null @@ -1,67 +0,0 @@ -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-ops-ui/src/features/tgu-map-2d/geometry/passes.test.ts b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/geometry/passes.test.ts deleted file mode 100644 index 3a7a3a4..0000000 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/geometry/passes.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -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-ops-ui/src/features/tgu-map-2d/geometry/passes.ts b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/geometry/passes.ts deleted file mode 100644 index 733bc17..0000000 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/geometry/passes.ts +++ /dev/null @@ -1,225 +0,0 @@ -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-ops-ui/src/features/tgu-map-2d/geometry/pointInPolygon.ts b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/geometry/pointInPolygon.ts deleted file mode 100644 index c30d4a6..0000000 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/geometry/pointInPolygon.ts +++ /dev/null @@ -1,21 +0,0 @@ -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-ops-ui/src/features/tgu-map-2d/geometry/simplifyGeometry.ts b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/geometry/simplifyGeometry.ts deleted file mode 100644 index 40a5c21..0000000 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/geometry/simplifyGeometry.ts +++ /dev/null @@ -1,22 +0,0 @@ -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-ops-ui/src/features/tgu-map-2d/geometry/spatialIndex.test.ts b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/geometry/spatialIndex.test.ts deleted file mode 100644 index 4c2b25e..0000000 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/geometry/spatialIndex.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -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-ops-ui/src/features/tgu-map-2d/geometry/spatialIndex.ts b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/geometry/spatialIndex.ts deleted file mode 100644 index 31463fb..0000000 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/geometry/spatialIndex.ts +++ /dev/null @@ -1,98 +0,0 @@ -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-ops-ui/src/features/tgu-map-2d/geometry/wktParser.ts b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/geometry/wktParser.ts deleted file mode 100644 index 4447386..0000000 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/geometry/wktParser.ts +++ /dev/null @@ -1,54 +0,0 @@ -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-ops-ui/src/features/tgu-map-2d/model/mapLayerTypes.ts b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/model/mapLayerTypes.ts deleted file mode 100644 index 6dff8ab..0000000 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/model/mapLayerTypes.ts +++ /dev/null @@ -1,45 +0,0 @@ -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-ops-ui/src/features/tgu-map-2d/model/mapSelectionTypes.ts b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/model/mapSelectionTypes.ts deleted file mode 100644 index e2af403..0000000 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/model/mapSelectionTypes.ts +++ /dev/null @@ -1,40 +0,0 @@ -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-ops-ui/src/features/tgu-map-2d/model/mapTypes.ts b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/model/mapTypes.ts deleted file mode 100644 index 227bf17..0000000 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/model/mapTypes.ts +++ /dev/null @@ -1,72 +0,0 @@ -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-ops-ui/src/features/tgu-map-2d/tgu2DMapInterval.test.ts b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/tgu2DMapInterval.test.ts deleted file mode 100644 index 1d5f154..0000000 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/tgu2DMapInterval.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -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-ops-ui/src/features/tgu-map-2d/tgu2DMapInterval.ts b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/tgu2DMapInterval.ts deleted file mode 100644 index 845d5d7..0000000 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/tgu2DMapInterval.ts +++ /dev/null @@ -1,29 +0,0 @@ -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-ops-ui/src/features/tgu-planning/CreateChainPlanDialog.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-planning/CreateChainPlanDialog.tsx deleted file mode 100644 index 19f88fc..0000000 --- a/services/pcp-tgu-ops-ui/src/features/tgu-planning/CreateChainPlanDialog.tsx +++ /dev/null @@ -1,208 +0,0 @@ -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-ops-ui/src/features/tgu-planning/TguLegend.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguLegend.tsx deleted file mode 100644 index b3a9b97..0000000 --- a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguLegend.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { STATUS_STYLES } from "./tguStatus"; - -export function TguLegend() { - return ( -
- {STATUS_STYLES.map((item) => ( - - - {item.label} - - ))} -
- ); -} diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanDetails.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanDetails.tsx deleted file mode 100644 index 5de6831..0000000 --- a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanDetails.tsx +++ /dev/null @@ -1,315 +0,0 @@ -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-ops-ui/src/features/tgu-planning/TguPlanningLayout.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningLayout.tsx deleted file mode 100644 index 587ccb4..0000000 --- a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningLayout.tsx +++ /dev/null @@ -1,15 +0,0 @@ -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-ops-ui/src/features/tgu-planning/TguPlanningPage.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningPage.tsx deleted file mode 100644 index cb66811..0000000 --- a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningPage.tsx +++ /dev/null @@ -1,623 +0,0 @@ -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-ops-ui/src/features/tgu-planning/TguSidebar.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguSidebar.tsx deleted file mode 100644 index adffc70..0000000 --- a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguSidebar.tsx +++ /dev/null @@ -1,79 +0,0 @@ -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-ops-ui/src/features/tgu-planning/TguTimeline.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguTimeline.tsx deleted file mode 100644 index ef44fbf..0000000 --- a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguTimeline.tsx +++ /dev/null @@ -1,299 +0,0 @@ -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-ops-ui/src/features/tgu-planning/TguToolbar.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguToolbar.tsx deleted file mode 100644 index 56cd096..0000000 --- a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguToolbar.tsx +++ /dev/null @@ -1,122 +0,0 @@ -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-ops-ui/src/features/tgu-planning/TguZrvPanel.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguZrvPanel.tsx deleted file mode 100644 index 6385cab..0000000 --- a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguZrvPanel.tsx +++ /dev/null @@ -1,74 +0,0 @@ -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-ops-ui/src/features/tgu-planning/createChainWindows.test.ts b/services/pcp-tgu-ops-ui/src/features/tgu-planning/createChainWindows.test.ts deleted file mode 100644 index f35c17d..0000000 --- a/services/pcp-tgu-ops-ui/src/features/tgu-planning/createChainWindows.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -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-ops-ui/src/features/tgu-planning/createChainWindows.ts b/services/pcp-tgu-ops-ui/src/features/tgu-planning/createChainWindows.ts deleted file mode 100644 index 88bc613..0000000 --- a/services/pcp-tgu-ops-ui/src/features/tgu-planning/createChainWindows.ts +++ /dev/null @@ -1,65 +0,0 @@ -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-ops-ui/src/features/tgu-planning/tguStatus.ts b/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguStatus.ts deleted file mode 100644 index c71c2f9..0000000 --- a/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguStatus.ts +++ /dev/null @@ -1,67 +0,0 @@ -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-ops-ui/src/features/tgu-planning/tguTimelineLayout.test.ts b/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguTimelineLayout.test.ts deleted file mode 100644 index 675a51d..0000000 --- a/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguTimelineLayout.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -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-ops-ui/src/features/tgu-planning/tguTimelineLayout.ts b/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguTimelineLayout.ts deleted file mode 100644 index 812e60d..0000000 --- a/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguTimelineLayout.ts +++ /dev/null @@ -1,142 +0,0 @@ -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-ops-ui/src/features/tgu-planning/tguTimelineMapper.test.ts b/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguTimelineMapper.test.ts deleted file mode 100644 index f48f9ed..0000000 --- a/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguTimelineMapper.test.ts +++ /dev/null @@ -1,126 +0,0 @@ -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-ops-ui/src/features/tgu-planning/tguTimelineMapper.ts b/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguTimelineMapper.ts deleted file mode 100644 index 52bf02d..0000000 --- a/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguTimelineMapper.ts +++ /dev/null @@ -1,248 +0,0 @@ -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-ops-ui/src/features/tgu-requests/RequestCreatePanel.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-requests/RequestCreatePanel.tsx deleted file mode 100644 index c6d7097..0000000 --- a/services/pcp-tgu-ops-ui/src/features/tgu-requests/RequestCreatePanel.tsx +++ /dev/null @@ -1,357 +0,0 @@ -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-ops-ui/src/features/tgu-requests/RequestDetailsPanel.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-requests/RequestDetailsPanel.tsx deleted file mode 100644 index a19d202..0000000 --- a/services/pcp-tgu-ops-ui/src/features/tgu-requests/RequestDetailsPanel.tsx +++ /dev/null @@ -1,172 +0,0 @@ -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-ops-ui/src/features/tgu-requests/TguRequestsTab.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-requests/TguRequestsTab.tsx deleted file mode 100644 index 4e7b3ab..0000000 --- a/services/pcp-tgu-ops-ui/src/features/tgu-requests/TguRequestsTab.tsx +++ /dev/null @@ -1,411 +0,0 @@ -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-ops-ui/src/features/tgu-requests/requestGeometry.test.ts b/services/pcp-tgu-ops-ui/src/features/tgu-requests/requestGeometry.test.ts deleted file mode 100644 index 4469fab..0000000 --- a/services/pcp-tgu-ops-ui/src/features/tgu-requests/requestGeometry.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -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-ops-ui/src/features/tgu-requests/requestGeometry.ts b/services/pcp-tgu-ops-ui/src/features/tgu-requests/requestGeometry.ts deleted file mode 100644 index f85be6a..0000000 --- a/services/pcp-tgu-ops-ui/src/features/tgu-requests/requestGeometry.ts +++ /dev/null @@ -1,27 +0,0 @@ -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-ops-ui/src/main.tsx b/services/pcp-tgu-ops-ui/src/main.tsx deleted file mode 100644 index e676c79..0000000 --- a/services/pcp-tgu-ops-ui/src/main.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { StrictMode } from "react"; -import { createRoot } from "react-dom/client"; -import App from "./App"; -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-ops-ui/src/model/requestTypes.ts b/services/pcp-tgu-ops-ui/src/model/requestTypes.ts deleted file mode 100644 index fb916c4..0000000 --- a/services/pcp-tgu-ops-ui/src/model/requestTypes.ts +++ /dev/null @@ -1,106 +0,0 @@ -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-ops-ui/src/model/tguTypes.ts b/services/pcp-tgu-ops-ui/src/model/tguTypes.ts deleted file mode 100644 index 48a5363..0000000 --- a/services/pcp-tgu-ops-ui/src/model/tguTypes.ts +++ /dev/null @@ -1,66 +0,0 @@ -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-ops-ui/src/model/timelineTypes.ts b/services/pcp-tgu-ops-ui/src/model/timelineTypes.ts deleted file mode 100644 index d0c0cbe..0000000 --- a/services/pcp-tgu-ops-ui/src/model/timelineTypes.ts +++ /dev/null @@ -1,64 +0,0 @@ -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-ops-ui/src/styles/editor-tokens.css b/services/pcp-tgu-ops-ui/src/styles/editor-tokens.css deleted file mode 100644 index c24f05e..0000000 --- a/services/pcp-tgu-ops-ui/src/styles/editor-tokens.css +++ /dev/null @@ -1,20 +0,0 @@ -: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-ops-ui/src/styles/tgu-editor.css b/services/pcp-tgu-ops-ui/src/styles/tgu-editor.css deleted file mode 100644 index 7f86e9f..0000000 --- a/services/pcp-tgu-ops-ui/src/styles/tgu-editor.css +++ /dev/null @@ -1,1223 +0,0 @@ -.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-ops-ui/src/styles/tgu-planning.css b/services/pcp-tgu-ops-ui/src/styles/tgu-planning.css deleted file mode 100644 index cd56a02..0000000 --- a/services/pcp-tgu-ops-ui/src/styles/tgu-planning.css +++ /dev/null @@ -1,1599 +0,0 @@ -.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-ops-ui/src/styles/theme.css b/services/pcp-tgu-ops-ui/src/styles/theme.css deleted file mode 100644 index 6b08e61..0000000 --- a/services/pcp-tgu-ops-ui/src/styles/theme.css +++ /dev/null @@ -1,57 +0,0 @@ -: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-ops-ui/src/utils/dataVolume.ts b/services/pcp-tgu-ops-ui/src/utils/dataVolume.ts deleted file mode 100644 index dbd03be..0000000 --- a/services/pcp-tgu-ops-ui/src/utils/dataVolume.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Форматирование объёма данных режима (съёмка/сброс). Бэк отдаёт объём в мегабайтах - * (см. 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-ops-ui/src/utils/localTime.ts b/services/pcp-tgu-ops-ui/src/utils/localTime.ts deleted file mode 100644 index 35e5364..0000000 --- a/services/pcp-tgu-ops-ui/src/utils/localTime.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * Единый разбор и форматирование времени в локальной зоне. - * - * Бэкенд (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-ops-ui/src/utils/uuid.test.ts b/services/pcp-tgu-ops-ui/src/utils/uuid.test.ts deleted file mode 100644 index a4d4f4c..0000000 --- a/services/pcp-tgu-ops-ui/src/utils/uuid.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -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-ops-ui/src/utils/uuid.ts b/services/pcp-tgu-ops-ui/src/utils/uuid.ts deleted file mode 100644 index 0bd9dd9..0000000 --- a/services/pcp-tgu-ops-ui/src/utils/uuid.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** - * Генерация 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-ops-ui/tsconfig.app.json b/services/pcp-tgu-ops-ui/tsconfig.app.json deleted file mode 100644 index 21c6762..0000000 --- a/services/pcp-tgu-ops-ui/tsconfig.app.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "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-ops-ui/tsconfig.json b/services/pcp-tgu-ops-ui/tsconfig.json deleted file mode 100644 index 1ffef60..0000000 --- a/services/pcp-tgu-ops-ui/tsconfig.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "files": [], - "references": [ - { "path": "./tsconfig.app.json" }, - { "path": "./tsconfig.node.json" } - ] -} diff --git a/services/pcp-tgu-ops-ui/tsconfig.node.json b/services/pcp-tgu-ops-ui/tsconfig.node.json deleted file mode 100644 index c077346..0000000 --- a/services/pcp-tgu-ops-ui/tsconfig.node.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "compilerOptions": { - "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", - "target": "ES2023", - "lib": ["ES2023"], - "module": "ESNext", - "moduleResolution": "Bundler", - "allowSyntheticDefaultImports": true, - "strict": true, - "noEmit": true, - "skipLibCheck": true - }, - "include": ["vite.config.ts"] -} diff --git a/services/pcp-tgu-ops-ui/vite.config.ts b/services/pcp-tgu-ops-ui/vite.config.ts deleted file mode 100644 index de3bddb..0000000 --- a/services/pcp-tgu-ops-ui/vite.config.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { defineConfig } from "vitest/config"; -import react from "@vitejs/plugin-react"; - -export default defineConfig({ - plugins: [react()], - server: { - host: "0.0.0.0", - port: 5174, - 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 - } -});