Редактор: ручное добавление маршрутов съёмки и сбросов
Режим «Сброс» теперь рабочий: загружает ЗРВ станций (ballisticsApi.fetchSatelliteRva), даёт оператору выбрать зону, затем показывает съёмки плана, заканчивающиеся строго до начала зоны, и сохраняет сброс в плане (missionPlanningApi.saveDropRoute → DropPanel). Старый фейковый DownlinkPanel на stationPasses убран из потока. Режим «Съёмка» сохраняет маршрут через saveSurveyRoute. Времена ЗРВ приходят как LocalDateTime без зоны и трактуются как UTC (helper localDateTimeMs), чтобы сравнивать с временами съёмок. В vite-прокси добавлены pcp-ballistics, pcp-mission и stations.
This commit is contained in:
@@ -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<PointVisibilityParam[]> {
|
||||||
|
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<RvaZone[]> {
|
||||||
|
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<string, unknown>;
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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<SurveyRoute> {
|
||||||
|
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<SurveyRoute> {
|
||||||
|
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<DropMode> {
|
||||||
|
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<SurveyRoute> {
|
||||||
|
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<string, unknown>;
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -1,4 +1,9 @@
|
|||||||
import { formatDate, formatDateTime, formatDuration, formatTime, kindColor, kindLabel } from "./editorPresentation";
|
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 {
|
import type {
|
||||||
EditorGroundStation,
|
EditorGroundStation,
|
||||||
EditorMode,
|
EditorMode,
|
||||||
@@ -32,9 +37,16 @@ type WorkInspectorProps = {
|
|||||||
type ShootPanelProps = {
|
type ShootPanelProps = {
|
||||||
target?: EditorTarget;
|
target?: EditorTarget;
|
||||||
passes: EditorVisibilityWindow[];
|
passes: EditorVisibilityWindow[];
|
||||||
|
loading?: boolean;
|
||||||
|
error?: string;
|
||||||
selectedPass?: EditorVisibilityWindow;
|
selectedPass?: EditorVisibilityWindow;
|
||||||
modeId?: string;
|
modeId?: string;
|
||||||
modes: EditorMode[];
|
modes: EditorMode[];
|
||||||
|
routeAnchor: SurveyRouteAnchor;
|
||||||
|
routeLoading?: boolean;
|
||||||
|
routeError?: string;
|
||||||
|
route?: SurveyRoute;
|
||||||
|
onAnchorChange: (anchor: SurveyRouteAnchor) => void;
|
||||||
onSelectPass: (pass?: EditorVisibilityWindow) => void;
|
onSelectPass: (pass?: EditorVisibilityWindow) => void;
|
||||||
onModeChange: (modeId: string) => void;
|
onModeChange: (modeId: string) => void;
|
||||||
onAdd: () => void;
|
onAdd: () => void;
|
||||||
@@ -50,6 +62,35 @@ type DownlinkPanelProps = {
|
|||||||
onClose: () => 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;
|
||||||
|
/** Время начала зоны в мс (UTC) — для подписи. */
|
||||||
|
zoneStartMs: (zone: RvaZone) => number;
|
||||||
|
zoneStopMs: (zone: RvaZone) => number;
|
||||||
|
/** Съёмки-кандидаты для выбранной зоны (end < начало зоны). */
|
||||||
|
candidates: DropCandidateSurvey[];
|
||||||
|
selectedSurveyIds: Set<number>;
|
||||||
|
onToggleSurvey: (id: number) => void;
|
||||||
|
saving?: boolean;
|
||||||
|
saveError?: string;
|
||||||
|
onAdd: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
export function WorkInspector({
|
export function WorkInspector({
|
||||||
work,
|
work,
|
||||||
modes,
|
modes,
|
||||||
@@ -131,9 +172,16 @@ export function WorkInspector({
|
|||||||
export function ShootPanel({
|
export function ShootPanel({
|
||||||
target,
|
target,
|
||||||
passes,
|
passes,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
selectedPass,
|
selectedPass,
|
||||||
modeId,
|
modeId,
|
||||||
modes,
|
modes,
|
||||||
|
routeAnchor,
|
||||||
|
routeLoading,
|
||||||
|
routeError,
|
||||||
|
route,
|
||||||
|
onAnchorChange,
|
||||||
onSelectPass,
|
onSelectPass,
|
||||||
onModeChange,
|
onModeChange,
|
||||||
onAdd,
|
onAdd,
|
||||||
@@ -169,7 +217,11 @@ export function ShootPanel({
|
|||||||
{target && (
|
{target && (
|
||||||
<section className="tgu-editor-panel__section">
|
<section className="tgu-editor-panel__section">
|
||||||
<div className="tgu-editor-eyebrow">2 · окно видимости</div>
|
<div className="tgu-editor-eyebrow">2 · окно видимости</div>
|
||||||
{passes.length === 0 ? (
|
{loading ? (
|
||||||
|
<div className="tgu-editor-panel__hint">Запрос параметров видимости…</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="tgu-alert tgu-alert--danger tgu-alert--compact">{error}</div>
|
||||||
|
) : passes.length === 0 ? (
|
||||||
<div className="tgu-editor-panel__hint">В окне планирования проходов над целью нет.</div>
|
<div className="tgu-editor-panel__hint">В окне планирования проходов над целью нет.</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="tgu-editor-pass-list">
|
<div className="tgu-editor-pass-list">
|
||||||
@@ -182,8 +234,11 @@ export function ShootPanel({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => onSelectPass(pass)}
|
onClick={() => onSelectPass(pass)}
|
||||||
>
|
>
|
||||||
<span>{formatDate(pass.startMs)} {formatTime(pass.startMs)}</span>
|
<span>
|
||||||
<small>{formatDuration(pass.endMs - pass.startMs)}</small>
|
{formatDate(pass.startMs)} {formatTime(pass.startMs)}
|
||||||
|
{pass.revolution != null && ` · виток ${pass.revolution}`}
|
||||||
|
</span>
|
||||||
|
<small>{formatVisibilityParams(pass)}</small>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -205,6 +260,39 @@ export function ShootPanel({
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{target && selectedPass && (
|
||||||
|
<section className="tgu-editor-panel__section">
|
||||||
|
<div className="tgu-editor-eyebrow">4 · маршрут съёмки</div>
|
||||||
|
<div className="tgu-editor-chip-row">
|
||||||
|
<button
|
||||||
|
className={routeAnchor === "CENTERED" ? "is-selected" : ""}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onAnchorChange("CENTERED")}
|
||||||
|
>
|
||||||
|
вокруг момента
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={routeAnchor === "FORWARD" ? "is-selected" : ""}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onAnchorChange("FORWARD")}
|
||||||
|
>
|
||||||
|
от момента вперёд
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{routeLoading ? (
|
||||||
|
<div className="tgu-editor-panel__hint">Построение маршрута…</div>
|
||||||
|
) : routeError ? (
|
||||||
|
<div className="tgu-alert tgu-alert--danger tgu-alert--compact">{routeError}</div>
|
||||||
|
) : route ? (
|
||||||
|
<div className="tgu-editor-panel__hint">
|
||||||
|
Маршрут построен · виток {route.revolution} · крен {route.roll.toFixed(1)}° · захват {route.captureAngle.toFixed(1)}°
|
||||||
|
<br />
|
||||||
|
центр {route.lat.toFixed(2)}, {route.longitude.toFixed(2)} · контур показан на карте
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
<button className="tgu-button tgu-button--primary" type="button" onClick={onAdd} disabled={!target || !selectedPass || !modeId}>
|
<button className="tgu-button tgu-button--primary" type="button" onClick={onAdd} disabled={!target || !selectedPass || !modeId}>
|
||||||
+ Добавить съёмку в план
|
+ Добавить съёмку в план
|
||||||
</button>
|
</button>
|
||||||
@@ -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 (
|
||||||
|
<div className="tgu-editor-panel">
|
||||||
|
<PanelHead title="Новый сброс по ЗРВ" onClose={onClose} />
|
||||||
|
<div className="tgu-editor-panel__hint">
|
||||||
|
Выберите зону радиовидимости станции, затем маршруты съёмки, которые успевают завершиться до начала зоны.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && <div className="tgu-editor-panel__hint">Загрузка зон радиовидимости…</div>}
|
||||||
|
{error && <div className="tgu-alert tgu-alert--danger">{error}</div>}
|
||||||
|
{!loading && !error && zones.length === 0 && (
|
||||||
|
<div className="tgu-editor-panel__hint">Нет зон радиовидимости в текущем окне.</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{zones.length > 0 && (
|
||||||
|
<div className="tgu-editor-station-list">
|
||||||
|
{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 (
|
||||||
|
<button
|
||||||
|
className={isSelected ? "is-selected" : ""}
|
||||||
|
key={`${zone.stationId}-${zone.revolution}-${zone.onStart.time}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelectZone(isSelected ? undefined : zone)}
|
||||||
|
>
|
||||||
|
<span>{stationName(zone.stationId)} · виток {zone.revolution}</span>
|
||||||
|
<small>
|
||||||
|
{formatDateTime(startMs)}–{formatTime(stopMs)} · {formatDuration(stopMs - startMs)}
|
||||||
|
</small>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedZone && (
|
||||||
|
<section className="tgu-editor-panel__section">
|
||||||
|
<div className="tgu-editor-eyebrow">Съёмки для сброса</div>
|
||||||
|
{candidates.length === 0 ? (
|
||||||
|
<div className="tgu-editor-panel__hint">Нет съёмок, завершающихся до начала выбранной зоны.</div>
|
||||||
|
) : (
|
||||||
|
<div className="tgu-editor-station-list">
|
||||||
|
{candidates.map((survey) => {
|
||||||
|
const checked = selectedSurveyIds.has(survey.id);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={checked ? "is-selected" : ""}
|
||||||
|
key={survey.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onToggleSurvey(survey.id)}
|
||||||
|
>
|
||||||
|
<span>{checked ? "☑" : "☐"} {survey.label}</span>
|
||||||
|
<small>
|
||||||
|
{formatDateTime(survey.startMs)}–{formatTime(survey.endMs)}
|
||||||
|
</small>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{saveError && <div className="tgu-alert tgu-alert--danger">{saveError}</div>}
|
||||||
|
|
||||||
|
<button className="tgu-button tgu-button--primary" type="button" onClick={onAdd} disabled={!canAdd}>
|
||||||
|
{saving ? "Сохранение…" : "+ Добавить сброс в план"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="tgu-editor-panel">
|
||||||
|
<PanelHead title={title} onClose={onClose} />
|
||||||
|
|
||||||
|
{selection.type === "marker" && selection.layer === "stations" && (
|
||||||
|
<section className="tgu-editor-panel__section">
|
||||||
|
<div className="tgu-editor-eyebrow">{selection.name}</div>
|
||||||
|
<dl className="tgu-map-request-details__grid">
|
||||||
|
<dt>Номер</dt>
|
||||||
|
<dd className="mono">{selection.stationNumber ?? "—"}</dd>
|
||||||
|
<dt>Широта</dt>
|
||||||
|
<dd className="mono">{selection.lat != null ? `${selection.lat.toFixed(4)}°` : "—"}</dd>
|
||||||
|
<dt>Долгота</dt>
|
||||||
|
<dd className="mono">{selection.lon != null ? `${selection.lon.toFixed(4)}°` : "—"}</dd>
|
||||||
|
<dt>Высота, м</dt>
|
||||||
|
<dd className="mono">{selection.altitude != null ? selection.altitude.toFixed(1) : "—"}</dd>
|
||||||
|
<dt>Угол места мин, °</dt>
|
||||||
|
<dd className="mono">{selection.elevationMin != null ? selection.elevationMin.toFixed(1) : "—"}</dd>
|
||||||
|
<dt>Угол места макс, °</dt>
|
||||||
|
<dd className="mono">{selection.elevationMax != null ? selection.elevationMax.toFixed(1) : "—"}</dd>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selection.type === "polygon" && selection.layer === "requests" && (
|
||||||
|
requestItem ? (
|
||||||
|
<section className="tgu-editor-panel__section">
|
||||||
|
<dl className="tgu-map-request-details__grid">
|
||||||
|
<dt>ID</dt>
|
||||||
|
<dd className="mono">{requestItem.id}</dd>
|
||||||
|
<dt>Название</dt>
|
||||||
|
<dd>{requestItem.name || "—"}</dd>
|
||||||
|
<dt>Статус</dt>
|
||||||
|
<dd>{requestItem.status}</dd>
|
||||||
|
<dt>Тип съёмки</dt>
|
||||||
|
<dd>{surveyTypeLabel(requestItem.surveyType)}</dd>
|
||||||
|
<dt>Начало</dt>
|
||||||
|
<dd className="mono">{formatIso(requestItem.beginDateTime)}</dd>
|
||||||
|
<dt>Конец</dt>
|
||||||
|
<dd className="mono">{formatIso(requestItem.endDateTime)}</dd>
|
||||||
|
<dt>Важность</dt>
|
||||||
|
<dd className="mono">{requestItem.importance}</dd>
|
||||||
|
<dt>Покрытие</dt>
|
||||||
|
<dd className="mono">{requestItem.coveragePercent != null ? `${requestItem.coveragePercent.toFixed(1)} %` : "—"}</dd>
|
||||||
|
<dt>КПП</dt>
|
||||||
|
<dd className="mono">{requestItem.kpp?.join(", ") || "—"}</dd>
|
||||||
|
<dt>Приоритет передачи</dt>
|
||||||
|
<dd>{requestItem.highPriorityTransmit ? "Да" : "Нет"}</dd>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
) : (
|
||||||
|
<div className="tgu-editor-panel__hint">{selection.name || selection.id}</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selection.type === "polygon" && selection.layer === "planWorks" && (
|
||||||
|
<section className="tgu-editor-panel__section">
|
||||||
|
<div className="tgu-editor-eyebrow">{selection.name}</div>
|
||||||
|
{selection.status && (
|
||||||
|
<span className="tgu-map-status-pill">
|
||||||
|
<i style={{ backgroundColor: statusStyle(selection.status).color }} />
|
||||||
|
{statusStyle(selection.status).label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }) {
|
function PanelHead({ title, onClose }: { title: string; onClose: () => void }) {
|
||||||
return (
|
return (
|
||||||
<div className="tgu-editor-panel__head">
|
<div className="tgu-editor-panel__head">
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ type EditorToolbarProps = {
|
|||||||
dirty: boolean;
|
dirty: boolean;
|
||||||
applying: boolean;
|
applying: boolean;
|
||||||
windowLabel: string;
|
windowLabel: string;
|
||||||
rightMode: "none" | "inspect" | "shoot" | "downlink";
|
rightMode: "none" | "inspect" | "shoot" | "downlink" | "mapInfo";
|
||||||
onSetRightMode: (mode: "shoot" | "downlink") => void;
|
onSetRightMode: (mode: "shoot" | "downlink") => void;
|
||||||
onUndo: () => void;
|
onUndo: () => void;
|
||||||
onRedo: () => void;
|
onRedo: () => void;
|
||||||
|
|||||||
@@ -122,19 +122,6 @@ export function MapPassSelector({ passes, fromIndex, toIndex, onFromChange, onTo
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Labels below handles */}
|
|
||||||
<div className="map-pass-selector__labels">
|
|
||||||
<div className="map-pass-selector__label" style={{ left: `${fromPct}%` }}>
|
|
||||||
<span className="map-pass-selector__label-num">{fromRev}</span>
|
|
||||||
<span className="map-pass-selector__label-time">{multiDay && `${utcDM(fromPass.startMs)} `}{utcHM(fromPass.startMs)}</span>
|
|
||||||
</div>
|
|
||||||
{toIndex !== fromIndex && (
|
|
||||||
<div className="map-pass-selector__label map-pass-selector__label--right" style={{ left: `${toPct}%` }}>
|
|
||||||
<span className="map-pass-selector__label-num">{toRev}</span>
|
|
||||||
<span className="map-pass-selector__label-time">{multiDay && `${utcDM(toPass.startMs)} `}{utcHM(toPass.startMs)}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
import { useEffect, useMemo, useRef, useState, useCallback, type MutableRefObject } from "react";
|
import { useEffect, useMemo, useRef, useState, useCallback, type MutableRefObject } from "react";
|
||||||
import { fetchRequestsForMap } from "../../api/requestApi";
|
import { fetchRequestsForMap } from "../../api/requestApi";
|
||||||
|
import { fetchStations, type StationDto } from "../../api/stationsApi";
|
||||||
import { fetchFlightLine, type FlightLinePoint } from "../../api/tguApi";
|
import { fetchFlightLine, type FlightLinePoint } from "../../api/tguApi";
|
||||||
import type { RequestMapItem } from "../../model/requestTypes";
|
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 { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView";
|
||||||
import { parseWktPolygon } from "../tgu-map-2d/geometry/wktParser";
|
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 { 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 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 {
|
import {
|
||||||
addDownlink,
|
addDownlink,
|
||||||
addShoot,
|
addShoot,
|
||||||
@@ -21,7 +26,7 @@ import {
|
|||||||
} from "./editorDraft";
|
} from "./editorDraft";
|
||||||
import { checkVisibility, conflictCount, mergeConflictMaps, overlaps, type EditorConflictMap } from "./editorConflicts";
|
import { checkVisibility, conflictCount, mergeConflictMaps, overlaps, type EditorConflictMap } from "./editorConflicts";
|
||||||
import { DEFAULT_EDITOR_MODES, formatDuration } from "./editorPresentation";
|
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 { MapPassSelector } from "./MapPassSelector";
|
||||||
import { EditorRail } from "./EditorRail";
|
import { EditorRail } from "./EditorRail";
|
||||||
import { EditorTimeline } from "./EditorTimeline";
|
import { EditorTimeline } from "./EditorTimeline";
|
||||||
@@ -39,9 +44,17 @@ import type {
|
|||||||
TguEditorTabProps
|
TguEditorTabProps
|
||||||
} from "./model/editorTypes";
|
} from "./model/editorTypes";
|
||||||
|
|
||||||
type EditorRightMode = "none" | "inspect" | "shoot" | "downlink";
|
type EditorRightMode = "none" | "inspect" | "shoot" | "downlink" | "mapInfo";
|
||||||
|
|
||||||
const HOUR_MS = 60 * 60 * 1000;
|
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 = {
|
const EDITOR_MAP_LAYERS: Tgu2DMapLayersState = {
|
||||||
tracks: true,
|
tracks: true,
|
||||||
swath: true,
|
swath: true,
|
||||||
@@ -55,7 +68,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
|||||||
const idCounterRef = useRef(0);
|
const idCounterRef = useRef(0);
|
||||||
const targetCounterRef = useRef(0);
|
const targetCounterRef = useRef(0);
|
||||||
const centerRef = useRef<HTMLElement>(null);
|
const centerRef = useRef<HTMLElement>(null);
|
||||||
const [mapRatio, setMapRatio] = useState(0.5);
|
const [mapRatio, setMapRatio] = useState(0.72);
|
||||||
const [history, setHistory] = useState<DraftHistory>(() => ({
|
const [history, setHistory] = useState<DraftHistory>(() => ({
|
||||||
draft: seedDraft(selectedPlan, selectedSpacecraftId),
|
draft: seedDraft(selectedPlan, selectedSpacecraftId),
|
||||||
past: [],
|
past: [],
|
||||||
@@ -67,7 +80,15 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
|||||||
const [target, setTarget] = useState<EditorTarget>();
|
const [target, setTarget] = useState<EditorTarget>();
|
||||||
const [selectedShootPass, setSelectedShootPass] = useState<EditorVisibilityWindow>();
|
const [selectedShootPass, setSelectedShootPass] = useState<EditorVisibilityWindow>();
|
||||||
const [modeId, setModeId] = useState(DEFAULT_EDITOR_MODES[0].id);
|
const [modeId, setModeId] = useState(DEFAULT_EDITOR_MODES[0].id);
|
||||||
const [selectedDownlink, setSelectedDownlink] = useState<DownlinkSelection>();
|
const [rvaZones, setRvaZones] = useState<RvaZone[]>([]);
|
||||||
|
const [rvaLoading, setRvaLoading] = useState(false);
|
||||||
|
const [rvaError, setRvaError] = useState<string>();
|
||||||
|
const [selectedZone, setSelectedZone] = useState<RvaZone>();
|
||||||
|
const [selectedDropSurveyIds, setSelectedDropSurveyIds] = useState<Set<number>>(new Set());
|
||||||
|
const [dropSaving, setDropSaving] = useState(false);
|
||||||
|
const [dropError, setDropError] = useState<string>();
|
||||||
|
const [mapInfoSelection, setMapInfoSelection] = useState<Tgu2DMapSelection>();
|
||||||
|
const [stationDtos, setStationDtos] = useState<StationDto[]>([]);
|
||||||
const [serviceConflicts, setServiceConflicts] = useState<EditorConflictMap>({});
|
const [serviceConflicts, setServiceConflicts] = useState<EditorConflictMap>({});
|
||||||
const [checking, setChecking] = useState(false);
|
const [checking, setChecking] = useState(false);
|
||||||
const [applying, setApplying] = 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 conflicts = useMemo(() => mergeConflictMaps(liveConflicts, serviceConflicts), [liveConflicts, serviceConflicts]);
|
||||||
const selectedWork = draft.works.find((work) => work.id === selectedWorkId);
|
const selectedWork = draft.works.find((work) => work.id === selectedWorkId);
|
||||||
const activeLabel = platformLabel(platforms, selectedSpacecraftId);
|
const activeLabel = platformLabel(platforms, selectedSpacecraftId);
|
||||||
const shootPasses = useMemo(
|
const [shootPasses, setShootPasses] = useState<EditorVisibilityWindow[]>([]);
|
||||||
() => target ? targetPasses({ spacecraftId: selectedSpacecraftId }, target, { fromMs: window.fromMs, toMs: window.toMs }) : [],
|
const [shootPassesLoading, setShootPassesLoading] = useState(false);
|
||||||
[selectedSpacecraftId, target, window.fromMs, window.toMs]
|
const [shootPassesError, setShootPassesError] = useState<string>();
|
||||||
);
|
const [routeAnchor, setRouteAnchor] = useState<SurveyRouteAnchor>("CENTERED");
|
||||||
const stationOptions = useMemo(
|
const [surveyRoute, setSurveyRoute] = useState<SurveyRoute>();
|
||||||
() => buildStationOptions(stations, selectedSpacecraftId, window, selectedPlan),
|
const [routeLoading, setRouteLoading] = useState(false);
|
||||||
[selectedPlan, selectedSpacecraftId, stations, window]
|
const [routeError, setRouteError] = useState<string>();
|
||||||
);
|
|
||||||
const visibilityTracks = useMemo(
|
const visibilityTracks = useMemo(
|
||||||
() => buildVisibilityTracks(target, shootPasses, selectedWork, stations, selectedSpacecraftId, window),
|
() => buildVisibilityTracks(target, shootPasses, selectedWork, stations, selectedSpacecraftId, window),
|
||||||
[selectedSpacecraftId, selectedWork, shootPasses, stations, target, 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 effectiveFrom = Math.max(1, Math.min(passFrom, orbitPasses.length || 1));
|
||||||
const effectiveTo = Math.max(effectiveFrom, Math.min(passTo, orbitPasses.length || 1));
|
const effectiveTo = Math.max(effectiveFrom, Math.min(passTo, orbitPasses.length || 1));
|
||||||
const mapScene = useMemo(
|
const mapScene = useMemo(
|
||||||
() => buildEditorMapScene(selectedSpacecraftId, flightLineData, orbitPasses, stations, requestItems, planModes, effectiveFrom, effectiveTo),
|
() => buildEditorMapScene(selectedSpacecraftId, flightLineData, orbitPasses, stationDtos, requestItems, planModes, effectiveFrom, effectiveTo, surveyRoute),
|
||||||
[selectedSpacecraftId, flightLineData, orbitPasses, stations, requestItems, planModes, effectiveFrom, effectiveTo]
|
[selectedSpacecraftId, flightLineData, orbitPasses, stationDtos, requestItems, planModes, effectiveFrom, effectiveTo, surveyRoute]
|
||||||
);
|
);
|
||||||
const shootTargets = useMemo(
|
const shootTargets = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -146,6 +166,25 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
|||||||
})),
|
})),
|
||||||
[contextSpacecraftIds, platforms]
|
[contextSpacecraftIds, platforms]
|
||||||
);
|
);
|
||||||
|
// Кандидаты на сброс: маршруты съёмки плана, заканчивающиеся строго до начала выбранной зоны.
|
||||||
|
const dropCandidates = useMemo<DropCandidateSurvey[]>(() => {
|
||||||
|
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 dirty = history.past.length > 0 || draft.works.some((work) => work.isNew);
|
||||||
const addedCount = draft.works.filter((work) => work.isNew).length;
|
const addedCount = draft.works.filter((work) => work.isNew).length;
|
||||||
|
|
||||||
@@ -156,7 +195,13 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
|||||||
setRightMode("none");
|
setRightMode("none");
|
||||||
setTarget(undefined);
|
setTarget(undefined);
|
||||||
setSelectedShootPass(undefined);
|
setSelectedShootPass(undefined);
|
||||||
setSelectedDownlink(undefined);
|
setSurveyRoute(undefined);
|
||||||
|
setRouteError(undefined);
|
||||||
|
setSelectedZone(undefined);
|
||||||
|
setSelectedDropSurveyIds(new Set());
|
||||||
|
setDropError(undefined);
|
||||||
|
setRvaZones([]);
|
||||||
|
setMapInfoSelection(undefined);
|
||||||
setServiceConflicts({});
|
setServiceConflicts({});
|
||||||
setNotice(undefined);
|
setNotice(undefined);
|
||||||
setApplyError(undefined);
|
setApplyError(undefined);
|
||||||
@@ -190,6 +235,125 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
|||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, [window.fromMs, window.toMs]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!selectedPlan) return;
|
if (!selectedPlan) return;
|
||||||
const planId = selectedPlan.planId;
|
const planId = selectedPlan.planId;
|
||||||
@@ -213,9 +377,41 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
|||||||
changeDraft((current) => updateWork(current, workId, patch));
|
changeDraft((current) => updateWork(current, workId, patch));
|
||||||
};
|
};
|
||||||
|
|
||||||
const addShootWork = () => {
|
const addShootWork = async () => {
|
||||||
if (!target || !selectedShootPass || !modeId) return;
|
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 durationMs = Math.min(selectedShootPass.endMs - selectedShootPass.startMs, 30 * 60 * 1000);
|
||||||
const startMs = selectedShootPass.startMs + Math.floor((selectedShootPass.endMs - selectedShootPass.startMs - durationMs) / 2);
|
const startMs = selectedShootPass.startMs + Math.floor((selectedShootPass.endMs - selectedShootPass.startMs - durationMs) / 2);
|
||||||
const mode = modes.find((item) => item.id === modeId);
|
const mode = modes.find((item) => item.id === modeId);
|
||||||
@@ -237,25 +433,63 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
|||||||
setSelectedShootPass(undefined);
|
setSelectedShootPass(undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
const addDownlinkWork = () => {
|
const stationNameById = useCallback(
|
||||||
if (!selectedDownlink) return;
|
(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 addDropWork = async () => {
|
||||||
const startMs = selectedDownlink.pass.startMs + Math.floor((selectedDownlink.pass.endMs - selectedDownlink.pass.startMs - durationMs) / 2);
|
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);
|
const id = nextWorkId(idCounterRef);
|
||||||
|
|
||||||
changeDraft((current) =>
|
changeDraft((current) =>
|
||||||
addDownlink(current, {
|
addDownlink(current, {
|
||||||
id,
|
id,
|
||||||
startMs,
|
startMs: zoneStartMsVal,
|
||||||
endMs: startMs + durationMs,
|
endMs: zoneStopMsVal,
|
||||||
label: "Сброс на НКПОР",
|
label: `Сброс · ${stationNm}`,
|
||||||
station: selectedDownlink.station
|
station: { id: String(selectedZone.stationId), name: stationNm }
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
setSelectedWorkId(id);
|
setSelectedWorkId(id);
|
||||||
setRightMode("inspect");
|
setRightMode("inspect");
|
||||||
setSelectedDownlink(undefined);
|
setSelectedZone(undefined);
|
||||||
|
setSelectedDropSurveyIds(new Set());
|
||||||
};
|
};
|
||||||
|
|
||||||
const runCheck = () => {
|
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) => {
|
const handleResizerDown = useCallback((e: React.MouseEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const startY = e.clientY;
|
const startY = e.clientY;
|
||||||
@@ -349,6 +597,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
|||||||
onSetRightMode={(mode) => {
|
onSetRightMode={(mode) => {
|
||||||
setRightMode(mode);
|
setRightMode(mode);
|
||||||
setSelectedWorkId(undefined);
|
setSelectedWorkId(undefined);
|
||||||
|
setMapInfoSelection(undefined);
|
||||||
setNotice(undefined);
|
setNotice(undefined);
|
||||||
}}
|
}}
|
||||||
onUndo={() => setHistory((current) => undo(current))}
|
onUndo={() => setHistory((current) => undo(current))}
|
||||||
@@ -426,7 +675,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
|||||||
setModeId(DEFAULT_EDITOR_MODES[0].id);
|
setModeId(DEFAULT_EDITOR_MODES[0].id);
|
||||||
setNotice(undefined);
|
setNotice(undefined);
|
||||||
}}
|
}}
|
||||||
onObjectSelect={() => undefined}
|
onObjectSelect={handleMapObjectSelect}
|
||||||
/>
|
/>
|
||||||
<div className="tgu-editor-map-resizer" onMouseDown={handleResizerDown} />
|
<div className="tgu-editor-map-resizer" onMouseDown={handleResizerDown} />
|
||||||
</div>
|
</div>
|
||||||
@@ -484,9 +733,16 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
|||||||
<ShootPanel
|
<ShootPanel
|
||||||
target={target}
|
target={target}
|
||||||
passes={shootPasses}
|
passes={shootPasses}
|
||||||
|
loading={shootPassesLoading}
|
||||||
|
error={shootPassesError}
|
||||||
selectedPass={selectedShootPass}
|
selectedPass={selectedShootPass}
|
||||||
modeId={modeId}
|
modeId={modeId}
|
||||||
modes={modes}
|
modes={modes}
|
||||||
|
routeAnchor={routeAnchor}
|
||||||
|
routeLoading={routeLoading}
|
||||||
|
routeError={routeError}
|
||||||
|
route={surveyRoute}
|
||||||
|
onAnchorChange={setRouteAnchor}
|
||||||
onSelectPass={setSelectedShootPass}
|
onSelectPass={setSelectedShootPass}
|
||||||
onModeChange={setModeId}
|
onModeChange={setModeId}
|
||||||
onAdd={addShootWork}
|
onAdd={addShootWork}
|
||||||
@@ -502,14 +758,47 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{rightMode === "downlink" && (
|
{rightMode === "downlink" && (
|
||||||
<DownlinkPanel
|
<DropPanel
|
||||||
stationOptions={stationOptions}
|
zones={rvaZones}
|
||||||
selected={selectedDownlink}
|
loading={rvaLoading}
|
||||||
onSelect={setSelectedDownlink}
|
error={rvaError}
|
||||||
onAdd={addDownlinkWork}
|
selectedZone={selectedZone}
|
||||||
|
onSelectZone={(zone) => {
|
||||||
|
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={() => {
|
onClose={() => {
|
||||||
setRightMode("none");
|
setRightMode("none");
|
||||||
setSelectedDownlink(undefined);
|
setSelectedZone(undefined);
|
||||||
|
setSelectedDropSurveyIds(new Set());
|
||||||
|
setDropError(undefined);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{rightMode === "mapInfo" && mapInfoSelection && (
|
||||||
|
<MapInfoPanel
|
||||||
|
selection={mapInfoSelection}
|
||||||
|
requestItems={requestItems}
|
||||||
|
onClose={() => {
|
||||||
|
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(
|
function defaultEditorWindow(
|
||||||
selectedPlan: TguEditorTabProps["selectedPlan"],
|
selectedPlan: TguEditorTabProps["selectedPlan"],
|
||||||
appliedRange: TguEditorTabProps["appliedRange"]
|
appliedRange: TguEditorTabProps["appliedRange"]
|
||||||
@@ -542,30 +846,13 @@ function buildStations(selectedPlan: TguEditorTabProps["selectedPlan"]): EditorG
|
|||||||
return [{ id: selectedPlan.kppId, name: selectedPlan.kppId }];
|
return [{ id: selectedPlan.kppId, name: selectedPlan.kppId }];
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildStationOptions(
|
/**
|
||||||
stations: EditorGroundStation[],
|
* LocalDateTime без зоны от бэка трактуем как UTC (как и поле `time` в ballisticsApi):
|
||||||
spacecraftId: string | undefined,
|
* зоны ЗРВ приходят без таймзоны, а времена съёмок — с offset; приводим к одной шкале.
|
||||||
window: EditorTimelineWindow,
|
*/
|
||||||
selectedPlan: TguEditorTabProps["selectedPlan"]
|
function localDateTimeMs(s: string): number {
|
||||||
): DownlinkStationOption[] {
|
const hasZone = /[zZ]$|[+-]\d{2}:?\d{2}$/.test(s);
|
||||||
return stations.map((station) => {
|
return Date.parse(hasZone ? s : `${s}Z`);
|
||||||
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)
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildVisibilityTracks(
|
function buildVisibilityTracks(
|
||||||
@@ -609,11 +896,12 @@ function buildEditorMapScene(
|
|||||||
spacecraftId: string | undefined,
|
spacecraftId: string | undefined,
|
||||||
flightLineData: FlightLinePoint[],
|
flightLineData: FlightLinePoint[],
|
||||||
orbitPasses: OrbitPassInfo[],
|
orbitPasses: OrbitPassInfo[],
|
||||||
stations: EditorGroundStation[],
|
stationDtos: StationDto[],
|
||||||
requestItems: RequestMapItem[],
|
requestItems: RequestMapItem[],
|
||||||
planModes: PlanMode[],
|
planModes: PlanMode[],
|
||||||
fromIndex: number,
|
fromIndex: number,
|
||||||
toIndex: number
|
toIndex: number,
|
||||||
|
surveyRoute?: SurveyRoute
|
||||||
): Tgu2DMapScene {
|
): Tgu2DMapScene {
|
||||||
const visibleRevolutions = new Set(
|
const visibleRevolutions = new Set(
|
||||||
orbitPasses.filter((p) => p.index >= fromIndex && p.index <= toIndex).map((p) => p.revolution!)
|
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 [];
|
// отдельным цветом (статус ISSUING). Рендерится в слое planWorks, поэтому виден без
|
||||||
return [{
|
// дополнительной настройки слоёв.
|
||||||
id: station.id,
|
if (surveyRoute?.contourWkt) {
|
||||||
layer: "stations" as const,
|
const rings = parseWktPolygon(surveyRoute.contourWkt);
|
||||||
point: { lat: station.lat, lon: station.lon },
|
for (const ring of rings) {
|
||||||
label: station.name,
|
if (ring.length < 3) continue;
|
||||||
zIndex: 10
|
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 };
|
return { polygons, lines, markers };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,16 @@ export type EditorVisibilityWindow = {
|
|||||||
startMs: number;
|
startMs: number;
|
||||||
endMs: number;
|
endMs: number;
|
||||||
quality: number;
|
quality: number;
|
||||||
|
/** Номер витка (если окно получено из баллистики). */
|
||||||
|
revolution?: number;
|
||||||
|
/** Крен, град. */
|
||||||
|
rollDeg?: number;
|
||||||
|
/** Угол Солнца, град. */
|
||||||
|
sunAngleDeg?: number;
|
||||||
|
/** Наклонная дальность. */
|
||||||
|
range?: number;
|
||||||
|
/** Направление витка. */
|
||||||
|
revSign?: "ASC" | "DESC";
|
||||||
};
|
};
|
||||||
|
|
||||||
export type EditorVisibilityTrack = {
|
export type EditorVisibilityTrack = {
|
||||||
|
|||||||
@@ -252,7 +252,7 @@
|
|||||||
|
|
||||||
.map-pass-selector__header {
|
.map-pass-selector__header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: baseline;
|
||||||
gap: 0.45rem;
|
gap: 0.45rem;
|
||||||
padding: 0.4rem 0.65rem;
|
padding: 0.4rem 0.65rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,20 @@ export default defineConfig({
|
|||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
rewrite: (path: string) => path.replace(/^\/api\/pcp-request/, "")
|
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": {
|
"/api": {
|
||||||
target: "http://localhost:7008",
|
target: "http://localhost:7008",
|
||||||
changeOrigin: true
|
changeOrigin: true
|
||||||
|
|||||||
Reference in New Issue
Block a user