Files
dc-observatio/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguTimeline.tsx
T
Дмитрий Соловьев 5049395911 pcp-tgu: пауза цепочки после REJECTED + режим «Черновики» + UI цепочек/ЗРВ
Бэкенд (pcp-tgu-service):
- REJECTED трактуется как «план неправильный»: авто-перевыдача больше НЕ
  взводится, цепочка встаёт на паузу (новый флаг
  SpacecraftPlanningStateEntity.pausedAfterReject, миграция V13). Технические
  негативы («не заложен»/«не принят») по-прежнему дают срочную перевыдачу.
- Пауза централизована в PlanProjectionService.project(): для отклонённой
  цепочки проекция пустая, воркер выдачи ничего не материализует. Флаг
  снимается при ручной выдаче/промоуте черновика/приёмке (оператор вмешался).
- Режим «Черновики»: GET /plans/drafts (опц. ?spacecraftId) -> только DRAFT.
- Гард дублей черновиков: (КА + начало + конец) уникальны среди DRAFT,
  повторное создание окна -> 409.
- ZrvWindowMatcher: сравнение по истинной abs-длительности Duration (<=1с),
  устойчиво к усечению ISO на фронте до целых секунд.

Фронт (pcp-tgu-ops-ui): вкладка «Планы» — таймлайн цепочек, детали плана,
панель ЗРВ (TguZrvPanel), диалог создания цепочки, режим черновиков.

Удалён устаревший docs/tasks/TASK_tgu-plan-versioning-manual-reissue.md.
2026-06-04 00:04:47 +03:00

