Files
dc-observatio/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguTimeline.tsx
T
Дмитрий Соловьев 9f1756a9f9 pcp-tgu-ops-ui: снятие плана с учёта + индикатор КА = нерешённый отказ
Индикатор «проблема КА» теперь означает НЕразрешённый отказ, а не «есть
хоть один REJECTED»: unresolvedProblemSpacecraftIds не красит КА, если
проблемный план снят с учёта (dismissedAt) либо цепочка уже продвинулась
за него (advanced-план со стартом не раньше). Исправленные и снятые
отказы гаснут сами.

- кнопка «Снять с учёта» / «Вернуть на учёт» в деталях плана для
  терминально-негативных статусов + бейдж «снят с учёта»;
- dismissPlan/restorePlan (через BFF), поле dismissedAt в модели;
- снятый план приглушён и штрихуется на таймлайне;
- тесты на unresolvedProblemSpacecraftIds.
2026-06-04 16:59:28 +03:00

300 lines
14 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 { TimelineRange, TimelineRow } from "../../model/timelineTypes";
import {
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 { formatLocalDate, formatLocalDateTime, formatLocalTime, parseLocal } from "../../utils/localTime";
export type ZrvWindow = { startMs: number; endMs: number; kppId: string };
// Терминально-«мёртвые» статусы: план выбыл из живой цепочки (вытеснен закладкой, отклонён,
// не принят, сбой закладки, неоднозначный старт). Применяется ТОЛЬКО к «следующему» (обратная
// ссылка basePlanId → выбранный, она неоднозначна): мёртвый план — это, как правило, коррекция,
// проигравшая гонку закладки и оставшаяся со ссылкой base_plan_id на победителя (особенно старые
// строки до наследования предшественника), и без фильтра она ложно показывалась бы «следующей».
// На «предыдущий» НЕ распространяется: это явная база самого выбранного плана (forward-ссылка из
// его поля basePlanId) — её показываем всегда, даже если база REJECTED/SUPERSEDED.
const DEAD_LINK_STATUSES: ReadonlySet<string> = new Set([
"SUPERSEDED",
"REJECTED",
"LAYIN_FAILED",
"NOT_ACCEPTED",
"START_AMBIGUOUS"
]);
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);
// 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
? 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 планов ТГУ">
<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} · {formatLocalDateTime(zrv.startMs)} {formatLocalDateTime(zrv.endMs)}</title>
</rect>
);
})}
</>
)}
</g>
);
})}
{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 == выбранный. Подсветка по реальной связи, не по времени.
// prev — явная база выбранного плана (forward-ссылка), показываем всегда, даже мёртвую.
// next — обратная ссылка; мёртвый план (вытесненный/отклонённый конкурент того же слота)
// живым продолжением не подсвечиваем.
const liveLink = !DEAD_LINK_STATUSES.has(segment.plan.status);
const neighbor = selected
? undefined
: selectedBasePlanId && segment.plan.planId === selectedBasePlanId
? "prev"
: selectedPlanId && liveLink && segment.plan.basePlanId === selectedPlanId
? "next"
: undefined;
const labelVisible = segment.width > 92;
// При активном выборе все «непричастные» планы гасим до еле заметных, а текущий /
// прошлый / следующий обводим яркой рамкой — так видно звенья цепочки выбранного плана.
const dimmed = selectedPlanId !== undefined && !selected && neighbor === undefined;
// Снят с учёта: гасим план на таймлайне (но не сильнее, чем выбор-затемнение).
const dismissed = Boolean(segment.plan.dismissedAt);
return (
<g
key={segment.plan.planId}
className={`tgu-timeline-plan ${selected ? "is-selected" : ""} ${dimmed ? "is-dimmed" : ""} ${
dismissed ? "is-dismissed-plan" : ""
}`}
onClick={() => onSelectPlan(segment.plan.planId)}
>
<title>
{segment.plan.planId} · {segment.plan.status}
{dismissed ? " · снят с учёта" : ""}
{neighbor === "prev" ? " · предыдущий в цепочке" : neighbor === "next" ? " · следующий в цепочке" : ""} ·{" "}
{formatDateTime(segment.plan.startTime)} - {formatDateTime(segment.plan.endTime)}
</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" />}
<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 formatTickDate(ms: number): string {
return formatLocalDate(ms);
}
function formatTickTime(ms: number): string {
return formatLocalTime(ms);
}
function formatDateTime(value: string): string {
return formatLocalDateTime(parseLocal(value));
}