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

Режим «Сброс» теперь рабочий: загружает ЗРВ станций
(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;
}
@@ -1,4 +1,9 @@
import { formatDate, formatDateTime, formatDuration, formatTime, kindColor, kindLabel } from "./editorPresentation";
import { statusStyle } from "../tgu-planning/tguStatus";
import type { RvaZone } from "../../api/ballisticsApi";
import type { SurveyRoute, SurveyRouteAnchor } from "../../api/missionPlanningApi";
import type { Tgu2DMapSelection } from "../tgu-map-2d/model/mapSelectionTypes";
import type { RequestMapItem } from "../../model/requestTypes";
import type {
EditorGroundStation,
EditorMode,
@@ -32,9 +37,16 @@ type WorkInspectorProps = {
type ShootPanelProps = {
target?: EditorTarget;
passes: EditorVisibilityWindow[];
loading?: boolean;
error?: string;
selectedPass?: EditorVisibilityWindow;
modeId?: string;
modes: EditorMode[];
routeAnchor: SurveyRouteAnchor;
routeLoading?: boolean;
routeError?: string;
route?: SurveyRoute;
onAnchorChange: (anchor: SurveyRouteAnchor) => void;
onSelectPass: (pass?: EditorVisibilityWindow) => void;
onModeChange: (modeId: string) => void;
onAdd: () => void;
@@ -50,6 +62,35 @@ type DownlinkPanelProps = {
onClose: () => void;
};
/** Маршрут съёмки — кандидат на сброс в выбранной зоне (заканчивается до начала зоны). */
export type DropCandidateSurvey = {
id: number;
label: string;
startMs: number;
endMs: number;
};
type DropPanelProps = {
zones: RvaZone[];
loading?: boolean;
error?: string;
selectedZone?: RvaZone;
onSelectZone: (zone?: RvaZone) => void;
/** Название станции по её номеру (из каталога станций). */
stationName: (stationId: number) => string;
/** Время начала зоны в мс (UTC) — для подписи. */
zoneStartMs: (zone: RvaZone) => number;
zoneStopMs: (zone: RvaZone) => number;
/** Съёмки-кандидаты для выбранной зоны (end < начало зоны). */
candidates: DropCandidateSurvey[];
selectedSurveyIds: Set<number>;
onToggleSurvey: (id: number) => void;
saving?: boolean;
saveError?: string;
onAdd: () => void;
onClose: () => void;
};
export function WorkInspector({
work,
modes,
@@ -131,9 +172,16 @@ export function WorkInspector({
export function ShootPanel({
target,
passes,
loading,
error,
selectedPass,
modeId,
modes,
routeAnchor,
routeLoading,
routeError,
route,
onAnchorChange,
onSelectPass,
onModeChange,
onAdd,
@@ -169,7 +217,11 @@ export function ShootPanel({
{target && (
<section className="tgu-editor-panel__section">
<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-pass-list">
@@ -182,8 +234,11 @@ export function ShootPanel({
type="button"
onClick={() => onSelectPass(pass)}
>
<span>{formatDate(pass.startMs)} {formatTime(pass.startMs)}</span>
<small>{formatDuration(pass.endMs - pass.startMs)}</small>
<span>
{formatDate(pass.startMs)} {formatTime(pass.startMs)}
{pass.revolution != null && ` · виток ${pass.revolution}`}
</span>
<small>{formatVisibilityParams(pass)}</small>
</button>
);
})}
@@ -205,6 +260,39 @@ export function ShootPanel({
</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>
@@ -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 }) {
return (
<div className="tgu-editor-panel__head">
@@ -13,7 +13,7 @@ type EditorToolbarProps = {
dirty: boolean;
applying: boolean;
windowLabel: string;
rightMode: "none" | "inspect" | "shoot" | "downlink";
rightMode: "none" | "inspect" | "shoot" | "downlink" | "mapInfo";
onSetRightMode: (mode: "shoot" | "downlink") => void;
onUndo: () => void;
onRedo: () => void;
@@ -122,19 +122,6 @@ export function MapPassSelector({ passes, fromIndex, toIndex, onFromChange, onTo
/>
</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>
);
@@ -1,13 +1,18 @@
import { useEffect, useMemo, useRef, useState, useCallback, type MutableRefObject } from "react";
import { fetchRequestsForMap } from "../../api/requestApi";
import { fetchStations, type StationDto } from "../../api/stationsApi";
import { fetchFlightLine, type FlightLinePoint } from "../../api/tguApi";
import type { RequestMapItem } from "../../model/requestTypes";
import { stationPasses, targetPasses, type OrbitPassInfo, type PassRange } from "../tgu-map-2d/geometry/passes";
import { stationPasses, type OrbitPassInfo, type PassRange } from "../tgu-map-2d/geometry/passes";
import { fetchPointVisibility, fetchSatelliteRva, type PointVisibilityParam, type RvaZone } from "../../api/ballisticsApi";
import { buildSurveyRoute, saveDropRoute, saveSurveyRoute, type SurveyRoute, type SurveyRouteAnchor } from "../../api/missionPlanningApi";
import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView";
import { parseWktPolygon } from "../tgu-map-2d/geometry/wktParser";
import { buildStationMarkers } from "../tgu-map-2d/Tgu2DMapLayers";
import type { Tgu2DMapLayersState } from "../tgu-map-2d/model/mapLayerTypes";
import type { Tgu2DMapSelection } from "../tgu-map-2d/model/mapSelectionTypes";
import type { MapLine, MapPolygon, Tgu2DMapScene } from "../tgu-map-2d/model/mapTypes";
import { fetchPlanModes, saveEditedPlan, type PlanMode } from "./editorApi";
import { fetchPlanModes, saveEditedPlan, type PlanMode, type PlanSurveyMode } from "./editorApi";
import {
addDownlink,
addShoot,
@@ -21,7 +26,7 @@ import {
} from "./editorDraft";
import { checkVisibility, conflictCount, mergeConflictMaps, overlaps, type EditorConflictMap } from "./editorConflicts";
import { DEFAULT_EDITOR_MODES, formatDuration } from "./editorPresentation";
import { DownlinkPanel, type DownlinkSelection, type DownlinkStationOption, ShootPanel, WorkInspector } from "./EditorPanels";
import { DropPanel, type DropCandidateSurvey, MapInfoPanel, ShootPanel, WorkInspector } from "./EditorPanels";
import { MapPassSelector } from "./MapPassSelector";
import { EditorRail } from "./EditorRail";
import { EditorTimeline } from "./EditorTimeline";
@@ -39,9 +44,17 @@ import type {
TguEditorTabProps
} from "./model/editorTypes";
type EditorRightMode = "none" | "inspect" | "shoot" | "downlink";
type EditorRightMode = "none" | "inspect" | "shoot" | "downlink" | "mapInfo";
const HOUR_MS = 60 * 60 * 1000;
/** Фиксированная длительность окна съёмки, строится вокруг момента видимости. */
const SHOOT_WINDOW_MS = 6 * 60 * 1000;
/**
* Угол захвата (полуширина полосы) по умолчанию, градусы.
* Пока задаётся на фронте так же, как в текущем построении маршрутов; в дальнейшем
* это значение будет определять бэкенд по профилю наблюдения КА.
*/
const DEFAULT_CAPTURE_ANGLE_DEG = 1.5;
const EDITOR_MAP_LAYERS: Tgu2DMapLayersState = {
tracks: true,
swath: true,
@@ -55,7 +68,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
const idCounterRef = useRef(0);
const targetCounterRef = useRef(0);
const centerRef = useRef<HTMLElement>(null);
const [mapRatio, setMapRatio] = useState(0.5);
const [mapRatio, setMapRatio] = useState(0.72);
const [history, setHistory] = useState<DraftHistory>(() => ({
draft: seedDraft(selectedPlan, selectedSpacecraftId),
past: [],
@@ -67,7 +80,15 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
const [target, setTarget] = useState<EditorTarget>();
const [selectedShootPass, setSelectedShootPass] = useState<EditorVisibilityWindow>();
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 [checking, setChecking] = useState(false);
const [applying, setApplying] = useState(false);
@@ -112,14 +133,13 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
const conflicts = useMemo(() => mergeConflictMaps(liveConflicts, serviceConflicts), [liveConflicts, serviceConflicts]);
const selectedWork = draft.works.find((work) => work.id === selectedWorkId);
const activeLabel = platformLabel(platforms, selectedSpacecraftId);
const shootPasses = useMemo(
() => target ? targetPasses({ spacecraftId: selectedSpacecraftId }, target, { fromMs: window.fromMs, toMs: window.toMs }) : [],
[selectedSpacecraftId, target, window.fromMs, window.toMs]
);
const stationOptions = useMemo(
() => buildStationOptions(stations, selectedSpacecraftId, window, selectedPlan),
[selectedPlan, selectedSpacecraftId, stations, window]
);
const [shootPasses, setShootPasses] = useState<EditorVisibilityWindow[]>([]);
const [shootPassesLoading, setShootPassesLoading] = useState(false);
const [shootPassesError, setShootPassesError] = useState<string>();
const [routeAnchor, setRouteAnchor] = useState<SurveyRouteAnchor>("CENTERED");
const [surveyRoute, setSurveyRoute] = useState<SurveyRoute>();
const [routeLoading, setRouteLoading] = useState(false);
const [routeError, setRouteError] = useState<string>();
const visibilityTracks = useMemo(
() => buildVisibilityTracks(target, shootPasses, selectedWork, stations, selectedSpacecraftId, window),
[selectedSpacecraftId, selectedWork, shootPasses, stations, target, window]
@@ -127,8 +147,8 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
const effectiveFrom = Math.max(1, Math.min(passFrom, orbitPasses.length || 1));
const effectiveTo = Math.max(effectiveFrom, Math.min(passTo, orbitPasses.length || 1));
const mapScene = useMemo(
() => buildEditorMapScene(selectedSpacecraftId, flightLineData, orbitPasses, stations, requestItems, planModes, effectiveFrom, effectiveTo),
[selectedSpacecraftId, flightLineData, orbitPasses, stations, requestItems, planModes, effectiveFrom, effectiveTo]
() => buildEditorMapScene(selectedSpacecraftId, flightLineData, orbitPasses, stationDtos, requestItems, planModes, effectiveFrom, effectiveTo, surveyRoute),
[selectedSpacecraftId, flightLineData, orbitPasses, stationDtos, requestItems, planModes, effectiveFrom, effectiveTo, surveyRoute]
);
const shootTargets = useMemo(
() =>
@@ -146,6 +166,25 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
})),
[contextSpacecraftIds, platforms]
);
// Кандидаты на сброс: маршруты съёмки плана, заканчивающиеся строго до начала выбранной зоны.
const dropCandidates = useMemo<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 addedCount = draft.works.filter((work) => work.isNew).length;
@@ -156,7 +195,13 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
setRightMode("none");
setTarget(undefined);
setSelectedShootPass(undefined);
setSelectedDownlink(undefined);
setSurveyRoute(undefined);
setRouteError(undefined);
setSelectedZone(undefined);
setSelectedDropSurveyIds(new Set());
setDropError(undefined);
setRvaZones([]);
setMapInfoSelection(undefined);
setServiceConflicts({});
setNotice(undefined);
setApplyError(undefined);
@@ -190,6 +235,125 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
return () => controller.abort();
}, [window.fromMs, window.toMs]);
useEffect(() => {
let cancelled = false;
void fetchStations()
.then((items) => { if (!cancelled) setStationDtos(items); })
.catch(() => { if (!cancelled) setStationDtos([]); });
return () => { cancelled = true; };
}, []);
useEffect(() => {
const numericNorad = noradId ? Number(noradId) : NaN;
if (rightMode !== "shoot" || !target || !Number.isFinite(numericNorad)) {
setShootPasses([]);
setShootPassesError(noradId ? undefined : target ? "Не задан NORAD-идентификатор КА." : undefined);
setShootPassesLoading(false);
return;
}
let cancelled = false;
setShootPassesLoading(true);
setShootPassesError(undefined);
void fetchPointVisibility(
numericNorad,
{ id: target.id, lat: target.lat, lon: target.lon },
window.fromMs,
window.toMs
)
.then((params) => {
if (cancelled) return;
setShootPasses(
params.map(visibilityWindowFromParam).sort((a, b) => a.startMs - b.startMs)
);
})
.catch((error) => {
if (cancelled) return;
setShootPasses([]);
setShootPassesError(error instanceof Error ? error.message : "Не удалось получить параметры видимости.");
})
.finally(() => {
if (!cancelled) setShootPassesLoading(false);
});
return () => {
cancelled = true;
};
}, [rightMode, target, noradId, window.fromMs, window.toMs]);
useEffect(() => {
const numericNorad = noradId ? Number(noradId) : NaN;
if (rightMode !== "shoot" || !target || !selectedShootPass || !Number.isFinite(numericNorad)) {
setSurveyRoute(undefined);
setRouteError(undefined);
setRouteLoading(false);
return;
}
const momentMs = (selectedShootPass.startMs + selectedShootPass.endMs) / 2;
const durationSeconds = Math.max(1, (selectedShootPass.endMs - selectedShootPass.startMs) / 1000);
let cancelled = false;
setRouteLoading(true);
setRouteError(undefined);
void buildSurveyRoute({
satelliteId: numericNorad,
time: new Date(momentMs).toISOString().slice(0, 19),
durationSeconds,
roll: selectedShootPass.rollDeg ?? 0,
captureAngle: DEFAULT_CAPTURE_ANGLE_DEG,
revolution: selectedShootPass.revolution ?? 0,
anchor: routeAnchor
})
.then((route) => {
if (!cancelled) setSurveyRoute(route);
})
.catch((error) => {
if (cancelled) return;
setSurveyRoute(undefined);
setRouteError(error instanceof Error ? error.message : "Не удалось построить маршрут съёмки.");
})
.finally(() => {
if (!cancelled) setRouteLoading(false);
});
return () => {
cancelled = true;
};
}, [rightMode, target, selectedShootPass, noradId, routeAnchor]);
// Зоны радиовидимости (ЗРВ) для ручного сброса — грузим при входе в режим «Сброс».
useEffect(() => {
const numericNorad = noradId ? Number(noradId) : NaN;
if (rightMode !== "downlink" || !Number.isFinite(numericNorad)) {
setRvaZones([]);
setRvaError(rightMode === "downlink" && !noradId ? "Не задан NORAD-идентификатор КА." : undefined);
setRvaLoading(false);
setSelectedZone(undefined);
setSelectedDropSurveyIds(new Set());
return;
}
let cancelled = false;
setRvaLoading(true);
setRvaError(undefined);
setSelectedZone(undefined);
setSelectedDropSurveyIds(new Set());
void fetchSatelliteRva(numericNorad, window.fromMs, window.toMs)
.then((zones) => {
if (!cancelled) setRvaZones(zones);
})
.catch((error) => {
if (cancelled) return;
setRvaZones([]);
setRvaError(error instanceof Error ? error.message : "Не удалось получить зоны радиовидимости.");
})
.finally(() => {
if (!cancelled) setRvaLoading(false);
});
return () => {
cancelled = true;
};
}, [rightMode, noradId, window.fromMs, window.toMs]);
useEffect(() => {
if (!selectedPlan) return;
const planId = selectedPlan.planId;
@@ -213,9 +377,41 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
changeDraft((current) => updateWork(current, workId, patch));
};
const addShootWork = () => {
const addShootWork = async () => {
if (!target || !selectedShootPass || !modeId) return;
// Персистенция построенного маршрута съёмки в pcp-mission-planing-service по id
// выбранного плана (planId == mission_id). Сохраняем только если план выбран и маршрут
// уже построен; иначе (открыт только КА без плана) добавляем включение без сохранения.
const numericNorad = noradId ? Number(noradId) : NaN;
if (selectedPlan && surveyRoute && Number.isFinite(numericNorad)) {
const planId = selectedPlan.planId;
const momentMs = (selectedShootPass.startMs + selectedShootPass.endMs) / 2;
const routeDurationSeconds = Math.max(1, (selectedShootPass.endMs - selectedShootPass.startMs) / 1000);
setRouteLoading(true);
setRouteError(undefined);
try {
const saved = await saveSurveyRoute(planId, {
satelliteId: numericNorad,
time: new Date(momentMs).toISOString().slice(0, 19),
durationSeconds: routeDurationSeconds,
roll: selectedShootPass.rollDeg ?? 0,
captureAngle: DEFAULT_CAPTURE_ANGLE_DEG,
revolution: selectedShootPass.revolution ?? 0,
anchor: routeAnchor,
// Окно выбранного плана — чтобы при автосоздании миссии/плана задать корректный интервал.
planStart: new Date(selectedPlan.startMs).toISOString().slice(0, 19),
planEnd: new Date(selectedPlan.endMs).toISOString().slice(0, 19)
});
setSurveyRoute(saved);
} catch (error) {
setRouteError(error instanceof Error ? error.message : "Не удалось сохранить маршрут съёмки.");
return;
} finally {
setRouteLoading(false);
}
}
const durationMs = Math.min(selectedShootPass.endMs - selectedShootPass.startMs, 30 * 60 * 1000);
const startMs = selectedShootPass.startMs + Math.floor((selectedShootPass.endMs - selectedShootPass.startMs - durationMs) / 2);
const mode = modes.find((item) => item.id === modeId);
@@ -237,25 +433,63 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
setSelectedShootPass(undefined);
};
const addDownlinkWork = () => {
if (!selectedDownlink) return;
const stationNameById = useCallback(
(stationId: number) =>
stationDtos.find((s) => s.number === stationId)?.name ?? `Станция ${stationId}`,
[stationDtos]
);
const durationMs = Math.min(selectedDownlink.pass.endMs - selectedDownlink.pass.startMs, 12 * 60 * 1000);
const startMs = selectedDownlink.pass.startMs + Math.floor((selectedDownlink.pass.endMs - selectedDownlink.pass.startMs - durationMs) / 2);
const addDropWork = async () => {
if (!selectedPlan || !selectedZone || selectedDropSurveyIds.size === 0) return;
const numericNorad = noradId ? Number(noradId) : NaN;
if (!Number.isFinite(numericNorad)) {
setDropError("Не задан NORAD-идентификатор КА.");
return;
}
const planId = selectedPlan.planId;
const zoneStartMsVal = localDateTimeMs(selectedZone.onStart.time);
const zoneStopMsVal = localDateTimeMs(selectedZone.onStop.time);
setDropSaving(true);
setDropError(undefined);
try {
await saveDropRoute(planId, {
satelliteId: numericNorad,
station: String(selectedZone.stationId),
revolution: selectedZone.revolution,
// Зона — LocalDateTime без зоны (как вернула баллистика).
zoneStart: selectedZone.onStart.time.slice(0, 19),
zoneStop: selectedZone.onStop.time.slice(0, 19),
surveyModeIds: [...selectedDropSurveyIds],
// Окно выбранного плана — для автосоздания миссии/плана с корректным интервалом.
planStart: new Date(selectedPlan.startMs).toISOString().slice(0, 19),
planEnd: new Date(selectedPlan.endMs).toISOString().slice(0, 19)
});
} catch (error) {
setDropError(error instanceof Error ? error.message : "Не удалось сохранить сброс.");
return;
} finally {
setDropSaving(false);
}
// Обновляем включения плана и добавляем работу-сброс в черновик для отображения на таймлайне.
void fetchPlanModes(planId).then(setPlanModes).catch(() => {});
const stationNm = stationNameById(selectedZone.stationId);
const id = nextWorkId(idCounterRef);
changeDraft((current) =>
addDownlink(current, {
id,
startMs,
endMs: startMs + durationMs,
label: "Сброс на НКПОР",
station: selectedDownlink.station
startMs: zoneStartMsVal,
endMs: zoneStopMsVal,
label: `Сброс · ${stationNm}`,
station: { id: String(selectedZone.stationId), name: stationNm }
})
);
setSelectedWorkId(id);
setRightMode("inspect");
setSelectedDownlink(undefined);
setSelectedZone(undefined);
setSelectedDropSurveyIds(new Set());
};
const runCheck = () => {
@@ -302,6 +536,20 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
});
};
const handleMapObjectSelect = useCallback((selection?: Tgu2DMapSelection) => {
if (rightMode === "shoot") return;
if (!selection) {
if (rightMode === "mapInfo") {
setRightMode("none");
setMapInfoSelection(undefined);
}
return;
}
setMapInfoSelection(selection);
setRightMode("mapInfo");
setSelectedWorkId(undefined);
}, [rightMode]);
const handleResizerDown = useCallback((e: React.MouseEvent) => {
e.preventDefault();
const startY = e.clientY;
@@ -349,6 +597,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
onSetRightMode={(mode) => {
setRightMode(mode);
setSelectedWorkId(undefined);
setMapInfoSelection(undefined);
setNotice(undefined);
}}
onUndo={() => setHistory((current) => undo(current))}
@@ -426,7 +675,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
setModeId(DEFAULT_EDITOR_MODES[0].id);
setNotice(undefined);
}}
onObjectSelect={() => undefined}
onObjectSelect={handleMapObjectSelect}
/>
<div className="tgu-editor-map-resizer" onMouseDown={handleResizerDown} />
</div>
@@ -484,9 +733,16 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
<ShootPanel
target={target}
passes={shootPasses}
loading={shootPassesLoading}
error={shootPassesError}
selectedPass={selectedShootPass}
modeId={modeId}
modes={modes}
routeAnchor={routeAnchor}
routeLoading={routeLoading}
routeError={routeError}
route={surveyRoute}
onAnchorChange={setRouteAnchor}
onSelectPass={setSelectedShootPass}
onModeChange={setModeId}
onAdd={addShootWork}
@@ -502,14 +758,47 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
/>
)}
{rightMode === "downlink" && (
<DownlinkPanel
stationOptions={stationOptions}
selected={selectedDownlink}
onSelect={setSelectedDownlink}
onAdd={addDownlinkWork}
<DropPanel
zones={rvaZones}
loading={rvaLoading}
error={rvaError}
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={() => {
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(
selectedPlan: TguEditorTabProps["selectedPlan"],
appliedRange: TguEditorTabProps["appliedRange"]
@@ -542,30 +846,13 @@ function buildStations(selectedPlan: TguEditorTabProps["selectedPlan"]): EditorG
return [{ id: selectedPlan.kppId, name: selectedPlan.kppId }];
}
function buildStationOptions(
stations: EditorGroundStation[],
spacecraftId: string | undefined,
window: EditorTimelineWindow,
selectedPlan: TguEditorTabProps["selectedPlan"]
): DownlinkStationOption[] {
return stations.map((station) => {
const computedPasses =
station.lat !== undefined && station.lon !== undefined
? stationPasses({ spacecraftId }, { lat: station.lat, lon: station.lon }, { fromMs: window.fromMs, toMs: window.toMs })
: [];
const fallbackPass = selectedPlan && selectedPlan.kppId === station.id
? [{
startMs: Math.max(window.fromMs, selectedPlan.startMs),
endMs: Math.min(window.toMs, selectedPlan.endMs),
quality: 0
}]
: [];
return {
station,
passes: computedPasses.length > 0 ? computedPasses : fallbackPass.filter((pass) => pass.endMs > pass.startMs)
};
});
/**
* LocalDateTime без зоны от бэка трактуем как UTC (как и поле `time` в ballisticsApi):
* зоны ЗРВ приходят без таймзоны, а времена съёмок — с offset; приводим к одной шкале.
*/
function localDateTimeMs(s: string): number {
const hasZone = /[zZ]$|[+-]\d{2}:?\d{2}$/.test(s);
return Date.parse(hasZone ? s : `${s}Z`);
}
function buildVisibilityTracks(
@@ -609,11 +896,12 @@ function buildEditorMapScene(
spacecraftId: string | undefined,
flightLineData: FlightLinePoint[],
orbitPasses: OrbitPassInfo[],
stations: EditorGroundStation[],
stationDtos: StationDto[],
requestItems: RequestMapItem[],
planModes: PlanMode[],
fromIndex: number,
toIndex: number
toIndex: number,
surveyRoute?: SurveyRoute
): Tgu2DMapScene {
const visibleRevolutions = new Set(
orbitPasses.filter((p) => p.index >= fromIndex && p.index <= toIndex).map((p) => p.revolution!)
@@ -689,16 +977,26 @@ function buildEditorMapScene(
}
}
const markers = stations.flatMap((station) => {
if (station.lat === undefined || station.lon === undefined) return [];
return [{
id: station.id,
layer: "stations" as const,
point: { lat: station.lat, lon: station.lon },
label: station.name,
zIndex: 10
}];
// Построенный (но ещё не сохранённый) маршрут съёмки — показываем поверх работ плана
// отдельным цветом (статус ISSUING). Рендерится в слое planWorks, поэтому виден без
// дополнительной настройки слоёв.
if (surveyRoute?.contourWkt) {
const rings = parseWktPolygon(surveyRoute.contourWkt);
for (const ring of rings) {
if (ring.length < 3) continue;
polygons.push({
id: `surveyRoute:${zIndex}`,
name: `Маршрут съёмки (виток ${surveyRoute.revolution})`,
kind: "planWork",
layer: "planWorks",
points: ring,
status: "ISSUING",
zIndex: zIndex++
});
}
}
const markers = buildStationMarkers(stationDtos);
return { polygons, lines, markers };
}
@@ -64,6 +64,16 @@ export type EditorVisibilityWindow = {
startMs: number;
endMs: number;
quality: number;
/** Номер витка (если окно получено из баллистики). */
revolution?: number;
/** Крен, град. */
rollDeg?: number;
/** Угол Солнца, град. */
sunAngleDeg?: number;
/** Наклонная дальность. */
range?: number;
/** Направление витка. */
revSign?: "ASC" | "DESC";
};
export type EditorVisibilityTrack = {
@@ -252,7 +252,7 @@
.map-pass-selector__header {
display: flex;
align-items: center;
align-items: baseline;
gap: 0.45rem;
padding: 0.4rem 0.65rem;
}
+14
View File
@@ -12,6 +12,20 @@ export default defineConfig({
changeOrigin: true,
rewrite: (path: string) => path.replace(/^\/api\/pcp-request/, "")
},
"/api/pcp-ballistics": {
target: "http://localhost:7003",
changeOrigin: true,
rewrite: (path: string) => path.replace(/^\/api\/pcp-ballistics/, "")
},
"/api/pcp-mission": {
target: "http://localhost:7010",
changeOrigin: true,
rewrite: (path: string) => path.replace(/^\/api\/pcp-mission/, "")
},
"/api/stations": {
target: "http://localhost:7009",
changeOrigin: true
},
"/api": {
target: "http://localhost:7008",
changeOrigin: true