281 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useLayoutEffect, useMemo, useRef, useState } from "react";
import type { TimelinePlanSegment, TimelineRange, TimelineRow } from "../../model/timelineTypes";
import {
buildSequentialLinks,
buildTimeTicks,
buildTimelineLayout,
TIMELINE_AXIS_HEIGHT,
TIMELINE_BOTTOM_PADDING,
TIMELINE_LABEL_WIDTH,
TIMELINE_ZRV_BAND_HEIGHT,
timeToX
} from "./tguTimelineLayout";
import { platformLabel } from "./tguTimelineMapper";
import { statusStyle } from "./tguStatus";
import { formatUtcDate, formatUtcDateTime, formatUtcTime, parseUtc } from "../../utils/utcTime";
export type ZrvWindow = { startMs: number; endMs: number; kppId: string };
type ZrvBands = {
spacecraftId: string;
windows: ZrvWindow[];
};
type TguTimelineProps = {
rows: TimelineRow[];
range: TimelineRange;
selectedPlanId?: string;
invalidRange?: boolean;
/** ЗРВ выбранного КА — подсветка доступных зон на его строке. */
zrvBands?: ZrvBands;
/** id выбранной ЗРВ (`${kppId}|${startMs}`) — подсветка кликнутой зоны. */
selectedZrvId?: string;
/** КА с цепочкой на паузе после REJECTED — рисуем пометку у имени КА. */
pausedSpacecraftIds?: ReadonlySet<string>;
onSelectPlan: (planId: string) => void;
/** Клик по ЗРВ: передаём пересекающиеся в этой точке зоны и индекс кликнутой. */
onSelectZrv?: (windows: ZrvWindow[], selectedIndex: number) => void;
};
function zrvId(zrv: ZrvWindow): string {
return `${zrv.kppId}|${zrv.startMs}`;
}
export function TguTimeline({
rows,
range,
selectedPlanId,
invalidRange,
zrvBands,
selectedZrvId,
pausedSpacecraftIds,
onSelectPlan,
onSelectZrv
}: TguTimelineProps) {
const containerRef = useRef<HTMLDivElement | null>(null);
const [containerWidth, setContainerWidth] = useState(1200);
const timelineWidth = Math.max(720, containerWidth - TIMELINE_LABEL_WIDTH - 28);
const fullWidth = TIMELINE_LABEL_WIDTH + timelineWidth;
const nowMs = Date.now();
useLayoutEffect(() => {
if (!containerRef.current) return;
setContainerWidth(Math.floor(containerRef.current.getBoundingClientRect().width));
const observer = new ResizeObserver(([entry]) => {
setContainerWidth(Math.floor(entry.contentRect.width));
});
observer.observe(containerRef.current);
return () => observer.disconnect();
}, []);
const layout = useMemo(
() => buildTimelineLayout(rows, range, timelineWidth),
[rows, range, timelineWidth]
);
const segments = layout.segments;
const height = Math.max(260, layout.height);
const segmentByPlanId = useMemo(
() => new Map(segments.map((segment) => [segment.plan.planId, segment])),
[segments]
);
const links = useMemo(() => buildSequentialLinks(rows), [rows]);
const ticks = useMemo(() => buildTimeTicks(range), [range]);
const nowX =
nowMs >= range.fromMs && nowMs <= range.toMs
? TIMELINE_LABEL_WIDTH + timeToX(nowMs, range, timelineWidth)
: undefined;
return (
<div className="tgu-timeline" ref={containerRef}>
{invalidRange ? (
<div className="tgu-empty">Некорректный интервал: from должен быть меньше to.</div>
) : rows.length === 0 || segments.length === 0 ? (
<div className="tgu-empty">Нет планов в выбранном интервале.</div>
) : (
<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={TIMELINE_LABEL_WIDTH} height={height} className="tgu-timeline__labels-background" />
<line
x1={TIMELINE_LABEL_WIDTH}
x2={fullWidth}
y1={TIMELINE_AXIS_HEIGHT}
y2={TIMELINE_AXIS_HEIGHT}
className="tgu-timeline__axis"
/>
{ticks.map((tick) => {
const x = TIMELINE_LABEL_WIDTH + timeToX(tick, range, timelineWidth);
return (
<g key={tick}>
<line x1={x} x2={x} y1="0" y2={height - TIMELINE_BOTTOM_PADDING} className="tgu-timeline__grid" />
<text x={x + 6} y="18" className="tgu-timeline__tick-date">
{formatTickDate(tick)}
</text>
<text x={x + 6} y="34" className="tgu-timeline__tick-time">
{formatTickTime(tick)}
</text>
</g>
);
})}
{nowX !== undefined && (
<g>
<line x1={nowX} x2={nowX} y1="0" y2={height - TIMELINE_BOTTOM_PADDING} className="tgu-timeline__now" />
<text x={nowX + 7} y="16" className="tgu-timeline__now-label">
сейчас
</text>
</g>
)}
{layout.rowLayouts.map(({ row, rowIndex, y, height: rowHeight, zrvBandY }) => {
return (
<g key={row.spacecraftId}>
<rect
x="0"
y={y}
width={fullWidth}
height={rowHeight}
className={rowIndex % 2 === 0 ? "tgu-timeline__row" : "tgu-timeline__row tgu-timeline__row--alt"}
/>
<text x="16" y={y + 22} className="tgu-timeline__row-title">
{platformLabel(row.platform, row.spacecraftId)}
</text>
<text x="16" y={y + 38} className="tgu-timeline__row-meta">
NORAD {row.platform?.noradId || row.spacecraftId}
</text>
{pausedSpacecraftIds?.has(row.spacecraftId) && (
<text
x={TIMELINE_LABEL_WIDTH - 12}
y={y + 22}
textAnchor="end"
className="tgu-timeline__paused-badge"
>
на паузе
<title>Цепочка на паузе: план отклонён создайте исправленный план («Создать план»).</title>
</text>
)}
{zrvBands?.spacecraftId === row.spacecraftId && (
<>
<text x="16" y={zrvBandY + TIMELINE_ZRV_BAND_HEIGHT - 3} className="tgu-timeline__zrv-label">
ЗРВ
</text>
{zrvBands.windows.map((zrv, zrvIndex) => {
const from = Math.max(zrv.startMs, range.fromMs);
const to = Math.min(zrv.endMs, range.toMs);
if (to <= from) return null;
const bx = TIMELINE_LABEL_WIDTH + timeToX(from, range, timelineWidth);
const bw = Math.max(2, timeToX(to, range, timelineWidth) - timeToX(from, range, timelineWidth));
const id = zrvId(zrv);
return (
<rect
key={`zrv-${zrvIndex}`}
className={`tgu-timeline__zrv${selectedZrvId === id ? " is-selected" : ""}`}
x={bx}
y={zrvBandY}
width={bw}
height={TIMELINE_ZRV_BAND_HEIGHT}
style={onSelectZrv ? { cursor: "pointer" } : undefined}
onClick={(event) => {
if (!onSelectZrv) return;
event.stopPropagation();
// Зоны «в этой точке» — все ЗРВ, пересекающие кликнутую по времени
// (разные КПП могут перекрываться), как «заявки в точке» на карте.
const overlapping = zrvBands.windows.filter(
(w) => w.startMs < zrv.endMs && w.endMs > zrv.startMs
);
const idx = overlapping.findIndex((w) => zrvId(w) === id);
onSelectZrv(overlapping, Math.max(0, idx));
}}
>
<title>ЗРВ {zrv.kppId} · {formatUtcDateTime(zrv.startMs)} {formatUtcDateTime(zrv.endMs)}</title>
</rect>
);
})}
</>
)}
</g>
);
})}
{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) => {
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;
const labelVisible = segment.width > 92;
return (
<g
key={segment.plan.planId}
className={`tgu-timeline-plan ${selected ? "is-selected" : ""}`}
onClick={() => onSelectPlan(segment.plan.planId)}
>
<title>
{segment.plan.planId} · {segment.plan.status} · {formatDateTime(segment.plan.startTime)} -{" "}
{formatDateTime(segment.plan.endTime)}
</title>
{selected && <line x1={x1} x2={x2} y1={y} y2={y} className="tgu-timeline-plan__selection" />}
<line
x1={x1}
x2={x2}
y1={y}
y2={y}
className={`tgu-timeline-plan__bar ${style.className}`}
stroke={style.color}
/>
{labelVisible && (
<text x={x1 + 10} y={y + 4} className="tgu-timeline-plan__label">
{segment.plan.kppId} · {segment.plan.status}
</text>
)}
</g>
);
})}
</svg>
)}
</div>
);
}
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 {
return formatUtcDate(ms);
}
function formatTickTime(ms: number): string {
return formatUtcTime(ms);
}
function formatDateTime(value: string): string {
return formatUtcDateTime(parseUtc(value));
}