Редактор: ручное добавление маршрутов съёмки и сбросов
Режим «Сброс» теперь рабочий: загружает ЗРВ станций (ballisticsApi.fetchSatelliteRva), даёт оператору выбрать зону, затем показывает съёмки плана, заканчивающиеся строго до начала зоны, и сохраняет сброс в плане (missionPlanningApi.saveDropRoute → DropPanel). Старый фейковый DownlinkPanel на stationPasses убран из потока. Режим «Съёмка» сохраняет маршрут через saveSurveyRoute. Времена ЗРВ приходят как LocalDateTime без зоны и трактуются как UTC (helper localDateTimeMs), чтобы сравнивать с временами съёмок. В vite-прокси добавлены pcp-ballistics, pcp-mission и stations.
This commit is contained in:
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user