9f1756a9f9
Индикатор «проблема КА» теперь означает НЕразрешённый отказ, а не «есть хоть один REJECTED»: unresolvedProblemSpacecraftIds не красит КА, если проблемный план снят с учёта (dismissedAt) либо цепочка уже продвинулась за него (advanced-план со стартом не раньше). Исправленные и снятые отказы гаснут сами. - кнопка «Снять с учёта» / «Вернуть на учёт» в деталях плана для терминально-негативных статусов + бейдж «снят с учёта»; - dismissPlan/restorePlan (через BFF), поле dismissedAt в модели; - снятый план приглушён и штрихуется на таймлайне; - тесты на unresolvedProblemSpacecraftIds.
300 lines
14 KiB
TypeScript
300 lines
14 KiB
TypeScript
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));
|
||
}
|