From 240e12f442d31d2cbfacf15ae4ae4cc64949be95 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, 2 Jun 2026 22:06:06 +0300 Subject: [PATCH] =?UTF-8?q?pcp-tgu-ops-ui:=20=D0=B2=D0=BA=D0=BB=D0=B0?= =?UTF-8?q?=D0=B4=D0=BA=D0=B0=20=D0=A0=D0=B5=D0=B4=D0=B0=D0=BA=D1=82=D0=BE?= =?UTF-8?q?=D1=80=20=E2=80=94=20=D1=88=D0=B0=D0=B3=20=D0=B2=D1=80=D0=B5?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D0=B8=20=D0=BC=D0=B0=D1=80=D1=88=D1=80=D1=83?= =?UTF-8?q?=D1=82=D0=B0=20=D0=B8=20=D1=84=D0=BE=D1=80=D0=BC=D0=B0=D1=82=20?= =?UTF-8?q?=D0=B4=D0=BB=D0=B8=D1=82=D0=B5=D0=BB=D1=8C=D0=BD=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RouteTimeStepper и доработки EditorPanels/TguEditorTab по вводу времени; - formatDuration: секунды/минуты для коротких интервалов; - стили tgu-editor.css. --- .../src/features/tgu-editor/EditorPanels.tsx | 149 +++++++++++++++--- .../src/features/tgu-editor/TguEditorTab.tsx | 92 ++++++++--- .../features/tgu-editor/editorPresentation.ts | 10 ++ .../pcp-tgu-ops-ui/src/styles/tgu-editor.css | 40 ++++- 4 files changed, 248 insertions(+), 43 deletions(-) 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 6219284..ead6a14 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 @@ -2,7 +2,8 @@ import { formatDate, formatDateTime, formatDuration, formatTime, kindColor, kind import { statusStyle } from "../tgu-planning/tguStatus"; import { formatUtcDateTime, parseUtc } from "../../utils/utcTime"; import type { RvaZone } from "../../api/ballisticsApi"; -import type { SurveyRoute, SurveyRouteAnchor } from "../../api/missionPlanningApi"; +import type { ChangeEvent } from "react"; +import type { SurveyRoute } from "../../api/missionPlanningApi"; import type { Tgu2DMapSelection } from "../tgu-map-2d/model/mapSelectionTypes"; import type { RequestMapItem } from "../../model/requestTypes"; import type { @@ -45,11 +46,27 @@ type ShootPanelProps = { selectedPass?: EditorVisibilityWindow; modeId?: string; modes: EditorMode[]; - routeAnchor: SurveyRouteAnchor; + /** Окно строящегося маршрута (UTC, мс). undefined пока не выбран проход. */ + routeWindow?: { startMs: number; endMs: number }; + /** Текущий крен (gamma) маршрута, градусы. */ + routeRoll?: number; + /** Окно видимости выбранного прохода — для подписи длительности. */ + passStartMs?: number; + passEndMs?: number; + /** Допустимый диапазон времени окна маршрута (±30 мин от окна видимости), мс. */ + routeMinMs?: number; + routeMaxMs?: number; routeLoading?: boolean; routeError?: string; route?: SurveyRoute; - onAnchorChange: (anchor: SurveyRouteAnchor) => void; + 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; @@ -57,6 +74,9 @@ type ShootPanelProps = { onClearTarget: () => void; }; +/** Минимальная длительность маршрута, мс (нижняя граница окна). */ +const MIN_ROUTE_DURATION_MS = 5_000; + type DownlinkPanelProps = { stationOptions: DownlinkStationOption[]; selected?: DownlinkSelection; @@ -204,11 +224,20 @@ export function ShootPanel({ selectedPass, modeId, modes, - routeAnchor, + routeWindow, + routeRoll, + passStartMs, + passEndMs, + routeMinMs, + routeMaxMs, routeLoading, routeError, route, - onAnchorChange, + onRouteStartChange, + onRouteEndChange, + onRouteShift, + onRouteRollChange, + onRouteRollSet, onSelectPass, onModeChange, onAdd, @@ -287,25 +316,57 @@ export function ShootPanel({ )} - {target && selectedPass && ( + {target && selectedPass && routeWindow && (
-
4 · маршрут съёмки
-
- - +
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(deg); + }} + /> + + +
+
+ {routeLoading ? (
Построение маршрута…
) : routeError ? ( @@ -628,3 +689,47 @@ function TimeStepper({ ); } + +/** + * Степпер времени окна маршрута с секундной точностью: кнопки ±5 c / ±30 c и ручной + * ввод времени `чч:мм:сс` (UTC, без даты — дата берётся из текущего значения). + * Значение зажимается в [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 onInput = (event: ChangeEvent) => { + const raw = event.target.value; + if (!raw) return; + // Ввод — только время суток; день оставляем прежним (UTC). + const datePart = new Date(value).toISOString().slice(0, 10); + const ms = parseUtc(`${datePart}T${raw}`); + if (Number.isFinite(ms)) onChange(clamp(ms)); + }; + + return ( + + ); +} 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 0919a4a..2ecc09a 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 @@ -5,7 +5,7 @@ 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, saveDropRoute, saveSurveyRoute, type SurveyRoute, type SurveyRouteAnchor } from "../../api/missionPlanningApi"; +import { buildSurveyRoute, saveDropRoute, saveSurveyRoute, type SurveyRoute } from "../../api/missionPlanningApi"; import { parseUtc } from "../../utils/utcTime"; import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView"; import { parseWktPolygon } from "../tgu-map-2d/geometry/wktParser"; @@ -58,6 +58,12 @@ const SHOOT_WINDOW_MS = 6 * 60 * 1000; * это значение будет определять бэкенд по профилю наблюдения КА. */ const DEFAULT_CAPTURE_ANGLE_DEG = 1.5; +/** Дефолтная длительность строящегося маршрута съёмки (мс). */ +const DEFAULT_ROUTE_DURATION_MS = 30_000; +/** Предел крена (gamma) маршрута при ручной правке, градусы. */ +const MAX_ROUTE_ROLL_DEG = 89; +/** Допустимый выход окна маршрута за пределы окна видимости, мс (±30 минут). */ +const ROUTE_TIME_MARGIN_MS = 30 * 60_000; const EDITOR_MAP_LAYERS: Tgu2DMapLayersState = { tracks: true, swath: true, @@ -144,7 +150,10 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, const [shootPasses, setShootPasses] = useState([]); const [shootPassesLoading, setShootPassesLoading] = useState(false); const [shootPassesError, setShootPassesError] = useState(); - const [routeAnchor, setRouteAnchor] = useState("CENTERED"); + // Окно строящегося маршрута съёмки: задаётся вручную в правой панели (начало/конец, UTC). + 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(); @@ -292,29 +301,44 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, }; }, [rightMode, target, noradId, window.fromMs, window.toMs]); + // При выборе нового прохода задаём дефолтное окно маршрута: 30 c вокруг момента + // видимости, обрезанное окном видимости прохода. Дальше пользователь правит вручную. + useEffect(() => { + if (rightMode !== "shoot" || !selectedShootPass) { + setRouteWindow(undefined); + setRouteRoll(undefined); + 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(selectedShootPass.rollDeg ?? 0); + }, [rightMode, selectedShootPass]); + useEffect(() => { const numericNorad = noradId ? Number(noradId) : NaN; - if (rightMode !== "shoot" || !target || !selectedShootPass || !Number.isFinite(numericNorad)) { + if (rightMode !== "shoot" || !target || !selectedShootPass || !routeWindow || routeRoll == null || !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); + const durationSeconds = Math.max(1, (routeWindow.endMs - routeWindow.startMs) / 1000); let cancelled = false; setRouteLoading(true); setRouteError(undefined); void buildSurveyRoute({ satelliteId: numericNorad, - time: new Date(momentMs).toISOString().slice(0, 19), + // Окно задано явно (начало/конец) — строим вперёд от начала на выбранную длительность. + time: new Date(routeWindow.startMs).toISOString().slice(0, 19), durationSeconds, - roll: selectedShootPass.rollDeg ?? 0, + roll: routeRoll, captureAngle: DEFAULT_CAPTURE_ANGLE_DEG, revolution: selectedShootPass.revolution ?? 0, - anchor: routeAnchor + anchor: "FORWARD" }) .then((route) => { if (!cancelled) setSurveyRoute(route); @@ -330,7 +354,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, return () => { cancelled = true; }; - }, [rightMode, target, selectedShootPass, noradId, routeAnchor]); + }, [rightMode, target, selectedShootPass, noradId, routeWindow, routeRoll]); // Зоны радиовидимости (ЗРВ) для ручного сброса — грузим при входе в режим «Сброс». useEffect(() => { @@ -396,21 +420,20 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, // выбранного плана (planId == mission_id). Сохраняем только если план выбран и маршрут // уже построен; иначе (открыт только КА без плана) добавляем включение без сохранения. const numericNorad = noradId ? Number(noradId) : NaN; - if (selectedPlan && surveyRoute && Number.isFinite(numericNorad)) { + if (selectedPlan && surveyRoute && routeWindow && routeRoll != null && Number.isFinite(numericNorad)) { const planId = selectedPlan.planId; - const momentMs = (selectedShootPass.startMs + selectedShootPass.endMs) / 2; - const routeDurationSeconds = Math.max(1, (selectedShootPass.endMs - selectedShootPass.startMs) / 1000); + const routeDurationSeconds = Math.max(1, (routeWindow.endMs - routeWindow.startMs) / 1000); setRouteLoading(true); setRouteError(undefined); try { const saved = await saveSurveyRoute(planId, { satelliteId: numericNorad, - time: new Date(momentMs).toISOString().slice(0, 19), + time: new Date(routeWindow.startMs).toISOString().slice(0, 19), durationSeconds: routeDurationSeconds, - roll: selectedShootPass.rollDeg ?? 0, + roll: routeRoll, captureAngle: DEFAULT_CAPTURE_ANGLE_DEG, revolution: selectedShootPass.revolution ?? 0, - anchor: routeAnchor, + anchor: "FORWARD", // Окно выбранного плана — чтобы при автосоздании миссии/плана задать корректный интервал. planStart: new Date(selectedPlan.startMs).toISOString().slice(0, 19), planEnd: new Date(selectedPlan.endMs).toISOString().slice(0, 19) @@ -424,8 +447,9 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, } } - const durationMs = Math.min(selectedShootPass.endMs - selectedShootPass.startMs, 30 * 60 * 1000); - const startMs = selectedShootPass.startMs + Math.floor((selectedShootPass.endMs - selectedShootPass.startMs - durationMs) / 2); + // Окно включения в черновике — заданное вручную окно маршрута (фолбэк — окно прохода). + const startMs = routeWindow?.startMs ?? selectedShootPass.startMs; + const endMs = routeWindow?.endMs ?? selectedShootPass.endMs; const mode = modes.find((item) => item.id === modeId); const id = nextWorkId(idCounterRef); @@ -433,7 +457,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, addShoot(current, { id, startMs, - endMs: startMs + durationMs, + endMs, label: mode?.label ?? "Съёмка", modeId, target @@ -752,11 +776,39 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, selectedPass={selectedShootPass} modeId={modeId} modes={modes} - routeAnchor={routeAnchor} + routeWindow={routeWindow} + routeRoll={routeRoll} + passStartMs={selectedShootPass?.startMs} + passEndMs={selectedShootPass?.endMs} + routeMinMs={selectedShootPass != null ? selectedShootPass.startMs - ROUTE_TIME_MARGIN_MS : undefined} + routeMaxMs={selectedShootPass != null ? selectedShootPass.endMs + ROUTE_TIME_MARGIN_MS : undefined} routeLoading={routeLoading} routeError={routeError} route={surveyRoute} - onAnchorChange={setRouteAnchor} + onRouteStartChange={(startMs) => setRouteWindow((current) => (current ? { ...current, startMs } : current))} + onRouteEndChange={(endMs) => setRouteWindow((current) => (current ? { ...current, endMs } : current))} + onRouteShift={(deltaMs) => + setRouteWindow((current) => { + if (!current || selectedShootPass == null) return current; + // Двигаем окно целиком, сохраняя длительность; не выходим за ±30 мин от окна видимости. + const minMs = selectedShootPass.startMs - ROUTE_TIME_MARGIN_MS; + const maxMs = selectedShootPass.endMs + ROUTE_TIME_MARGIN_MS; + let shift = deltaMs; + if (current.endMs + shift > maxMs) shift = maxMs - current.endMs; + if (current.startMs + shift < minMs) shift = minMs - current.startMs; + return { startMs: current.startMs + shift, endMs: current.endMs + shift }; + }) + } + onRouteRollChange={(deltaDeg) => + setRouteRoll((current) => + Math.max(-MAX_ROUTE_ROLL_DEG, Math.min(MAX_ROUTE_ROLL_DEG, (current ?? 0) + deltaDeg)) + ) + } + onRouteRollSet={(deg) => + setRouteRoll((current) => + Number.isFinite(deg) ? Math.max(-MAX_ROUTE_ROLL_DEG, Math.min(MAX_ROUTE_ROLL_DEG, deg)) : current + ) + } onSelectPass={setSelectedShootPass} onModeChange={setModeId} onAdd={addShootWork} 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 index 17a93f2..e387e45 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorPresentation.ts +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorPresentation.ts @@ -37,6 +37,16 @@ export function formatDateTime(ms: number): string { } 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)} ч`; 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 ab64a8e..01f341e 100644 --- a/services/pcp-tgu-ops-ui/src/styles/tgu-editor.css +++ b/services/pcp-tgu-ops-ui/src/styles/tgu-editor.css @@ -910,6 +910,18 @@ 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); @@ -975,7 +987,8 @@ font-size: 0.62rem; } -.tgu-editor-time-stepper output { +.tgu-editor-time-stepper output, +.tgu-editor-time-stepper input { flex: 1; min-width: 0; border: 1px solid var(--line); @@ -988,6 +1001,31 @@ 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;