From 715b5108d6c4d69139cad36c063fb5324ab03104 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: Mon, 1 Jun 2026 22:17:32 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B5=D0=B4=D0=B0=D0=BA=D1=82=D0=BE?= =?UTF-8?q?=D1=80:=20=D1=80=D1=83=D1=87=D0=BD=D0=BE=D0=B5=20=D0=B4=D0=BE?= =?UTF-8?q?=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BC=D0=B0?= =?UTF-8?q?=D1=80=D1=88=D1=80=D1=83=D1=82=D0=BE=D0=B2=20=D1=81=D1=8A=D1=91?= =?UTF-8?q?=D0=BC=D0=BA=D0=B8=20=D0=B8=20=D1=81=D0=B1=D1=80=D0=BE=D1=81?= =?UTF-8?q?=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Режим «Сброс» теперь рабочий: загружает ЗРВ станций (ballisticsApi.fetchSatelliteRva), даёт оператору выбрать зону, затем показывает съёмки плана, заканчивающиеся строго до начала зоны, и сохраняет сброс в плане (missionPlanningApi.saveDropRoute → DropPanel). Старый фейковый DownlinkPanel на stationPasses убран из потока. Режим «Съёмка» сохраняет маршрут через saveSurveyRoute. Времена ЗРВ приходят как LocalDateTime без зоны и трактуются как UTC (helper localDateTimeMs), чтобы сравнивать с временами съёмок. В vite-прокси добавлены pcp-ballistics, pcp-mission и stations. --- .../pcp-tgu-ops-ui/src/api/ballisticsApi.ts | 175 +++++++ .../src/api/missionPlanningApi.ts | 191 ++++++++ .../src/features/tgu-editor/EditorPanels.tsx | 311 ++++++++++++- .../src/features/tgu-editor/EditorToolbar.tsx | 2 +- .../features/tgu-editor/MapPassSelector.tsx | 13 - .../src/features/tgu-editor/TguEditorTab.tsx | 440 +++++++++++++++--- .../features/tgu-editor/model/editorTypes.ts | 10 + .../pcp-tgu-ops-ui/src/styles/tgu-editor.css | 2 +- services/pcp-tgu-ops-ui/vite.config.ts | 14 + 9 files changed, 1069 insertions(+), 89 deletions(-) create mode 100644 services/pcp-tgu-ops-ui/src/api/ballisticsApi.ts create mode 100644 services/pcp-tgu-ops-ui/src/api/missionPlanningApi.ts diff --git a/services/pcp-tgu-ops-ui/src/api/ballisticsApi.ts b/services/pcp-tgu-ops-ui/src/api/ballisticsApi.ts new file mode 100644 index 0000000..d470295 --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/api/ballisticsApi.ts @@ -0,0 +1,175 @@ +import { TguApiError } from "../model/tguTypes"; + +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 new Date(ms).toISOString().slice(0, 19); +} + +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 new file mode 100644 index 0000000..81226bd --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/api/missionPlanningApi.ts @@ -0,0 +1,191 @@ +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; + /** Идентификатор сохранённого режима съёмки в БД (есть только после сохранения). */ + 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[]; +}; + +/** + * Ручное добавление сброса по выбранной зоне радиовидимости + * (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; +} + +async function postSurveyRoute(url: string, params: BuildSurveyRouteParams): 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: "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 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/features/tgu-editor/EditorPanels.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorPanels.tsx index 218fb1f..016ff09 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorPanels.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorPanels.tsx @@ -1,4 +1,9 @@ import { formatDate, formatDateTime, formatDuration, formatTime, kindColor, kindLabel } from "./editorPresentation"; +import { statusStyle } from "../tgu-planning/tguStatus"; +import type { RvaZone } from "../../api/ballisticsApi"; +import type { SurveyRoute, SurveyRouteAnchor } from "../../api/missionPlanningApi"; +import type { Tgu2DMapSelection } from "../tgu-map-2d/model/mapSelectionTypes"; +import type { RequestMapItem } from "../../model/requestTypes"; import type { EditorGroundStation, EditorMode, @@ -32,9 +37,16 @@ type WorkInspectorProps = { type ShootPanelProps = { target?: EditorTarget; passes: EditorVisibilityWindow[]; + loading?: boolean; + error?: string; selectedPass?: EditorVisibilityWindow; modeId?: string; modes: EditorMode[]; + routeAnchor: SurveyRouteAnchor; + routeLoading?: boolean; + routeError?: string; + route?: SurveyRoute; + onAnchorChange: (anchor: SurveyRouteAnchor) => void; onSelectPass: (pass?: EditorVisibilityWindow) => void; onModeChange: (modeId: string) => void; onAdd: () => void; @@ -50,6 +62,35 @@ type DownlinkPanelProps = { 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; + /** Время начала зоны в мс (UTC) — для подписи. */ + zoneStartMs: (zone: RvaZone) => number; + zoneStopMs: (zone: RvaZone) => number; + /** Съёмки-кандидаты для выбранной зоны (end < начало зоны). */ + candidates: DropCandidateSurvey[]; + selectedSurveyIds: Set; + onToggleSurvey: (id: number) => void; + saving?: boolean; + saveError?: string; + onAdd: () => void; + onClose: () => void; +}; + export function WorkInspector({ work, modes, @@ -131,9 +172,16 @@ export function WorkInspector({ export function ShootPanel({ target, passes, + loading, + error, selectedPass, modeId, modes, + routeAnchor, + routeLoading, + routeError, + route, + onAnchorChange, onSelectPass, onModeChange, onAdd, @@ -169,7 +217,11 @@ export function ShootPanel({ {target && (
2 · окно видимости
- {passes.length === 0 ? ( + {loading ? ( +
Запрос параметров видимости…
+ ) : error ? ( +
{error}
+ ) : passes.length === 0 ? (
В окне планирования проходов над целью нет.
) : (
@@ -182,8 +234,11 @@ export function ShootPanel({ type="button" onClick={() => onSelectPass(pass)} > - {formatDate(pass.startMs)} {formatTime(pass.startMs)} - {formatDuration(pass.endMs - pass.startMs)} + + {formatDate(pass.startMs)} {formatTime(pass.startMs)} + {pass.revolution != null && ` · виток ${pass.revolution}`} + + {formatVisibilityParams(pass)} ); })} @@ -205,6 +260,39 @@ export function ShootPanel({
)} + {target && selectedPass && ( +
+
4 · маршрут съёмки
+
+ + +
+ {routeLoading ? ( +
Построение маршрута…
+ ) : routeError ? ( +
{routeError}
+ ) : route ? ( +
+ Маршрут построен · виток {route.revolution} · крен {route.roll.toFixed(1)}° · захват {route.captureAngle.toFixed(1)}° +
+ центр {route.lat.toFixed(2)}, {route.longitude.toFixed(2)} · контур показан на карте +
+ ) : null} +
+ )} + @@ -254,6 +342,223 @@ export function DownlinkPanel({ stationOptions, selected, onSelect, onAdd, onClo ); } +export function DropPanel({ + zones, + loading, + error, + selectedZone, + onSelectZone, + stationName, + zoneStartMs, + zoneStopMs, + candidates, + selectedSurveyIds, + onToggleSurvey, + saving, + saveError, + onAdd, + onClose +}: DropPanelProps) { + const canAdd = !!selectedZone && selectedSurveyIds.size > 0 && !saving; + + 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 ( + + ); + })} +
+ )} +
+ )} + + {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} + + )} +
+ )} +
+ ); +} + +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 new Date(iso).toLocaleString("ru-RU"); +} + function PanelHead({ title, onClose }: { title: string; onClose: () => void }) { return (
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 index e776a79..9feb5b1 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorToolbar.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorToolbar.tsx @@ -13,7 +13,7 @@ type EditorToolbarProps = { dirty: boolean; applying: boolean; windowLabel: string; - rightMode: "none" | "inspect" | "shoot" | "downlink"; + rightMode: "none" | "inspect" | "shoot" | "downlink" | "mapInfo"; onSetRightMode: (mode: "shoot" | "downlink") => void; onUndo: () => void; onRedo: () => void; 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 index eeb061f..bdc29ce 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/MapPassSelector.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/MapPassSelector.tsx @@ -122,19 +122,6 @@ export function MapPassSelector({ passes, fromIndex, toIndex, onFromChange, onTo />
- {/* Labels below handles */} -
-
- {fromRev} - {multiDay && `${utcDM(fromPass.startMs)} `}{utcHM(fromPass.startMs)} -
- {toIndex !== fromIndex && ( -
- {toRev} - {multiDay && `${utcDM(toPass.startMs)} `}{utcHM(toPass.startMs)} -
- )} -
); 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 index c518e0b..4be10f9 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx @@ -1,13 +1,18 @@ 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, targetPasses, type OrbitPassInfo, type PassRange } from "../tgu-map-2d/geometry/passes"; +import { stationPasses, type OrbitPassInfo, type PassRange } from "../tgu-map-2d/geometry/passes"; +import { fetchPointVisibility, fetchSatelliteRva, type PointVisibilityParam, type RvaZone } from "../../api/ballisticsApi"; +import { buildSurveyRoute, saveDropRoute, saveSurveyRoute, type SurveyRoute, type SurveyRouteAnchor } from "../../api/missionPlanningApi"; 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 { MapLine, MapPolygon, Tgu2DMapScene } from "../tgu-map-2d/model/mapTypes"; -import { fetchPlanModes, saveEditedPlan, type PlanMode } from "./editorApi"; +import { fetchPlanModes, saveEditedPlan, type PlanMode, type PlanSurveyMode } from "./editorApi"; import { addDownlink, addShoot, @@ -21,7 +26,7 @@ import { } from "./editorDraft"; import { checkVisibility, conflictCount, mergeConflictMaps, overlaps, type EditorConflictMap } from "./editorConflicts"; import { DEFAULT_EDITOR_MODES, formatDuration } from "./editorPresentation"; -import { DownlinkPanel, type DownlinkSelection, type DownlinkStationOption, ShootPanel, WorkInspector } from "./EditorPanels"; +import { DropPanel, type DropCandidateSurvey, MapInfoPanel, ShootPanel, WorkInspector } from "./EditorPanels"; import { MapPassSelector } from "./MapPassSelector"; import { EditorRail } from "./EditorRail"; import { EditorTimeline } from "./EditorTimeline"; @@ -39,9 +44,17 @@ import type { TguEditorTabProps } from "./model/editorTypes"; -type EditorRightMode = "none" | "inspect" | "shoot" | "downlink"; +type EditorRightMode = "none" | "inspect" | "shoot" | "downlink" | "mapInfo"; const HOUR_MS = 60 * 60 * 1000; +/** Фиксированная длительность окна съёмки, строится вокруг момента видимости. */ +const SHOOT_WINDOW_MS = 6 * 60 * 1000; +/** + * Угол захвата (полуширина полосы) по умолчанию, градусы. + * Пока задаётся на фронте так же, как в текущем построении маршрутов; в дальнейшем + * это значение будет определять бэкенд по профилю наблюдения КА. + */ +const DEFAULT_CAPTURE_ANGLE_DEG = 1.5; const EDITOR_MAP_LAYERS: Tgu2DMapLayersState = { tracks: true, swath: true, @@ -55,7 +68,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, const idCounterRef = useRef(0); const targetCounterRef = useRef(0); const centerRef = useRef(null); - const [mapRatio, setMapRatio] = useState(0.5); + const [mapRatio, setMapRatio] = useState(0.72); const [history, setHistory] = useState(() => ({ draft: seedDraft(selectedPlan, selectedSpacecraftId), past: [], @@ -67,7 +80,15 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, const [target, setTarget] = useState(); const [selectedShootPass, setSelectedShootPass] = useState(); const [modeId, setModeId] = useState(DEFAULT_EDITOR_MODES[0].id); - const [selectedDownlink, setSelectedDownlink] = useState(); + 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 [stationDtos, setStationDtos] = useState([]); const [serviceConflicts, setServiceConflicts] = useState({}); const [checking, setChecking] = useState(false); const [applying, setApplying] = useState(false); @@ -112,14 +133,13 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, const conflicts = useMemo(() => mergeConflictMaps(liveConflicts, serviceConflicts), [liveConflicts, serviceConflicts]); const selectedWork = draft.works.find((work) => work.id === selectedWorkId); const activeLabel = platformLabel(platforms, selectedSpacecraftId); - const shootPasses = useMemo( - () => target ? targetPasses({ spacecraftId: selectedSpacecraftId }, target, { fromMs: window.fromMs, toMs: window.toMs }) : [], - [selectedSpacecraftId, target, window.fromMs, window.toMs] - ); - const stationOptions = useMemo( - () => buildStationOptions(stations, selectedSpacecraftId, window, selectedPlan), - [selectedPlan, selectedSpacecraftId, stations, window] - ); + const [shootPasses, setShootPasses] = useState([]); + const [shootPassesLoading, setShootPassesLoading] = useState(false); + const [shootPassesError, setShootPassesError] = useState(); + const [routeAnchor, setRouteAnchor] = useState("CENTERED"); + 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] @@ -127,8 +147,8 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, 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, stations, requestItems, planModes, effectiveFrom, effectiveTo), - [selectedSpacecraftId, flightLineData, orbitPasses, stations, requestItems, planModes, effectiveFrom, effectiveTo] + () => buildEditorMapScene(selectedSpacecraftId, flightLineData, orbitPasses, stationDtos, requestItems, planModes, effectiveFrom, effectiveTo, surveyRoute), + [selectedSpacecraftId, flightLineData, orbitPasses, stationDtos, requestItems, planModes, effectiveFrom, effectiveTo, surveyRoute] ); const shootTargets = useMemo( () => @@ -146,6 +166,25 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, })), [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]); + const dirty = history.past.length > 0 || draft.works.some((work) => work.isNew); const addedCount = draft.works.filter((work) => work.isNew).length; @@ -156,7 +195,13 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, setRightMode("none"); setTarget(undefined); setSelectedShootPass(undefined); - setSelectedDownlink(undefined); + setSurveyRoute(undefined); + setRouteError(undefined); + setSelectedZone(undefined); + setSelectedDropSurveyIds(new Set()); + setDropError(undefined); + setRvaZones([]); + setMapInfoSelection(undefined); setServiceConflicts({}); setNotice(undefined); setApplyError(undefined); @@ -190,6 +235,125 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, 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]); + + useEffect(() => { + const numericNorad = noradId ? Number(noradId) : NaN; + if (rightMode !== "shoot" || !target || !selectedShootPass || !Number.isFinite(numericNorad)) { + setSurveyRoute(undefined); + setRouteError(undefined); + setRouteLoading(false); + return; + } + + const momentMs = (selectedShootPass.startMs + selectedShootPass.endMs) / 2; + const durationSeconds = Math.max(1, (selectedShootPass.endMs - selectedShootPass.startMs) / 1000); + + let cancelled = false; + setRouteLoading(true); + setRouteError(undefined); + void buildSurveyRoute({ + satelliteId: numericNorad, + time: new Date(momentMs).toISOString().slice(0, 19), + durationSeconds, + roll: selectedShootPass.rollDeg ?? 0, + captureAngle: DEFAULT_CAPTURE_ANGLE_DEG, + revolution: selectedShootPass.revolution ?? 0, + anchor: routeAnchor + }) + .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, routeAnchor]); + + // Зоны радиовидимости (ЗРВ) для ручного сброса — грузим при входе в режим «Сброс». + 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); + 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]); + useEffect(() => { if (!selectedPlan) return; const planId = selectedPlan.planId; @@ -213,9 +377,41 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, changeDraft((current) => updateWork(current, workId, patch)); }; - const addShootWork = () => { + const addShootWork = async () => { if (!target || !selectedShootPass || !modeId) return; + // Персистенция построенного маршрута съёмки в pcp-mission-planing-service по id + // выбранного плана (planId == mission_id). Сохраняем только если план выбран и маршрут + // уже построен; иначе (открыт только КА без плана) добавляем включение без сохранения. + const numericNorad = noradId ? Number(noradId) : NaN; + if (selectedPlan && surveyRoute && Number.isFinite(numericNorad)) { + const planId = selectedPlan.planId; + const momentMs = (selectedShootPass.startMs + selectedShootPass.endMs) / 2; + const routeDurationSeconds = Math.max(1, (selectedShootPass.endMs - selectedShootPass.startMs) / 1000); + setRouteLoading(true); + setRouteError(undefined); + try { + const saved = await saveSurveyRoute(planId, { + satelliteId: numericNorad, + time: new Date(momentMs).toISOString().slice(0, 19), + durationSeconds: routeDurationSeconds, + roll: selectedShootPass.rollDeg ?? 0, + captureAngle: DEFAULT_CAPTURE_ANGLE_DEG, + revolution: selectedShootPass.revolution ?? 0, + anchor: routeAnchor, + // Окно выбранного плана — чтобы при автосоздании миссии/плана задать корректный интервал. + planStart: new Date(selectedPlan.startMs).toISOString().slice(0, 19), + planEnd: new Date(selectedPlan.endMs).toISOString().slice(0, 19) + }); + setSurveyRoute(saved); + } catch (error) { + setRouteError(error instanceof Error ? error.message : "Не удалось сохранить маршрут съёмки."); + return; + } finally { + setRouteLoading(false); + } + } + const durationMs = Math.min(selectedShootPass.endMs - selectedShootPass.startMs, 30 * 60 * 1000); const startMs = selectedShootPass.startMs + Math.floor((selectedShootPass.endMs - selectedShootPass.startMs - durationMs) / 2); const mode = modes.find((item) => item.id === modeId); @@ -237,25 +433,63 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, setSelectedShootPass(undefined); }; - const addDownlinkWork = () => { - if (!selectedDownlink) return; + const stationNameById = useCallback( + (stationId: number) => + stationDtos.find((s) => s.number === stationId)?.name ?? `Станция ${stationId}`, + [stationDtos] + ); - const durationMs = Math.min(selectedDownlink.pass.endMs - selectedDownlink.pass.startMs, 12 * 60 * 1000); - const startMs = selectedDownlink.pass.startMs + Math.floor((selectedDownlink.pass.endMs - selectedDownlink.pass.startMs - durationMs) / 2); + const addDropWork = async () => { + if (!selectedPlan || !selectedZone || selectedDropSurveyIds.size === 0) return; + const numericNorad = noradId ? Number(noradId) : NaN; + if (!Number.isFinite(numericNorad)) { + setDropError("Не задан NORAD-идентификатор КА."); + return; + } + + const planId = selectedPlan.planId; + const zoneStartMsVal = localDateTimeMs(selectedZone.onStart.time); + const zoneStopMsVal = localDateTimeMs(selectedZone.onStop.time); + + setDropSaving(true); + setDropError(undefined); + try { + await saveDropRoute(planId, { + 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: new Date(selectedPlan.startMs).toISOString().slice(0, 19), + planEnd: new Date(selectedPlan.endMs).toISOString().slice(0, 19) + }); + } catch (error) { + setDropError(error instanceof Error ? error.message : "Не удалось сохранить сброс."); + return; + } finally { + setDropSaving(false); + } + + // Обновляем включения плана и добавляем работу-сброс в черновик для отображения на таймлайне. + void fetchPlanModes(planId).then(setPlanModes).catch(() => {}); + const stationNm = stationNameById(selectedZone.stationId); const id = nextWorkId(idCounterRef); - changeDraft((current) => addDownlink(current, { id, - startMs, - endMs: startMs + durationMs, - label: "Сброс на НКПОР", - station: selectedDownlink.station + startMs: zoneStartMsVal, + endMs: zoneStopMsVal, + label: `Сброс · ${stationNm}`, + station: { id: String(selectedZone.stationId), name: stationNm } }) ); setSelectedWorkId(id); setRightMode("inspect"); - setSelectedDownlink(undefined); + setSelectedZone(undefined); + setSelectedDropSurveyIds(new Set()); }; const runCheck = () => { @@ -302,6 +536,20 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, }); }; + const handleMapObjectSelect = useCallback((selection?: Tgu2DMapSelection) => { + if (rightMode === "shoot") return; + if (!selection) { + if (rightMode === "mapInfo") { + setRightMode("none"); + setMapInfoSelection(undefined); + } + return; + } + setMapInfoSelection(selection); + setRightMode("mapInfo"); + setSelectedWorkId(undefined); + }, [rightMode]); + const handleResizerDown = useCallback((e: React.MouseEvent) => { e.preventDefault(); const startY = e.clientY; @@ -349,6 +597,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, onSetRightMode={(mode) => { setRightMode(mode); setSelectedWorkId(undefined); + setMapInfoSelection(undefined); setNotice(undefined); }} onUndo={() => setHistory((current) => undo(current))} @@ -426,7 +675,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, setModeId(DEFAULT_EDITOR_MODES[0].id); setNotice(undefined); }} - onObjectSelect={() => undefined} + onObjectSelect={handleMapObjectSelect} />
@@ -484,9 +733,16 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, )} {rightMode === "downlink" && ( - { + setSelectedZone(zone); + setSelectedDropSurveyIds(new Set()); + setDropError(undefined); + }} + stationName={stationNameById} + zoneStartMs={(zone) => localDateTimeMs(zone.onStart.time)} + zoneStopMs={(zone) => localDateTimeMs(zone.onStop.time)} + candidates={dropCandidates} + selectedSurveyIds={selectedDropSurveyIds} + onToggleSurvey={(id) => + setSelectedDropSurveyIds((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }) + } + saving={dropSaving} + saveError={dropError} + onAdd={addDropWork} onClose={() => { setRightMode("none"); - setSelectedDownlink(undefined); + setSelectedZone(undefined); + setSelectedDropSurveyIds(new Set()); + setDropError(undefined); + }} + /> + )} + {rightMode === "mapInfo" && mapInfoSelection && ( + { + setRightMode("none"); + setMapInfoSelection(undefined); }} /> )} @@ -520,6 +809,21 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, ); } +function visibilityWindowFromParam(param: PointVisibilityParam): EditorVisibilityWindow { + const timeMs = Date.parse(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"] @@ -542,30 +846,13 @@ function buildStations(selectedPlan: TguEditorTabProps["selectedPlan"]): EditorG return [{ id: selectedPlan.kppId, name: selectedPlan.kppId }]; } -function buildStationOptions( - stations: EditorGroundStation[], - spacecraftId: string | undefined, - window: EditorTimelineWindow, - selectedPlan: TguEditorTabProps["selectedPlan"] -): DownlinkStationOption[] { - return stations.map((station) => { - const computedPasses = - station.lat !== undefined && station.lon !== undefined - ? stationPasses({ spacecraftId }, { lat: station.lat, lon: station.lon }, { fromMs: window.fromMs, toMs: window.toMs }) - : []; - const fallbackPass = selectedPlan && selectedPlan.kppId === station.id - ? [{ - startMs: Math.max(window.fromMs, selectedPlan.startMs), - endMs: Math.min(window.toMs, selectedPlan.endMs), - quality: 0 - }] - : []; - - return { - station, - passes: computedPasses.length > 0 ? computedPasses : fallbackPass.filter((pass) => pass.endMs > pass.startMs) - }; - }); +/** + * LocalDateTime без зоны от бэка трактуем как UTC (как и поле `time` в ballisticsApi): + * зоны ЗРВ приходят без таймзоны, а времена съёмок — с offset; приводим к одной шкале. + */ +function localDateTimeMs(s: string): number { + const hasZone = /[zZ]$|[+-]\d{2}:?\d{2}$/.test(s); + return Date.parse(hasZone ? s : `${s}Z`); } function buildVisibilityTracks( @@ -609,11 +896,12 @@ function buildEditorMapScene( spacecraftId: string | undefined, flightLineData: FlightLinePoint[], orbitPasses: OrbitPassInfo[], - stations: EditorGroundStation[], + stationDtos: StationDto[], requestItems: RequestMapItem[], planModes: PlanMode[], fromIndex: number, - toIndex: number + toIndex: number, + surveyRoute?: SurveyRoute ): Tgu2DMapScene { const visibleRevolutions = new Set( orbitPasses.filter((p) => p.index >= fromIndex && p.index <= toIndex).map((p) => p.revolution!) @@ -689,16 +977,26 @@ function buildEditorMapScene( } } - const markers = stations.flatMap((station) => { - if (station.lat === undefined || station.lon === undefined) return []; - return [{ - id: station.id, - layer: "stations" as const, - point: { lat: station.lat, lon: station.lon }, - label: station.name, - zIndex: 10 - }]; - }); + // Построенный (но ещё не сохранённый) маршрут съёмки — показываем поверх работ плана + // отдельным цветом (статус 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 }; } 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 index af6c0a5..c1bfa97 100644 --- 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 @@ -64,6 +64,16 @@ export type EditorVisibilityWindow = { startMs: number; endMs: number; quality: number; + /** Номер витка (если окно получено из баллистики). */ + revolution?: number; + /** Крен, град. */ + rollDeg?: number; + /** Угол Солнца, град. */ + sunAngleDeg?: number; + /** Наклонная дальность. */ + range?: number; + /** Направление витка. */ + revSign?: "ASC" | "DESC"; }; export type EditorVisibilityTrack = { diff --git a/services/pcp-tgu-ops-ui/src/styles/tgu-editor.css b/services/pcp-tgu-ops-ui/src/styles/tgu-editor.css index ab4094a..0361f45 100644 --- a/services/pcp-tgu-ops-ui/src/styles/tgu-editor.css +++ b/services/pcp-tgu-ops-ui/src/styles/tgu-editor.css @@ -252,7 +252,7 @@ .map-pass-selector__header { display: flex; - align-items: center; + align-items: baseline; gap: 0.45rem; padding: 0.4rem 0.65rem; } diff --git a/services/pcp-tgu-ops-ui/vite.config.ts b/services/pcp-tgu-ops-ui/vite.config.ts index 044cdfe..7d9d868 100644 --- a/services/pcp-tgu-ops-ui/vite.config.ts +++ b/services/pcp-tgu-ops-ui/vite.config.ts @@ -12,6 +12,20 @@ export default defineConfig({ 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/stations": { + target: "http://localhost:7009", + changeOrigin: true + }, "/api": { target: "http://localhost:7008", changeOrigin: true