Редактор: ручное добавление маршрутов съёмки и сбросов

Режим «Сброс» теперь рабочий: загружает ЗРВ станций
(ballisticsApi.fetchSatelliteRva), даёт оператору выбрать зону, затем
показывает съёмки плана, заканчивающиеся строго до начала зоны, и
сохраняет сброс в плане (missionPlanningApi.saveDropRoute → DropPanel).
Старый фейковый DownlinkPanel на stationPasses убран из потока.
Режим «Съёмка» сохраняет маршрут через saveSurveyRoute.

Времена ЗРВ приходят как LocalDateTime без зоны и трактуются как UTC
(helper localDateTimeMs), чтобы сравнивать с временами съёмок.
В vite-прокси добавлены pcp-ballistics, pcp-mission и stations.
This commit is contained in:
Дмитрий Соловьев
2026-06-01 22:17:32 +03:00
parent ef97ebbcf7
commit 715b5108d6
9 changed files with 1069 additions and 89 deletions
@@ -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;
}