pcp-tgu-ops-ui: вкладка Редактор — шаг времени маршрута и формат длительности

- RouteTimeStepper и доработки EditorPanels/TguEditorTab по вводу времени;
- formatDuration: секунды/минуты для коротких интервалов;
- стили tgu-editor.css.
This commit is contained in:
Дмитрий Соловьев
2026-06-02 22:06:06 +03:00
parent c2e2b2922f
commit 240e12f442
4 changed files with 248 additions and 43 deletions
@@ -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<EditorVisibilityWindow[]>([]);
const [shootPassesLoading, setShootPassesLoading] = useState(false);
const [shootPassesError, setShootPassesError] = useState<string>();
const [routeAnchor, setRouteAnchor] = useState<SurveyRouteAnchor>("CENTERED");
// Окно строящегося маршрута съёмки: задаётся вручную в правой панели (начало/конец, UTC).
const [routeWindow, setRouteWindow] = useState<{ startMs: number; endMs: number }>();
// Крен (gamma) строящегося маршрута, градусы. По умолчанию — из прохода, дальше правится вручную.
const [routeRoll, setRouteRoll] = useState<number>();
const [surveyRoute, setSurveyRoute] = useState<SurveyRoute>();
const [routeLoading, setRouteLoading] = useState(false);
const [routeError, setRouteError] = useState<string>();
@@ -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}