pcp-tgu интерфейс
This commit is contained in:
@@ -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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user