pcp-tgu интерфейс

This commit is contained in:
Дмитрий Соловьев
2026-05-29 16:09:36 +03:00
parent 913b3d7c9b
commit 9fca4d4051
34 changed files with 3163 additions and 0 deletions
@@ -0,0 +1,15 @@
import { STATUS_STYLES } from "./tguStatus";
export function TguLegend() {
return (
<div className="tgu-legend" aria-label="Легенда статусов">
{STATUS_STYLES.map((item) => (
<span className="tgu-legend__item" key={item.label}>
<span className={`tgu-legend__swatch ${item.className}`} />
{item.label}
</span>
))}
<span className="tgu-legend__note">Стрелки показывают следующий план того же КА по времени.</span>
</div>
);
}
@@ -0,0 +1,102 @@
import type { TguPlanDecision } from "../../model/tguTypes";
import type { TguPlanUi } from "../../model/timelineTypes";
import { platformLabel } from "./tguTimelineMapper";
import { statusStyle } from "./tguStatus";
type TguPlanDetailsProps = {
plan?: TguPlanUi;
decisionInFlight: boolean;
notice?: string;
error?: string;
onDecision: (planId: string, decision: TguPlanDecision, reason?: string) => void;
onClose: () => void;
};
export function TguPlanDetails({ plan, decisionInFlight, notice, error, onDecision, onClose }: TguPlanDetailsProps) {
if (!plan) {
return (
<aside className="tgu-details tgu-details--empty">
<div className="tgu-details__placeholder">
<span className="tgu-details__placeholder-icon" aria-hidden="true" />
<span>Выберите plan bar на timeline.</span>
</div>
</aside>
);
}
const style = statusStyle(plan.status);
const duration = formatDuration(plan.endMs - plan.startMs);
const reject = () => {
const reason = window.prompt("Reason для REJECTED", "UI_TEST_REJECTED");
if (reason === null) return;
onDecision(plan.planId, "REJECTED", reason);
};
return (
<aside className="tgu-details">
<div className="tgu-details__header">
<div>
<div className="tgu-details__eyebrow">Выбранный план</div>
<h2>{plan.planId}</h2>
</div>
<button className="tgu-icon-button" type="button" onClick={onClose} aria-label="Закрыть детали">
x
</button>
</div>
<div className={`tgu-status-pill ${style.className}`}>{style.label}</div>
{notice && <div className="tgu-alert tgu-alert--success">{notice}</div>}
{error && <div className="tgu-alert tgu-alert--danger">{error}</div>}
<dl className="tgu-details-grid">
<Detail label="planId" value={plan.planId} mono />
<Detail label="spacecraftId" value={plan.spacecraftId} mono />
<Detail label="КА" value={platformLabel(plan.platform, plan.spacecraftId)} />
<Detail label="startTime" value={formatDateTime(plan.startTime)} mono />
<Detail label="endTime" value={formatDateTime(plan.endTime)} mono />
<Detail label="duration" value={duration} mono />
<Detail label="kppId" value={plan.kppId} mono />
<Detail label="status" value={plan.status} mono />
</dl>
{plan.status === "WAITING_DECISION" && (
<div className="tgu-details__actions">
<button
className="tgu-button tgu-button--accept"
type="button"
disabled={decisionInFlight}
onClick={() => onDecision(plan.planId, "ACCEPTED", "UI_TEST_ACCEPTED")}
>
Принять
</button>
<button className="tgu-button tgu-button--reject" type="button" disabled={decisionInFlight} onClick={reject}>
Отклонить
</button>
</div>
)}
</aside>
);
}
function Detail({ label, value, mono }: { label: string; value?: string; mono?: boolean }) {
return (
<div>
<dt>{label}</dt>
<dd className={mono ? "mono" : undefined}>{value || "-"}</dd>
</div>
);
}
function formatDateTime(value: string): string {
return new Date(value).toLocaleString("ru-RU");
}
function formatDuration(ms: number): string {
const minutes = Math.max(0, Math.round(ms / 60_000));
const hours = Math.floor(minutes / 60);
const restMinutes = minutes % 60;
if (hours === 0) return `${restMinutes} мин`;
return `${hours} ч ${restMinutes} мин`;
}
@@ -0,0 +1,21 @@
import type { ReactNode } from "react";
type TguPlanningLayoutProps = {
toolbar: ReactNode;
sidebar: ReactNode;
timeline: ReactNode;
details: ReactNode;
};
export function TguPlanningLayout({ toolbar, sidebar, timeline, details }: TguPlanningLayoutProps) {
return (
<div className="tgu-app-shell">
{toolbar}
<main className="tgu-workspace">
{sidebar}
<section className="tgu-center">{timeline}</section>
{details}
</main>
</div>
);
}
@@ -0,0 +1,217 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { fetchPlans, fetchPlatforms, sendPlanDecision } from "../../api/tguApi";
import type { TguPlanDecision, TguPlatform } from "../../model/tguTypes";
import type { TguPlanUi, TimelineRange } from "../../model/timelineTypes";
import { TguPlanDetails } from "./TguPlanDetails";
import { TguPlanningLayout } from "./TguPlanningLayout";
import { TguSidebar } from "./TguSidebar";
import { TguTimeline } from "./TguTimeline";
import { TguToolbar } from "./TguToolbar";
import { buildPlatformsForInterval, buildTimelineRows, mapPlans } from "./tguTimelineMapper";
const DAY_MS = 24 * 60 * 60 * 1000;
export function TguPlanningPage() {
const initialRange = useMemo(defaultRange, []);
const [fromValue, setFromValue] = useState(formatDateTimeLocal(initialRange.fromMs));
const [toValue, setToValue] = useState(formatDateTimeLocal(initialRange.toMs));
const [appliedRange, setAppliedRange] = useState<TimelineRange>(initialRange);
const [rawPlatforms, setRawPlatforms] = useState<TguPlatform[]>([]);
const [plans, setPlans] = useState<TguPlanUi[]>([]);
const [loading, setLoading] = useState(false);
const [pageError, setPageError] = useState<string>();
const [platformLoadFailed, setPlatformLoadFailed] = useState(false);
const [search, setSearch] = useState("");
const [selectedSpacecraftId, setSelectedSpacecraftId] = useState<string>();
const [selectedPlanId, setSelectedPlanId] = useState<string>();
const [decisionInFlight, setDecisionInFlight] = useState(false);
const [decisionNotice, setDecisionNotice] = useState<string>();
const [decisionError, setDecisionError] = useState<string>();
const parsedInputRange = useMemo(
() => ({
fromMs: new Date(fromValue).getTime(),
toMs: new Date(toValue).getTime()
}),
[fromValue, toValue]
);
const invalidRange =
!Number.isFinite(parsedInputRange.fromMs) ||
!Number.isFinite(parsedInputRange.toMs) ||
parsedInputRange.fromMs >= parsedInputRange.toMs;
const loadData = useCallback(
async (range: TimelineRange) => {
setLoading(true);
setPageError(undefined);
setDecisionError(undefined);
const historyDays = historyDaysForRange(range);
const [platformResult, planResult] = await Promise.allSettled([
fetchPlatforms(),
fetchPlans(historyDays)
]);
if (platformResult.status === "rejected") {
setPlatformLoadFailed(true);
} else {
setPlatformLoadFailed(false);
setRawPlatforms(platformResult.value);
}
if (planResult.status === "rejected") {
setPlans([]);
setPageError(planResult.reason instanceof Error ? planResult.reason.message : "Не удалось загрузить планы.");
} else {
const platforms = platformResult.status === "fulfilled" ? platformResult.value : [];
setPlans(mapPlans(planResult.value, platforms));
}
setLoading(false);
},
[]
);
useEffect(() => {
void loadData(appliedRange);
}, [appliedRange, loadData]);
const platforms = useMemo(
() => buildPlatformsForInterval(rawPlatforms, plans, appliedRange),
[rawPlatforms, plans, appliedRange]
);
const rows = useMemo(
() => buildTimelineRows(plans, platforms, appliedRange, selectedSpacecraftId),
[plans, platforms, appliedRange, selectedSpacecraftId]
);
const selectedPlan = useMemo(() => {
const plan = plans.find((item) => item.planId === selectedPlanId);
if (!plan) return undefined;
const platform = platforms.find((item) => item.spacecraftId === plan.spacecraftId);
return { ...plan, platform };
}, [plans, platforms, selectedPlanId]);
const applyRange = () => {
setDecisionNotice(undefined);
if (invalidRange) {
setPageError("Некорректный интервал: from должен быть меньше to.");
return;
}
setPageError(undefined);
setAppliedRange(parsedInputRange);
};
const quickRange = (hours: number) => {
const fromMs = startOfCurrentDayMs();
const toMs = fromMs + hours * 60 * 60 * 1000;
setFromValue(formatDateTimeLocal(fromMs));
setToValue(formatDateTimeLocal(toMs));
setAppliedRange({ fromMs, toMs });
setPageError(undefined);
setDecisionNotice(undefined);
};
const refresh = () => {
if (invalidRange) {
setPageError("Некорректный интервал: from должен быть меньше to.");
return;
}
void loadData(appliedRange);
};
const handleDecision = async (planId: string, decision: TguPlanDecision, reason?: string) => {
setDecisionInFlight(true);
setDecisionError(undefined);
setDecisionNotice(undefined);
try {
await sendPlanDecision(planId, decision, reason);
setDecisionNotice(
decision === "REJECTED"
? "Решение REJECTED отправлено. План может вернуться в PLANNED для повторной выдачи."
: "Решение ACCEPTED отправлено."
);
await loadData(appliedRange);
} catch (error) {
setDecisionError(error instanceof Error ? error.message : "Не удалось отправить решение.");
} finally {
setDecisionInFlight(false);
}
};
return (
<TguPlanningLayout
toolbar={
<TguToolbar
fromValue={fromValue}
toValue={toValue}
loading={loading}
error={pageError}
onFromChange={setFromValue}
onToChange={setToValue}
onApply={applyRange}
onQuickRange={quickRange}
onRefresh={refresh}
/>
}
sidebar={
<TguSidebar
platforms={platforms}
selectedSpacecraftId={selectedSpacecraftId}
search={search}
platformLoadFailed={platformLoadFailed}
onSearchChange={setSearch}
onSelectSpacecraft={(spacecraftId) => {
setSelectedSpacecraftId(spacecraftId);
setSelectedPlanId(undefined);
setDecisionNotice(undefined);
}}
/>
}
timeline={
<TguTimeline
rows={rows}
range={appliedRange}
invalidRange={invalidRange}
selectedPlanId={selectedPlanId}
onSelectPlan={setSelectedPlanId}
/>
}
details={
<TguPlanDetails
plan={selectedPlan}
decisionInFlight={decisionInFlight}
notice={decisionNotice}
error={decisionError}
onDecision={handleDecision}
onClose={() => setSelectedPlanId(undefined)}
/>
}
/>
);
}
function defaultRange(): TimelineRange {
const fromMs = startOfCurrentDayMs();
return { fromMs, toMs: fromMs + DAY_MS };
}
function startOfCurrentDayMs(): number {
const now = new Date();
return new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
}
function formatDateTimeLocal(ms: number): string {
const date = new Date(ms);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
return `${year}-${month}-${day}T${hours}:${minutes}`;
}
function historyDaysForRange(range: TimelineRange): number {
const daysBack = Math.ceil(Math.max(0, Date.now() - range.fromMs) / DAY_MS);
return Math.max(1, daysBack + 1);
}
@@ -0,0 +1,74 @@
import type { TguPlatformUi } from "../../model/timelineTypes";
import { filterPlatforms, platformLabel } from "./tguTimelineMapper";
type TguSidebarProps = {
platforms: TguPlatformUi[];
selectedSpacecraftId?: string;
search: string;
platformLoadFailed: boolean;
onSearchChange: (value: string) => void;
onSelectSpacecraft: (spacecraftId?: string) => void;
};
export function TguSidebar({
platforms,
selectedSpacecraftId,
search,
platformLoadFailed,
onSearchChange,
onSelectSpacecraft
}: TguSidebarProps) {
const filtered = filterPlatforms(platforms, search);
return (
<aside className="tgu-sidebar">
<div className="tgu-panel-heading">
<span>Космические аппараты</span>
<button className="tgu-link-button" type="button" onClick={() => onSelectSpacecraft(undefined)}>
Все
</button>
</div>
<div className="tgu-search">
<input
type="search"
placeholder="Поиск name / NORAD / mission"
value={search}
onChange={(event) => onSearchChange(event.target.value)}
/>
</div>
{platformLoadFailed && (
<div className="tgu-alert tgu-alert--warning">
Список платформ не загрузился. Timeline построен по spacecraftId из планов.
</div>
)}
<div className="tgu-spacecraft-list">
{filtered.map((platform) => {
const selected = selectedSpacecraftId === platform.spacecraftId;
return (
<button
className={`tgu-spacecraft ${selected ? "is-selected" : ""}`}
type="button"
key={platform.spacecraftId}
onClick={() => onSelectSpacecraft(platform.spacecraftId)}
>
<span className={`tgu-spacecraft__problem ${platform.hasProblemStatus ? "is-problem" : ""}`} />
<span className="tgu-spacecraft__body">
<span className="tgu-spacecraft__name">{platformLabel(platform, platform.spacecraftId)}</span>
<span className="tgu-spacecraft__meta">
NORAD {platform.noradId || platform.spacecraftId}
{platform.mission ? ` · ${platform.mission}` : ""}
</span>
{platform.status && <span className="tgu-spacecraft__status">{platform.status}</span>}
</span>
<span className="tgu-spacecraft__count">{platform.planCount}</span>
</button>
);
})}
{filtered.length === 0 && <div className="tgu-empty tgu-empty--compact">Нет КА по текущему поиску.</div>}
</div>
</aside>
);
}
@@ -0,0 +1,201 @@
import { useEffect, useMemo, useRef, useState } from "react";
import type { TimelinePlanSegment, TimelineRange, TimelineRow } from "../../model/timelineTypes";
import {
buildSequentialLinks,
buildTimeTicks,
buildTimelineSegments,
TIMELINE_AXIS_HEIGHT,
TIMELINE_BOTTOM_PADDING,
TIMELINE_LABEL_WIDTH,
TIMELINE_ROW_HEIGHT,
timeToX,
timelineHeight
} from "./tguTimelineLayout";
import { platformLabel } from "./tguTimelineMapper";
import { statusStyle } from "./tguStatus";
type TguTimelineProps = {
rows: TimelineRow[];
range: TimelineRange;
selectedPlanId?: string;
invalidRange?: boolean;
onSelectPlan: (planId: string) => void;
};
export function TguTimeline({ rows, range, selectedPlanId, invalidRange, onSelectPlan }: 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 height = Math.max(260, timelineHeight(rows.length));
const nowMs = Date.now();
useEffect(() => {
if (!containerRef.current) return;
const observer = new ResizeObserver(([entry]) => {
setContainerWidth(Math.floor(entry.contentRect.width));
});
observer.observe(containerRef.current);
return () => observer.disconnect();
}, []);
const segments = useMemo(
() => buildTimelineSegments(rows, range, timelineWidth),
[rows, range, timelineWidth]
);
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;
if (invalidRange) {
return <div className="tgu-empty">Некорректный интервал: from должен быть меньше to.</div>;
}
if (rows.length === 0 || segments.length === 0) {
return <div className="tgu-empty">Нет планов в выбранном интервале.</div>;
}
return (
<div className="tgu-timeline" ref={containerRef}>
<svg className="tgu-timeline__svg" viewBox={`0 0 ${fullWidth} ${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>
)}
{rows.map((row, rowIndex) => {
const y = TIMELINE_AXIS_HEIGHT + rowIndex * TIMELINE_ROW_HEIGHT;
return (
<g key={row.spacecraftId}>
<rect
x="0"
y={y}
width={fullWidth}
height={TIMELINE_ROW_HEIGHT}
className={rowIndex % 2 === 0 ? "tgu-timeline__row" : "tgu-timeline__row tgu-timeline__row--alt"}
/>
<text x="16" y={y + 24} className="tgu-timeline__row-title">
{platformLabel(row.platform, row.spacecraftId)}
</text>
<text x="16" y={y + 42} className="tgu-timeline__row-meta">
NORAD {row.platform?.noradId || row.spacecraftId}
</text>
</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 = TIMELINE_AXIS_HEIGHT + segment.rowIndex * TIMELINE_ROW_HEIGHT + TIMELINE_ROW_HEIGHT / 2;
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 = TIMELINE_AXIS_HEIGHT + from.rowIndex * TIMELINE_ROW_HEIGHT + TIMELINE_ROW_HEIGHT / 2;
const y2 = TIMELINE_AXIS_HEIGHT + to.rowIndex * TIMELINE_ROW_HEIGHT + TIMELINE_ROW_HEIGHT / 2;
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 new Date(ms).toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit" });
}
function formatTickTime(ms: number): string {
return new Date(ms).toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" });
}
function formatDateTime(value: string): string {
return new Date(value).toLocaleString("ru-RU");
}
@@ -0,0 +1,66 @@
import { TguLegend } from "./TguLegend";
type TguToolbarProps = {
fromValue: string;
toValue: string;
loading: boolean;
error?: string;
onFromChange: (value: string) => void;
onToChange: (value: string) => void;
onApply: () => void;
onQuickRange: (hours: number) => void;
onRefresh: () => void;
};
export function TguToolbar({
fromValue,
toValue,
loading,
error,
onFromChange,
onToChange,
onApply,
onQuickRange,
onRefresh
}: TguToolbarProps) {
return (
<header className="tgu-toolbar">
<div className="tgu-toolbar__row">
<div className="tgu-toolbar__brand">
<span className="tgu-toolbar__mark" aria-hidden="true" />
<div>
<div className="tgu-toolbar__title">Планирование ТГУ</div>
<div className="tgu-toolbar__subtitle">Mission Ops</div>
</div>
</div>
<label className="tgu-field">
<span>С</span>
<input type="datetime-local" value={fromValue} onChange={(event) => onFromChange(event.target.value)} />
</label>
<label className="tgu-field">
<span>По</span>
<input type="datetime-local" value={toValue} onChange={(event) => onToChange(event.target.value)} />
</label>
<button className="tgu-button tgu-button--primary" type="button" onClick={onApply} disabled={loading}>
Показать
</button>
<button className="tgu-button" type="button" onClick={() => onQuickRange(24)} disabled={loading}>
24ч
</button>
<button className="tgu-button" type="button" onClick={() => onQuickRange(72)} disabled={loading}>
3 суток
</button>
<button className="tgu-button" type="button" onClick={() => onQuickRange(168)} disabled={loading}>
7 суток
</button>
<button className="tgu-button" type="button" onClick={onRefresh} disabled={loading}>
{loading ? "Обновление..." : "Обновить"}
</button>
</div>
<TguLegend />
{error && <div className="tgu-alert tgu-alert--danger">{error}</div>}
</header>
);
}
@@ -0,0 +1,26 @@
import type { TguPlanStatus } from "../../model/tguTypes";
import type { TimelineStatusStyle } from "../../model/timelineTypes";
export const STATUS_STYLES: TimelineStatusStyle[] = [
{ status: "ACCEPTED", label: "ACCEPTED", color: "#2f9e44", className: "status-accepted" },
{ status: "REJECTED", label: "REJECTED", color: "#d64545", className: "status-rejected" },
{ status: "WAITING_DECISION", label: "WAITING_DECISION", color: "#f0ad2e", className: "status-waiting" },
{ status: "PLANNED", label: "PLANNED", color: "#8d99a6", className: "status-planned" },
{ status: "ISSUING", label: "ISSUING", color: "#2f80ed", className: "status-issuing" },
{ status: "EXPIRED", label: "EXPIRED", color: "#495057", className: "status-expired" },
{ status: "SUPERSEDED", label: "SUPERSEDED", color: "#c7ced6", className: "status-superseded" },
{ status: "START_AMBIGUOUS", label: "START_AMBIGUOUS", color: "#b42318", className: "status-ambiguous" }
];
const STATUS_BY_NAME = new Map(STATUS_STYLES.map((style) => [style.status, style]));
export function statusStyle(status: TguPlanStatus): TimelineStatusStyle {
return (
STATUS_BY_NAME.get(status) ?? {
status,
label: status || "UNKNOWN",
color: "#79828d",
className: "status-unknown"
}
);
}
@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import type { TguPlanUi, TimelineRow } from "../../model/timelineTypes";
import { buildSequentialLinks, clipPlanToRange, timeToX } from "./tguTimelineLayout";
const range = {
fromMs: Date.parse("2026-05-29T00:00:00"),
toMs: Date.parse("2026-05-30T00:00:00")
};
function plan(id: string, startTime: string, endTime: string, spacecraftId = "56756"): TguPlanUi {
return {
planId: id,
spacecraftId,
startTime,
endTime,
kppId: "KPP-1",
status: "PLANNED",
startMs: Date.parse(startTime),
endMs: Date.parse(endTime)
};
}
describe("tguTimelineLayout", () => {
it("maps time to x inside the selected interval", () => {
expect(timeToX(Date.parse("2026-05-29T12:00:00"), range, 1200)).toBe(600);
});
it("clips partially visible plans to the selected interval", () => {
const clipped = clipPlanToRange(plan("p1", "2026-05-28T23:00:00", "2026-05-29T02:00:00"), range);
expect(clipped).toEqual({
startMs: range.fromMs,
endMs: Date.parse("2026-05-29T02:00:00")
});
});
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" }
]);
});
});
@@ -0,0 +1,93 @@
import type { TguPlanUi, TimelineLink, TimelinePlanSegment, TimelineRange, TimelineRow } from "../../model/timelineTypes";
export const TIMELINE_LABEL_WIDTH = 220;
export const TIMELINE_ROW_HEIGHT = 58;
export const TIMELINE_AXIS_HEIGHT = 46;
export const TIMELINE_BOTTOM_PADDING = 18;
export function timeToX(timeMs: number, range: TimelineRange, timelineWidth: number): number {
return ((timeMs - range.fromMs) / (range.toMs - range.fromMs)) * timelineWidth;
}
export function clipPlanToRange(plan: TguPlanUi, range: TimelineRange): { startMs: number; endMs: number } | null {
if (plan.endMs < range.fromMs || plan.startMs > range.toMs) {
return null;
}
return {
startMs: Math.max(plan.startMs, range.fromMs),
endMs: Math.min(plan.endMs, range.toMs)
};
}
export function buildTimelineSegments(
rows: TimelineRow[],
range: TimelineRange,
timelineWidth: number
): TimelinePlanSegment[] {
const segments: TimelinePlanSegment[] = [];
rows.forEach((row, rowIndex) => {
for (const plan of row.plans) {
const clipped = clipPlanToRange(plan, range);
if (!clipped) continue;
const x = timeToX(clipped.startMs, range, timelineWidth);
const x2 = timeToX(clipped.endMs, range, timelineWidth);
segments.push({
plan,
rowIndex,
x,
x2,
width: Math.max(3, x2 - x)
});
}
});
return segments;
}
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;
const candidates = [
15 * 60_000,
30 * 60_000,
60 * 60_000,
2 * 60 * 60_000,
3 * 60 * 60_000,
6 * 60 * 60_000,
12 * 60 * 60_000,
24 * 60 * 60_000
];
const step = candidates.find((candidate) => candidate >= roughStep) ?? 24 * 60 * 60_000;
const first = Math.ceil(range.fromMs / step) * step;
const ticks: number[] = [];
for (let tick = first; tick <= range.toMs; tick += step) {
ticks.push(tick);
}
return ticks;
}
export function timelineHeight(rowCount: number): number {
return TIMELINE_AXIS_HEIGHT + rowCount * TIMELINE_ROW_HEIGHT + TIMELINE_BOTTOM_PADDING;
}
@@ -0,0 +1,162 @@
import type { TguPlan, TguPlatform } from "../../model/tguTypes";
import type { TguPlanUi, TguPlatformUi, TimelineRange, TimelineRow } from "../../model/timelineTypes";
const PROBLEM_STATUSES = new Set(["REJECTED", "EXPIRED", "START_AMBIGUOUS"]);
export function mapPlans(plans: TguPlan[], platforms: TguPlatform[]): TguPlanUi[] {
const platformByNorad = buildPlatformByNorad(platforms);
return plans
.map((plan) => {
const startMs = new Date(plan.startTime).getTime();
const endMs = new Date(plan.endTime).getTime();
const platform = platformByNorad.get(plan.spacecraftId);
return {
...plan,
startMs,
endMs,
platform: platform ? toPlatformUi(plan.spacecraftId, platform, 0, false) : undefined
};
})
.filter((plan) => Number.isFinite(plan.startMs) && Number.isFinite(plan.endMs));
}
export function buildPlatformsForInterval(
platforms: TguPlatform[],
plans: TguPlanUi[],
range: TimelineRange
): TguPlatformUi[] {
const intervalPlans = filterPlansByRange(plans, range);
const platformByNorad = buildPlatformByNorad(platforms);
const planStats = new Map<string, { count: number; hasProblemStatus: boolean }>();
for (const plan of intervalPlans) {
const stats = planStats.get(plan.spacecraftId) ?? { count: 0, hasProblemStatus: false };
stats.count += 1;
stats.hasProblemStatus = stats.hasProblemStatus || PROBLEM_STATUSES.has(plan.status);
planStats.set(plan.spacecraftId, stats);
}
const platformItems = Array.from(platformByNorad.entries()).map(([spacecraftId, platform]) => {
const stats = planStats.get(spacecraftId) ?? { count: 0, hasProblemStatus: false };
return toPlatformUi(spacecraftId, platform, stats.count, stats.hasProblemStatus);
});
const knownSpacecraft = new Set(platformItems.map((platform) => platform.spacecraftId));
for (const [spacecraftId, stats] of planStats.entries()) {
if (!knownSpacecraft.has(spacecraftId)) {
platformItems.push({
spacecraftId,
noradId: spacecraftId,
planCount: stats.count,
hasProblemStatus: stats.hasProblemStatus
});
}
}
return platformItems.sort((a, b) => platformLabel(a).localeCompare(platformLabel(b), "ru"));
}
export function buildTimelineRows(
plans: TguPlanUi[],
platforms: TguPlatformUi[],
range: TimelineRange,
selectedSpacecraftId?: string
): TimelineRow[] {
const platformBySpacecraft = new Map(platforms.map((platform) => [platform.spacecraftId, platform]));
const rowsBySpacecraft = new Map<string, TimelineRow>();
for (const plan of filterPlansByRange(plans, range)) {
if (selectedSpacecraftId && plan.spacecraftId !== selectedSpacecraftId) {
continue;
}
const row =
rowsBySpacecraft.get(plan.spacecraftId) ??
{
spacecraftId: plan.spacecraftId,
platform: platformBySpacecraft.get(plan.spacecraftId),
plans: []
};
row.plans.push(plan);
rowsBySpacecraft.set(plan.spacecraftId, row);
}
for (const platform of platforms) {
if (selectedSpacecraftId && platform.spacecraftId !== selectedSpacecraftId) {
continue;
}
if (!rowsBySpacecraft.has(platform.spacecraftId) && platform.planCount > 0) {
rowsBySpacecraft.set(platform.spacecraftId, {
spacecraftId: platform.spacecraftId,
platform,
plans: []
});
}
}
return Array.from(rowsBySpacecraft.values())
.map((row) => ({
...row,
platform: row.platform ?? platformBySpacecraft.get(row.spacecraftId),
plans: [...row.plans].sort((a, b) => a.startMs - b.startMs)
}))
.sort((a, b) => platformLabel(a.platform, a.spacecraftId).localeCompare(platformLabel(b.platform, b.spacecraftId), "ru"));
}
export function filterPlatforms(platforms: TguPlatformUi[], query: string): TguPlatformUi[] {
const normalized = query.trim().toLowerCase();
if (!normalized) return platforms;
return platforms.filter((platform) => {
const searchText = [
platform.name,
platform.noradId,
platform.spacecraftId,
platform.mission,
platform.status
]
.filter(Boolean)
.join(" ")
.toLowerCase();
return searchText.includes(normalized);
});
}
export function filterPlansByRange(plans: TguPlanUi[], range: TimelineRange): TguPlanUi[] {
return plans.filter((plan) => plan.endMs >= range.fromMs && plan.startMs <= range.toMs);
}
export function platformLabel(platform?: TguPlatformUi, fallback?: string): string {
return platform?.name || platform?.noradId || fallback || platform?.spacecraftId || "КА";
}
function buildPlatformByNorad(platforms: TguPlatform[]): Map<string, TguPlatform> {
const result = new Map<string, TguPlatform>();
for (const platform of platforms) {
if (platform.noradId !== null && platform.noradId !== undefined) {
result.set(String(platform.noradId), platform);
}
}
return result;
}
function toPlatformUi(
spacecraftId: string,
platform: TguPlatform,
planCount: number,
hasProblemStatus: boolean
): TguPlatformUi {
return {
spacecraftId,
name: platform.name ?? undefined,
noradId: platform.noradId === null || platform.noradId === undefined ? undefined : String(platform.noradId),
status: platform.status ?? undefined,
mission: platform.mission ?? undefined,
planCount,
hasProblemStatus,
platform
};
}