pcp-tgu: связь планов на таймлайне по basePlanId вместо стрелок/нумерации по времени

Стрелки и бейджи-цифры цепочки строились по ВРЕМЕНИ (соседние по времени
планы КА) и ложно сцепляли два черновика с одной предысторией как «один —
предок другого». Убираем эту логику целиком (buildSequentialLinks,
buildPlanChains, CHAIN_COLORS/chainColor, тип TimelineLink) и показываем
связь только по реальному basePlanId:

- В деталях плана — строка basePlanId (рендерится, если задан).
- При выборе плана на таймлайне подсвечиваем гало два соседних звена по
  basePlanId: предыдущий (planId == basePlanId выбранного) — тёплый янтарь,
  следующий (basePlanId == выбранный) — бирюза. Следующих может быть
  несколько (черновики с одной предысторией) — подсвечиваются все.

Бэкенд: basePlanId/projected проброшены через PlanResponse (pcp-tgu-service)
и TguPlanResponse (pcp-ui-service — прокси /api/tgu-planning/plans, иначе
Jackson срезал бы неизвестные поля). Требует рестарт обоих сервисов.

Заодно: очистка нотиса деталей при смене фокуса (план/ЗРВ) и блок
«Включения» (счётчики съёмок/сбросов) в правой панели.
This commit is contained in:
Дмитрий Соловьев
2026-06-04 11:44:28 +03:00
parent 294b17757d
commit a5a1cdfa64
10 changed files with 150 additions and 96 deletions
@@ -11,8 +11,16 @@ import { formatLocalDateTime, parseLocal } from "../../utils/localTime";
// config-repo/pcp-tgu-service.yaml → tgu.planning.waiting-decision-grace-minutes. // config-repo/pcp-tgu-service.yaml → tgu.planning.waiting-decision-grace-minutes.
const DECISION_GRACE_MINUTES = 30; const DECISION_GRACE_MINUTES = 30;
/** Состав включений плана: сколько съёмок и сбросов. undefined — ещё грузится / неизвестно. */
export type PlanModeCounts = {
survey: number;
drop: number;
};
type TguPlanDetailsProps = { type TguPlanDetailsProps = {
plan?: TguPlanUi; plan?: TguPlanUi;
/** Состав включений плана для блока «Включения» (съёмки/сбросы). */
modeCounts?: PlanModeCounts;
/** Цепочка КА на паузе после REJECTED — показываем подсказку про ручной исправленный план. */ /** Цепочка КА на паузе после REJECTED — показываем подсказку про ручной исправленный план. */
chainPaused?: boolean; chainPaused?: boolean;
decisionInFlight: boolean; decisionInFlight: boolean;
@@ -29,6 +37,7 @@ type TguPlanDetailsProps = {
export function TguPlanDetails({ export function TguPlanDetails({
plan, plan,
modeCounts,
chainPaused, chainPaused,
decisionInFlight, decisionInFlight,
notice, notice,
@@ -95,6 +104,13 @@ export function TguPlanDetails({
<Detail label="duration" value={duration} mono /> <Detail label="duration" value={duration} mono />
<Detail label="kppId" value={plan.kppId} mono /> <Detail label="kppId" value={plan.kppId} mono />
<Detail label="status" value={plan.status} mono /> <Detail label="status" value={plan.status} mono />
{plan.basePlanId && <Detail label="basePlanId" value={plan.basePlanId} mono />}
</dl>
<div className="tgu-details__eyebrow">Включения</div>
<dl className="tgu-details-grid">
<Detail label="съёмок" value={modeCounts ? String(modeCounts.survey) : "…"} mono />
<Detail label="сбросов" value={modeCounts ? String(modeCounts.drop) : "…"} mono />
</dl> </dl>
{plan.status === "DRAFT" && ( {plan.status === "DRAFT" && (
@@ -12,8 +12,9 @@ import {
promoteDraft, promoteDraft,
type ZrvWindowResponse type ZrvWindowResponse
} from "../../api/tguPlanApi"; } from "../../api/tguPlanApi";
import { fetchPlanModes } from "../tgu-editor/editorApi";
import { CreateChainPlanDialog } from "./CreateChainPlanDialog"; import { CreateChainPlanDialog } from "./CreateChainPlanDialog";
import { TguPlanDetails } from "./TguPlanDetails"; import { TguPlanDetails, type PlanModeCounts } from "./TguPlanDetails";
import { TguPlanningLayout } from "./TguPlanningLayout"; import { TguPlanningLayout } from "./TguPlanningLayout";
import { TguSidebar } from "./TguSidebar"; import { TguSidebar } from "./TguSidebar";
import { TguTimeline, type ZrvWindow } from "./TguTimeline"; import { TguTimeline, type ZrvWindow } from "./TguTimeline";
@@ -208,6 +209,32 @@ export function TguPlanningPage() {
return { ...plan, platform }; return { ...plan, platform };
}, [allPlans, platforms, selectedPlanId]); }, [allPlans, platforms, selectedPlanId]);
// Состав включений выбранного плана (счётчики съёмок/сбросов) для правой панели.
// planId == mission_id, поэтому грузим режимы плана тем же эндпоинтом, что и редактор.
const [planModeCounts, setPlanModeCounts] = useState<PlanModeCounts>();
useEffect(() => {
if (!selectedPlanId) {
setPlanModeCounts(undefined);
return;
}
let cancelled = false;
setPlanModeCounts(undefined);
void fetchPlanModes(selectedPlanId)
.then((modes) => {
if (cancelled) return;
setPlanModeCounts({
survey: modes.filter((mode) => mode.type === "SURVEY").length,
drop: modes.filter((mode) => mode.type === "DROP").length
});
})
.catch(() => {
if (!cancelled) setPlanModeCounts(undefined);
});
return () => {
cancelled = true;
};
}, [selectedPlanId]);
const applyRange = () => { const applyRange = () => {
setDecisionNotice(undefined); setDecisionNotice(undefined);
if (invalidRange) { if (invalidRange) {
@@ -351,6 +378,8 @@ export function TguPlanningPage() {
selectedPlanId={selectedPlanId} selectedPlanId={selectedPlanId}
onSelectPlan={(planId) => { onSelectPlan={(planId) => {
setZrvSelection(undefined); setZrvSelection(undefined);
setDecisionNotice(undefined);
setDecisionError(undefined);
setSelectedPlanId(planId); setSelectedPlanId(planId);
const plan = allPlans.find((item) => item.planId === planId); const plan = allPlans.find((item) => item.planId === planId);
if (plan) { if (plan) {
@@ -359,6 +388,8 @@ export function TguPlanningPage() {
}} }}
onSelectZrv={(windows, index) => { onSelectZrv={(windows, index) => {
setSelectedPlanId(undefined); setSelectedPlanId(undefined);
setDecisionNotice(undefined);
setDecisionError(undefined);
setZrvSelection({ windows, index }); setZrvSelection({ windows, index });
}} }}
/> />
@@ -373,6 +404,7 @@ export function TguPlanningPage() {
) : ( ) : (
<TguPlanDetails <TguPlanDetails
plan={selectedPlan} plan={selectedPlan}
modeCounts={planModeCounts}
chainPaused={selectedPlan ? paused.has(selectedPlan.spacecraftId) : false} chainPaused={selectedPlan ? paused.has(selectedPlan.spacecraftId) : false}
decisionInFlight={decisionInFlight} decisionInFlight={decisionInFlight}
notice={decisionNotice} notice={decisionNotice}
@@ -1,7 +1,6 @@
import { useLayoutEffect, useMemo, useRef, useState } from "react"; import { useLayoutEffect, useMemo, useRef, useState } from "react";
import type { TimelinePlanSegment, TimelineRange, TimelineRow } from "../../model/timelineTypes"; import type { TimelineRange, TimelineRow } from "../../model/timelineTypes";
import { import {
buildSequentialLinks,
buildTimeTicks, buildTimeTicks,
buildTimelineLayout, buildTimelineLayout,
TIMELINE_AXIS_HEIGHT, TIMELINE_AXIS_HEIGHT,
@@ -76,11 +75,11 @@ export function TguTimeline({
); );
const segments = layout.segments; const segments = layout.segments;
const height = Math.max(260, layout.height); const height = Math.max(260, layout.height);
const segmentByPlanId = useMemo( // basePlanId выбранного плана — чтобы подсветить его прошлое/будущее звено по РЕАЛЬНОЙ связи
() => new Map(segments.map((segment) => [segment.plan.planId, segment])), // (а не по времени): предыдущий = план с этим planId, следующий = план с basePlanId == выбранный.
[segments] const selectedBasePlanId = selectedPlanId
); ? segments.find((segment) => segment.plan.planId === selectedPlanId)?.plan.basePlanId ?? undefined
const links = useMemo(() => buildSequentialLinks(rows), [rows]); : undefined;
const ticks = useMemo(() => buildTimeTicks(range), [range]); const ticks = useMemo(() => buildTimeTicks(range), [range]);
const nowX = const nowX =
nowMs >= range.fromMs && nowMs <= range.toMs nowMs >= range.fromMs && nowMs <= range.toMs
@@ -95,12 +94,6 @@ export function TguTimeline({
<div className="tgu-empty">Нет планов в выбранном интервале.</div> <div className="tgu-empty">Нет планов в выбранном интервале.</div>
) : ( ) : (
<svg className="tgu-timeline__svg" viewBox={`0 0 ${fullWidth} ${height}`} style={{ height }} role="img" aria-label="Timeline планов ТГУ"> <svg className="tgu-timeline__svg" viewBox={`0 0 ${fullWidth} ${height}`} style={{ height }} role="img" aria-label="Timeline планов ТГУ">
<defs>
<marker id="tgu-arrow-head" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">
<path d="M0 0 L8 4 L0 8 z" fill="#8aa2a6" />
</marker>
</defs>
<rect x="0" y="0" width={fullWidth} height={height} className="tgu-timeline__background" /> <rect x="0" y="0" width={fullWidth} height={height} className="tgu-timeline__background" />
<rect x="0" y="0" width={TIMELINE_LABEL_WIDTH} height={height} className="tgu-timeline__labels-background" /> <rect x="0" y="0" width={TIMELINE_LABEL_WIDTH} height={height} className="tgu-timeline__labels-background" />
@@ -206,19 +199,21 @@ export function TguTimeline({
); );
})} })}
{links.map((link) => {
const from = segmentByPlanId.get(link.fromPlanId);
const to = segmentByPlanId.get(link.toPlanId);
if (!from || !to) return null;
return <TimelineArrow key={`${link.fromPlanId}-${link.toPlanId}`} from={from} to={to} />;
})}
{segments.map((segment) => { {segments.map((segment) => {
const y = segment.centerY; const y = segment.centerY;
const x1 = TIMELINE_LABEL_WIDTH + segment.x; const x1 = TIMELINE_LABEL_WIDTH + segment.x;
const x2 = TIMELINE_LABEL_WIDTH + segment.x + segment.width; const x2 = TIMELINE_LABEL_WIDTH + segment.x + segment.width;
const style = statusStyle(segment.plan.status); const style = statusStyle(segment.plan.status);
const selected = selectedPlanId === segment.plan.planId; const selected = selectedPlanId === segment.plan.planId;
// Сосед выбранного по цепочке: предыдущий (прошлое) — planId == basePlanId выбранного;
// следующий (будущее) — basePlanId == выбранный. Подсветка по реальной связи, не по времени.
const neighbor = selected
? undefined
: selectedBasePlanId && segment.plan.planId === selectedBasePlanId
? "prev"
: selectedPlanId && segment.plan.basePlanId === selectedPlanId
? "next"
: undefined;
const labelVisible = segment.width > 92; const labelVisible = segment.width > 92;
return ( return (
@@ -228,9 +223,19 @@ export function TguTimeline({
onClick={() => onSelectPlan(segment.plan.planId)} onClick={() => onSelectPlan(segment.plan.planId)}
> >
<title> <title>
{segment.plan.planId} · {segment.plan.status} · {formatDateTime(segment.plan.startTime)} -{" "} {segment.plan.planId} · {segment.plan.status}
{formatDateTime(segment.plan.endTime)} {neighbor === "prev" ? " · предыдущий в цепочке" : neighbor === "next" ? " · следующий в цепочке" : ""} ·{" "}
{formatDateTime(segment.plan.startTime)} - {formatDateTime(segment.plan.endTime)}
</title> </title>
{neighbor && (
<line
x1={x1}
x2={x2}
y1={y}
y2={y}
className={`tgu-timeline-plan__neighbor tgu-timeline-plan__neighbor--${neighbor}`}
/>
)}
{selected && <line x1={x1} x2={x2} y1={y} y2={y} className="tgu-timeline-plan__selection" />} {selected && <line x1={x1} x2={x2} y1={y} y2={y} className="tgu-timeline-plan__selection" />}
<line <line
x1={x1} x1={x1}
@@ -254,19 +259,6 @@ export function TguTimeline({
); );
} }
function TimelineArrow({ from, to }: { from: TimelinePlanSegment; to: TimelinePlanSegment }) {
const y1 = from.centerY;
const y2 = to.centerY;
const x1 = TIMELINE_LABEL_WIDTH + from.x + from.width + 4;
const x2 = TIMELINE_LABEL_WIDTH + to.x - 6;
const mid = x1 + Math.max(18, (x2 - x1) / 2);
const path = x2 > x1
? `M ${x1} ${y1} C ${mid} ${y1}, ${mid} ${y2}, ${x2} ${y2}`
: `M ${x1} ${y1} C ${x1 + 20} ${y1 - 18}, ${x2 - 20} ${y2 - 18}, ${x2} ${y2}`;
return <path d={path} className="tgu-timeline__arrow" markerEnd="url(#tgu-arrow-head)" />;
}
function formatTickDate(ms: number): string { function formatTickDate(ms: number): string {
return formatLocalDate(ms); return formatLocalDate(ms);
} }
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { TguPlanUi, TimelineRow } from "../../model/timelineTypes"; import type { TguPlanUi, TimelineRow } from "../../model/timelineTypes";
import { buildSequentialLinks, buildTimelineLayout, clipPlanToRange, timeToX } from "./tguTimelineLayout"; import { buildTimelineLayout, clipPlanToRange, timeToX } from "./tguTimelineLayout";
import { parseLocal } from "../../utils/localTime"; import { parseLocal } from "../../utils/localTime";
const range = { const range = {
@@ -8,7 +8,13 @@ const range = {
toMs: parseLocal("2026-05-30T00:00:00") toMs: parseLocal("2026-05-30T00:00:00")
}; };
function plan(id: string, startTime: string, endTime: string, spacecraftId = "56756"): TguPlanUi { function plan(
id: string,
startTime: string,
endTime: string,
spacecraftId = "56756",
extra: Partial<TguPlanUi> = {}
): TguPlanUi {
return { return {
planId: id, planId: id,
spacecraftId, spacecraftId,
@@ -17,7 +23,8 @@ function plan(id: string, startTime: string, endTime: string, spacecraftId = "56
kppId: "KPP-1", kppId: "KPP-1",
status: "PLANNED", status: "PLANNED",
startMs: parseLocal(startTime), startMs: parseLocal(startTime),
endMs: parseLocal(endTime) endMs: parseLocal(endTime),
...extra
}; };
} }
@@ -35,28 +42,6 @@ describe("tguTimelineLayout", () => {
}); });
}); });
it("builds links only between sequential plans of the same spacecraft", () => {
const rows: TimelineRow[] = [
{
spacecraftId: "56756",
plans: [
plan("p2", "2026-05-29T04:00:00", "2026-05-29T05:00:00"),
plan("p1", "2026-05-29T01:00:00", "2026-05-29T02:00:00")
]
},
{
spacecraftId: "777",
plans: [
plan("p3", "2026-05-29T01:00:00", "2026-05-29T02:00:00", "777")
]
}
];
expect(buildSequentialLinks(rows)).toEqual([
{ spacecraftId: "56756", fromPlanId: "p1", toPlanId: "p2" }
]);
});
it("keeps non-overlapping plans on a single lane", () => { it("keeps non-overlapping plans on a single lane", () => {
const rows: TimelineRow[] = [ const rows: TimelineRow[] = [
{ {
@@ -1,6 +1,5 @@
import type { import type {
TguPlanUi, TguPlanUi,
TimelineLink,
TimelinePlanSegment, TimelinePlanSegment,
TimelineRange, TimelineRange,
TimelineRow, TimelineRow,
@@ -118,23 +117,6 @@ export function buildTimelineLayout(
return { rowLayouts, segments, height: cursorY + TIMELINE_BOTTOM_PADDING }; return { rowLayouts, segments, height: cursorY + TIMELINE_BOTTOM_PADDING };
} }
export function buildSequentialLinks(rows: TimelineRow[]): TimelineLink[] {
const links: TimelineLink[] = [];
for (const row of rows) {
const sorted = [...row.plans].sort((a, b) => a.startMs - b.startMs || a.endMs - b.endMs);
for (let index = 0; index < sorted.length - 1; index += 1) {
links.push({
spacecraftId: row.spacecraftId,
fromPlanId: sorted[index].planId,
toPlanId: sorted[index + 1].planId
});
}
}
return links;
}
export function buildTimeTicks(range: TimelineRange, targetCount = 8): number[] { export function buildTimeTicks(range: TimelineRange, targetCount = 8): number[] {
const span = range.toMs - range.fromMs; const span = range.toMs - range.fromMs;
const roughStep = span / targetCount; const roughStep = span / targetCount;
@@ -16,6 +16,13 @@ export type TguPlan = {
endTime: string; endTime: string;
kppId: string; kppId: string;
status: TguPlanStatus; status: TguPlanStatus;
/**
* Предшественник плана в цепочке. `null`/отсутствует — у плана нет предыстории (старт новой
* цепочки «без истории» или самый первый план): на таймлайне в него не ведут стрелку.
*/
basePlanId?: string | null;
/** `true` — вычисленная проекция будущего (не persisted); цепочку продолжает, но стартом не является. */
projected?: boolean;
}; };
export type TguPlatform = { export type TguPlatform = {
@@ -54,12 +54,6 @@ export type TimelineRowLayout = {
zrvBandY: number; zrvBandY: number;
}; };
export type TimelineLink = {
spacecraftId: string;
fromPlanId: string;
toPlanId: string;
};
export type TimelineStatusStyle = { export type TimelineStatusStyle = {
status: TguPlanStatus; status: TguPlanStatus;
label: string; label: string;
@@ -422,13 +422,6 @@
font-weight: 700; font-weight: 700;
} }
.tgu-timeline__arrow {
fill: none;
stroke: #8aa2a6;
stroke-width: 1.5;
opacity: 0.8;
}
.tgu-timeline-plan { .tgu-timeline-plan {
cursor: pointer; cursor: pointer;
} }
@@ -450,6 +443,21 @@
opacity: 0.85; opacity: 0.85;
} }
/* Гало вокруг соседнего по цепочке плана при выборе: прошлое — тёплый янтарь, будущее — бирюза. */
.tgu-timeline-plan__neighbor {
stroke-linecap: round;
stroke-width: 19;
opacity: 0.7;
}
.tgu-timeline-plan__neighbor--prev {
stroke: #e0a23c;
}
.tgu-timeline-plan__neighbor--next {
stroke: #46b3c4;
}
.tgu-timeline-plan__label { .tgu-timeline-plan__label {
fill: #101214; fill: #101214;
font-size: 11px; font-size: 11px;
@@ -916,6 +924,22 @@
pointer-events: none; pointer-events: none;
} }
.tgu-2d-map__coords {
position: absolute;
left: 50%;
bottom: 0.5rem;
transform: translateX(-50%);
border-radius: 4px;
padding: 0.2rem 0.55rem;
background: rgba(8, 16, 15, 0.72);
color: var(--text);
font-family: "SFMono-Regular", Consolas, monospace;
font-size: 0.66rem;
letter-spacing: 0.02em;
white-space: nowrap;
pointer-events: none;
}
.tgu-2d-map__zoom { .tgu-2d-map__zoom {
position: absolute; position: absolute;
right: 0.75rem; right: 0.75rem;
@@ -31,7 +31,21 @@ data class PlanResponse(
val kppId: String, val kppId: String,
/** Статус persisted плана в жизненном цикле выдачи. */ /** Статус persisted плана в жизненном цикле выдачи. */
val status: PlannedPlanStatus val status: PlannedPlanStatus,
/**
* Непосредственный предшественник плана в цепочке (`base_plan_id`). `null` — у плана нет
* предыстории: это начало новой цепочки (создан «без истории») либо самый первый план.
* Фронт по этому полю решает, рисовать ли входящую стрелку: в старт цепочки она не ведёт.
*/
val basePlanId: UUID? = null,
/**
* `true` — это вычисленная на лету проекция будущего (в БД не записана), а не persisted-план.
* У проекции предшественник не хранится, но цепочку она продолжает — фронт связывает её
* соседние звенья по времени, поэтому начало цепочки определяется ТОЛЬКО для persisted.
*/
val projected: Boolean = false
) { ) {
companion object { companion object {
@@ -46,7 +60,9 @@ data class PlanResponse(
startTime = plan.startTime, startTime = plan.startTime,
endTime = plan.endTime, endTime = plan.endTime,
kppId = plan.kppId, kppId = plan.kppId,
status = plan.status status = plan.status,
basePlanId = plan.basePlanId,
projected = false
) )
/** /**
@@ -62,7 +78,9 @@ data class PlanResponse(
startTime = plan.startTime, startTime = plan.startTime,
endTime = plan.endTime, endTime = plan.endTime,
kppId = plan.kppId, kppId = plan.kppId,
status = PlannedPlanStatus.PLANNED status = PlannedPlanStatus.PLANNED,
basePlanId = null,
projected = true
) )
private fun deterministicPlanId(plan: CalculatedPlan): UUID { private fun deterministicPlanId(plan: CalculatedPlan): UUID {
@@ -26,7 +26,11 @@ data class TguPlanResponse(
val startTime: LocalDateTime, val startTime: LocalDateTime,
val endTime: LocalDateTime, val endTime: LocalDateTime,
val kppId: String, val kppId: String,
val status: String val status: String,
/** Предшественник в цепочке (`null` — старт без предыстории). Проксируется из pcp-tgu-service. */
val basePlanId: UUID? = null,
/** `true` — проекция будущего (не persisted). Проксируется из pcp-tgu-service. */
val projected: Boolean = false
) )
enum class TguPlanDecision { enum class TguPlanDecision {