pcp-tgu: редактор — стабильные витки, плейсхолдер без плана, черновик без авто-перехода

Вкладка «Планы»:
- «Создать черновик на основе плана» больше не перекидывает в редактор: черновик
  создаётся, выбирается на схеме (тумблер «Черновики» вкл.) и показывается подсказка —
  оператор сам переходит в «Редактор».

Редактор:
- Витки больше не пропадают «иногда»: жизненный цикл flightLineData теперь полностью
  внутри fetch-эффекта на [noradId, mapRange] (сброс + перезагрузка атомарны). Раньше
  трассу сбрасывал общий reset по ссылке selectedPlan, а перезагружал отдельный эффект —
  фоновый loadData пересобирал selectedPlan с тем же окном, mapRange по значению не менялся,
  фетч не перезапускался, витки молча исчезали.
- Явный индикатор вместо немой пустоты: «Загрузка трассы…» / «Нет данных трассы на это окно».
- После удаления открытого черновика редактор не «всплывал» обратно: guard загрузки включений
  не тянет режимы по устаревшему draftPlanId, когда план не выбран.
- Редактор включений гейтится на наличие плана (план == миссия): без плана — плейсхолдер
  «Выберите КА и план…». Иначе после удаления черновика вкладка оставалась активной по КА
  (витки, режим съёмки), хотя редактировать нечего.
