From a5a1cdfa646c86015c4e6d33bff38581eaba56e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9=20=D0=A1=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D1=8C=D0=B5=D0=B2?= Date: Thu, 4 Jun 2026 11:44:28 +0300 Subject: [PATCH] =?UTF-8?q?pcp-tgu:=20=D1=81=D0=B2=D1=8F=D0=B7=D1=8C=20?= =?UTF-8?q?=D0=BF=D0=BB=D0=B0=D0=BD=D0=BE=D0=B2=20=D0=BD=D0=B0=20=D1=82?= =?UTF-8?q?=D0=B0=D0=B9=D0=BC=D0=BB=D0=B0=D0=B9=D0=BD=D0=B5=20=D0=BF=D0=BE?= =?UTF-8?q?=20basePlanId=20=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=BE=20=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=B5=D0=BB=D0=BE=D0=BA/=D0=BD=D1=83=D0=BC=D0=B5?= =?UTF-8?q?=D1=80=D0=B0=D1=86=D0=B8=D0=B8=20=D0=BF=D0=BE=20=D0=B2=D1=80?= =?UTF-8?q?=D0=B5=D0=BC=D0=B5=D0=BD=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Стрелки и бейджи-цифры цепочки строились по ВРЕМЕНИ (соседние по времени планы КА) и ложно сцепляли два черновика с одной предысторией как «один — предок другого». Убираем эту логику целиком (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 срезал бы неизвестные поля). Требует рестарт обоих сервисов. Заодно: очистка нотиса деталей при смене фокуса (план/ЗРВ) и блок «Включения» (счётчики съёмок/сбросов) в правой панели. --- .../features/tgu-planning/TguPlanDetails.tsx | 16 +++++ .../features/tgu-planning/TguPlanningPage.tsx | 34 +++++++++- .../src/features/tgu-planning/TguTimeline.tsx | 62 ++++++++----------- .../tgu-planning/tguTimelineLayout.test.ts | 35 +++-------- .../tgu-planning/tguTimelineLayout.ts | 18 ------ services/pcp-tgu-ops-ui/src/model/tguTypes.ts | 7 +++ .../pcp-tgu-ops-ui/src/model/timelineTypes.ts | 6 -- .../src/styles/tgu-planning.css | 38 +++++++++--- .../pcp_tgu_service/dto/PlanResponse.kt | 24 ++++++- .../slots_service/dto/tgu/TguPlanningDTO.kt | 6 +- 10 files changed, 150 insertions(+), 96 deletions(-) diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanDetails.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanDetails.tsx index 3ae267b..91b05bd 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanDetails.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanDetails.tsx @@ -11,8 +11,16 @@ import { formatLocalDateTime, parseLocal } from "../../utils/localTime"; // config-repo/pcp-tgu-service.yaml → tgu.planning.waiting-decision-grace-minutes. const DECISION_GRACE_MINUTES = 30; +/** Состав включений плана: сколько съёмок и сбросов. undefined — ещё грузится / неизвестно. */ +export type PlanModeCounts = { + survey: number; + drop: number; +}; + type TguPlanDetailsProps = { plan?: TguPlanUi; + /** Состав включений плана для блока «Включения» (съёмки/сбросы). */ + modeCounts?: PlanModeCounts; /** Цепочка КА на паузе после REJECTED — показываем подсказку про ручной исправленный план. */ chainPaused?: boolean; decisionInFlight: boolean; @@ -29,6 +37,7 @@ type TguPlanDetailsProps = { export function TguPlanDetails({ plan, + modeCounts, chainPaused, decisionInFlight, notice, @@ -95,6 +104,13 @@ export function TguPlanDetails({ + {plan.basePlanId && } + + +
Включения
+
+ +
{plan.status === "DRAFT" && ( diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningPage.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningPage.tsx index 368888d..75d0b68 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningPage.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningPage.tsx @@ -12,8 +12,9 @@ import { promoteDraft, type ZrvWindowResponse } from "../../api/tguPlanApi"; +import { fetchPlanModes } from "../tgu-editor/editorApi"; import { CreateChainPlanDialog } from "./CreateChainPlanDialog"; -import { TguPlanDetails } from "./TguPlanDetails"; +import { TguPlanDetails, type PlanModeCounts } from "./TguPlanDetails"; import { TguPlanningLayout } from "./TguPlanningLayout"; import { TguSidebar } from "./TguSidebar"; import { TguTimeline, type ZrvWindow } from "./TguTimeline"; @@ -208,6 +209,32 @@ export function TguPlanningPage() { return { ...plan, platform }; }, [allPlans, platforms, selectedPlanId]); + // Состав включений выбранного плана (счётчики съёмок/сбросов) для правой панели. + // planId == mission_id, поэтому грузим режимы плана тем же эндпоинтом, что и редактор. + const [planModeCounts, setPlanModeCounts] = useState(); + 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 = () => { setDecisionNotice(undefined); if (invalidRange) { @@ -351,6 +378,8 @@ export function TguPlanningPage() { selectedPlanId={selectedPlanId} onSelectPlan={(planId) => { setZrvSelection(undefined); + setDecisionNotice(undefined); + setDecisionError(undefined); setSelectedPlanId(planId); const plan = allPlans.find((item) => item.planId === planId); if (plan) { @@ -359,6 +388,8 @@ export function TguPlanningPage() { }} onSelectZrv={(windows, index) => { setSelectedPlanId(undefined); + setDecisionNotice(undefined); + setDecisionError(undefined); setZrvSelection({ windows, index }); }} /> @@ -373,6 +404,7 @@ export function TguPlanningPage() { ) : ( new Map(segments.map((segment) => [segment.plan.planId, segment])), - [segments] - ); - const links = useMemo(() => buildSequentialLinks(rows), [rows]); + // basePlanId выбранного плана — чтобы подсветить его прошлое/будущее звено по РЕАЛЬНОЙ связи + // (а не по времени): предыдущий = план с этим planId, следующий = план с basePlanId == выбранный. + const selectedBasePlanId = selectedPlanId + ? segments.find((segment) => segment.plan.planId === selectedPlanId)?.plan.basePlanId ?? undefined + : undefined; const ticks = useMemo(() => buildTimeTicks(range), [range]); const nowX = nowMs >= range.fromMs && nowMs <= range.toMs @@ -95,12 +94,6 @@ export function TguTimeline({
Нет планов в выбранном интервале.
) : ( - - - - - - @@ -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 ; - })} - {segments.map((segment) => { const y = segment.centerY; const x1 = TIMELINE_LABEL_WIDTH + segment.x; const x2 = TIMELINE_LABEL_WIDTH + segment.x + segment.width; const style = statusStyle(segment.plan.status); 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; return ( @@ -228,9 +223,19 @@ export function TguTimeline({ onClick={() => onSelectPlan(segment.plan.planId)} > - {segment.plan.planId} · {segment.plan.status} · {formatDateTime(segment.plan.startTime)} -{" "} - {formatDateTime(segment.plan.endTime)} + {segment.plan.planId} · {segment.plan.status} + {neighbor === "prev" ? " · предыдущий в цепочке" : neighbor === "next" ? " · следующий в цепочке" : ""} ·{" "} + {formatDateTime(segment.plan.startTime)} - {formatDateTime(segment.plan.endTime)} + {neighbor && ( + + )} {selected && } 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 ; -} - function formatTickDate(ms: number): string { return formatLocalDate(ms); } diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguTimelineLayout.test.ts b/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguTimelineLayout.test.ts index 55ad041..675a51d 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguTimelineLayout.test.ts +++ b/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguTimelineLayout.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; 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"; const range = { @@ -8,7 +8,13 @@ const range = { 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 { return { planId: id, spacecraftId, @@ -17,7 +23,8 @@ function plan(id: string, startTime: string, endTime: string, spacecraftId = "56 kppId: "KPP-1", status: "PLANNED", 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", () => { const rows: TimelineRow[] = [ { diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguTimelineLayout.ts b/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguTimelineLayout.ts index 6631b06..812e60d 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguTimelineLayout.ts +++ b/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguTimelineLayout.ts @@ -1,6 +1,5 @@ import type { TguPlanUi, - TimelineLink, TimelinePlanSegment, TimelineRange, TimelineRow, @@ -118,23 +117,6 @@ export function buildTimelineLayout( 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[] { const span = range.toMs - range.fromMs; const roughStep = span / targetCount; diff --git a/services/pcp-tgu-ops-ui/src/model/tguTypes.ts b/services/pcp-tgu-ops-ui/src/model/tguTypes.ts index bea1f4f..871a3df 100644 --- a/services/pcp-tgu-ops-ui/src/model/tguTypes.ts +++ b/services/pcp-tgu-ops-ui/src/model/tguTypes.ts @@ -16,6 +16,13 @@ export type TguPlan = { endTime: string; kppId: string; status: TguPlanStatus; + /** + * Предшественник плана в цепочке. `null`/отсутствует — у плана нет предыстории (старт новой + * цепочки «без истории» или самый первый план): на таймлайне в него не ведут стрелку. + */ + basePlanId?: string | null; + /** `true` — вычисленная проекция будущего (не persisted); цепочку продолжает, но стартом не является. */ + projected?: boolean; }; export type TguPlatform = { diff --git a/services/pcp-tgu-ops-ui/src/model/timelineTypes.ts b/services/pcp-tgu-ops-ui/src/model/timelineTypes.ts index 9b5d3a8..8db7416 100644 --- a/services/pcp-tgu-ops-ui/src/model/timelineTypes.ts +++ b/services/pcp-tgu-ops-ui/src/model/timelineTypes.ts @@ -54,12 +54,6 @@ export type TimelineRowLayout = { zrvBandY: number; }; -export type TimelineLink = { - spacecraftId: string; - fromPlanId: string; - toPlanId: string; -}; - export type TimelineStatusStyle = { status: TguPlanStatus; label: string; diff --git a/services/pcp-tgu-ops-ui/src/styles/tgu-planning.css b/services/pcp-tgu-ops-ui/src/styles/tgu-planning.css index a26e885..361dae6 100644 --- a/services/pcp-tgu-ops-ui/src/styles/tgu-planning.css +++ b/services/pcp-tgu-ops-ui/src/styles/tgu-planning.css @@ -422,13 +422,6 @@ font-weight: 700; } -.tgu-timeline__arrow { - fill: none; - stroke: #8aa2a6; - stroke-width: 1.5; - opacity: 0.8; -} - .tgu-timeline-plan { cursor: pointer; } @@ -450,6 +443,21 @@ 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 { fill: #101214; font-size: 11px; @@ -916,6 +924,22 @@ 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 { position: absolute; right: 0.75rem; diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/PlanResponse.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/PlanResponse.kt index 71b21a1..f7e3fd3 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/PlanResponse.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/PlanResponse.kt @@ -31,7 +31,21 @@ data class PlanResponse( val kppId: String, /** Статус persisted плана в жизненном цикле выдачи. */ - val status: PlannedPlanStatus + val status: PlannedPlanStatus, + + /** + * Непосредственный предшественник плана в цепочке (`base_plan_id`). `null` — у плана нет + * предыстории: это начало новой цепочки (создан «без истории») либо самый первый план. + * Фронт по этому полю решает, рисовать ли входящую стрелку: в старт цепочки она не ведёт. + */ + val basePlanId: UUID? = null, + + /** + * `true` — это вычисленная на лету проекция будущего (в БД не записана), а не persisted-план. + * У проекции предшественник не хранится, но цепочку она продолжает — фронт связывает её + * соседние звенья по времени, поэтому начало цепочки определяется ТОЛЬКО для persisted. + */ + val projected: Boolean = false ) { companion object { @@ -46,7 +60,9 @@ data class PlanResponse( startTime = plan.startTime, endTime = plan.endTime, kppId = plan.kppId, - status = plan.status + status = plan.status, + basePlanId = plan.basePlanId, + projected = false ) /** @@ -62,7 +78,9 @@ data class PlanResponse( startTime = plan.startTime, endTime = plan.endTime, kppId = plan.kppId, - status = PlannedPlanStatus.PLANNED + status = PlannedPlanStatus.PLANNED, + basePlanId = null, + projected = true ) private fun deterministicPlanId(plan: CalculatedPlan): UUID { diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/tgu/TguPlanningDTO.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/tgu/TguPlanningDTO.kt index ee32dcf..65fd2f9 100644 --- a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/tgu/TguPlanningDTO.kt +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/tgu/TguPlanningDTO.kt @@ -26,7 +26,11 @@ data class TguPlanResponse( val startTime: LocalDateTime, val endTime: LocalDateTime, 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 {