This commit is contained in:
Дмитрий Соловьев
2026-06-04 13:30:12 +03:00
parent 9e5460d646
commit f0e56b6e2a
3 changed files with 63 additions and 14 deletions
@@ -127,6 +127,9 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
const [passFrom, setPassFrom] = useState(1); const [passFrom, setPassFrom] = useState(1);
const [passTo, setPassTo] = useState(999); const [passTo, setPassTo] = useState(999);
const [flightLineData, setFlightLineData] = useState<FlightLinePoint[]>([]); const [flightLineData, setFlightLineData] = useState<FlightLinePoint[]>([]);
// Статус загрузки трассы (витков): отличаем «ещё грузится» от «бэк вернул пусто на это окно»,
// чтобы при отсутствии данных не было молчаливой пустоты вместо панели витков.
const [flightLineLoading, setFlightLineLoading] = useState(false);
// Команда карте центрироваться/подсветить объект выбранного включения (клик по включению). // Команда карте центрироваться/подсветить объект выбранного включения (клик по включению).
const [mapFocus, setMapFocus] = useState<{ point?: GeoPoint; objectId?: string; token: number }>({ token: 0 }); const [mapFocus, setMapFocus] = useState<{ point?: GeoPoint; objectId?: string; token: number }>({ token: 0 });
const draft = history.draft; const draft = history.draft;
@@ -277,15 +280,26 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
setPlanModes([]); setPlanModes([]);
setPassFrom(1); setPassFrom(1);
setPassTo(999); setPassTo(999);
setFlightLineData([]);
}, [appliedRange, selectedPlan, selectedSpacecraftId]); }, [appliedRange, selectedPlan, selectedSpacecraftId]);
// Линия трассы (витки) живёт исключительно по своим входам запроса — noradId и mapRange.
// НЕ сбрасываем её в общем reset-эффекте по ссылке selectedPlan: фоновый loadData (после
// решения/создания черновика/обновления интервала) пересобирает объект selectedPlan с теми же
// временами окна, mapRange по значению не меняется, и фетч бы не перезапустился — витки
// молча пропадали бы до реальной смены окна. Привязка к [noradId, mapRange] делает это
// идемпотентным: сброс и перезагрузка происходят вместе и только когда меняется само окно/КА.
useEffect(() => { useEffect(() => {
if (!noradId) return; if (!noradId) {
setFlightLineData([]);
setFlightLineLoading(false);
return;
}
let cancelled = false; let cancelled = false;
setFlightLineLoading(true);
void fetchFlightLine(noradId, mapRange.fromMs, mapRange.toMs) void fetchFlightLine(noradId, mapRange.fromMs, mapRange.toMs)
.then((data) => { if (!cancelled) setFlightLineData(data); }) .then((data) => { if (!cancelled) setFlightLineData(data); })
.catch(() => { if (!cancelled) setFlightLineData([]); }); .catch(() => { if (!cancelled) setFlightLineData([]); })
.finally(() => { if (!cancelled) setFlightLineLoading(false); });
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [noradId, mapRange]); }, [noradId, mapRange]);
@@ -464,8 +478,15 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
// Источник истины для включений — DRAFT, если он уже создан, иначе живой план (read-only превью). // Источник истины для включений — DRAFT, если он уже создан, иначе живой план (read-only превью).
useEffect(() => { useEffect(() => {
const missionId = draftPlanId ?? selectedPlan?.planId; // Нет выбранного плана (например, открытый черновик удалили) — чистим включения и НЕ тянем
if (!missionId) return; // режимы по устаревшему draftPlanId. Он обнулится следующим коммитом (эффект выше), но в этом
// ещё указывает на удалённый черновик; без гарда fetchPlanModes(удалённый id) вернул бы его
// включения обратно — редактор «не чистился» бы после удаления.
if (!selectedPlan) {
setPlanModes([]);
return;
}
const missionId = draftPlanId ?? selectedPlan.planId;
let cancelled = false; let cancelled = false;
void fetchPlanModes(missionId).then((modes) => { void fetchPlanModes(missionId).then((modes) => {
if (!cancelled) setPlanModes(modes); if (!cancelled) setPlanModes(modes);
@@ -895,10 +916,13 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
); );
}; };
if (!selectedSpacecraftId && !selectedPlan) { // Редактор включений работает только при выбранном плане (план == миссия, в которую кладутся
// включения). Без плана — плейсхолдер: иначе после удаления открытого черновика вкладка
// оставалась бы «активной» по выбранному КА (витки, режим съёмки), хотя редактировать нечего.
if (!selectedPlan) {
return ( return (
<main className="tgu-editor-workspace"> <main className="tgu-editor-workspace">
<div className="tgu-empty">Выберите КА или план, чтобы открыть редактор включений.</div> <div className="tgu-empty">Выберите КА и план, чтобы открыть редактор включений.</div>
</main> </main>
); );
} }
@@ -969,7 +993,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
> >
<div className="tgu-editor-map-stage"> <div className="tgu-editor-map-stage">
{rightMode === "shoot" && <div className="tgu-map-overlay">Кликните точку съёмки на карте.</div>} {rightMode === "shoot" && <div className="tgu-map-overlay">Кликните точку съёмки на карте.</div>}
{orbitPasses.length > 0 && ( {orbitPasses.length > 0 ? (
<MapPassSelector <MapPassSelector
passes={orbitPasses} passes={orbitPasses}
fromIndex={effectiveFrom} fromIndex={effectiveFrom}
@@ -977,7 +1001,14 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
onFromChange={setPassFrom} onFromChange={setPassFrom}
onToChange={setPassTo} onToChange={setPassTo}
/> />
)} ) : noradId ? (
<div className="map-pass-selector map-pass-selector--empty">
<span className="map-pass-selector__title">Витки</span>
<span className="map-pass-selector__empty-note">
{flightLineLoading ? "Загрузка трассы…" : "Нет данных трассы на это окно"}
</span>
</div>
) : null}
<Tgu2DMapView <Tgu2DMapView
scene={mapScene} scene={mapScene}
layers={EDITOR_MAP_LAYERS} layers={EDITOR_MAP_LAYERS}
@@ -301,13 +301,17 @@ export function TguPlanningPage() {
} }
}; };
// Создать черновик-копию на основе существующего плана (replace, окно 1:1, все включения) и // Создать черновик-копию на основе существующего плана (replace, окно 1:1, все включения).
// открыть его в редакторе. Это путь «сделать исправленный план», а не повторная отправка того же. // Это путь «сделать исправленный план», а не повторная отправка того же. В редактор сами
// не уходим — оператор выберет черновик и перейдёт в «Редактор» вручную.
const handleCreateDraftFromPlan = async (planId: string) => { const handleCreateDraftFromPlan = async (planId: string) => {
// Один открытый черновик на базу: если он уже есть — открываем его, второй не плодим. // Один открытый черновик на базу: если он уже есть — выбираем его, второй не плодим.
const existing = drafts.find((item) => item.basePlanId === planId); const existing = drafts.find((item) => item.basePlanId === planId);
if (existing) { if (existing) {
handleEditDraft(existing.planId); setShowDrafts(true);
setSelectedSpacecraftId(existing.spacecraftId);
setSelectedPlanId(existing.planId);
setDecisionNotice("Черновик на основе этого плана уже создан — выбран на схеме.");
return; return;
} }
setDraftActionInFlight(true); setDraftActionInFlight(true);
@@ -320,7 +324,7 @@ export function TguPlanningPage() {
setShowDrafts(true); setShowDrafts(true);
if (base) setSelectedSpacecraftId(base.spacecraftId); if (base) setSelectedSpacecraftId(base.spacecraftId);
setSelectedPlanId(draftPlanId); setSelectedPlanId(draftPlanId);
setActiveTab("editor"); setDecisionNotice("Черновик создан и выбран на схеме. Откройте «Редактор», чтобы поправить и отправить его.");
} catch (error) { } catch (error) {
setDecisionError(error instanceof Error ? error.message : "Не удалось создать черновик на основе плана."); setDecisionError(error instanceof Error ? error.message : "Не удалось создать черновик на основе плана.");
} finally { } finally {
@@ -308,6 +308,20 @@
user-select: none; user-select: none;
} }
.map-pass-selector--empty {
display: flex;
align-items: baseline;
gap: 0.5rem;
padding: 0.4rem 0.65rem;
width: auto;
max-width: 28rem;
}
.map-pass-selector__empty-note {
color: var(--text-muted, #98a2ac);
font-size: 0.74rem;
}
.map-pass-selector__header { .map-pass-selector__header {
display: flex; display: flex;
align-items: baseline; align-items: baseline;