Add PCP architecture docs and TGU ops updates

Capture current PCP architecture notes, service-map prototypes, TGU operations UI/map work, local configuration updates, database helper scripts, and request/sample JSON artifacts.
This commit is contained in:
Дмитрий Соловьев
2026-05-30 14:18:19 +03:00
parent 9fca4d4051
commit c3a1a8b4a1
86 changed files with 10426 additions and 74 deletions
@@ -2,20 +2,14 @@ import type { ReactNode } from "react";
type TguPlanningLayoutProps = {
toolbar: ReactNode;
sidebar: ReactNode;
timeline: ReactNode;
details: ReactNode;
children: ReactNode;
};
export function TguPlanningLayout({ toolbar, sidebar, timeline, details }: TguPlanningLayoutProps) {
export function TguPlanningLayout({ toolbar, children }: TguPlanningLayoutProps) {
return (
<div className="tgu-app-shell">
{toolbar}
<main className="tgu-workspace">
{sidebar}
<section className="tgu-center">{timeline}</section>
{details}
</main>
{children}
</div>
);
}
@@ -2,6 +2,7 @@ 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 { Tgu2DMapTab } from "../tgu-map-2d/Tgu2DMapTab";
import { TguPlanDetails } from "./TguPlanDetails";
import { TguPlanningLayout } from "./TguPlanningLayout";
import { TguSidebar } from "./TguSidebar";
@@ -24,6 +25,7 @@ export function TguPlanningPage() {
const [search, setSearch] = useState("");
const [selectedSpacecraftId, setSelectedSpacecraftId] = useState<string>();
const [selectedPlanId, setSelectedPlanId] = useState<string>();
const [activeTab, setActiveTab] = useState<"timeline" | "map">("timeline");
const [decisionInFlight, setDecisionInFlight] = useState(false);
const [decisionNotice, setDecisionNotice] = useState<string>();
const [decisionError, setDecisionError] = useState<string>();
@@ -146,7 +148,9 @@ export function TguPlanningPage() {
fromValue={fromValue}
toValue={toValue}
loading={loading}
activeTab={activeTab}
error={pageError}
onTabChange={setActiveTab}
onFromChange={setFromValue}
onToChange={setToValue}
onApply={applyRange}
@@ -154,40 +158,60 @@ export function TguPlanningPage() {
onRefresh={refresh}
/>
}
sidebar={
<TguSidebar
platforms={platforms}
>
{activeTab === "timeline" ? (
<main className="tgu-workspace">
<TguSidebar
platforms={platforms}
selectedSpacecraftId={selectedSpacecraftId}
search={search}
platformLoadFailed={platformLoadFailed}
onSearchChange={setSearch}
onSelectSpacecraft={(spacecraftId) => {
setSelectedSpacecraftId(spacecraftId);
setSelectedPlanId(undefined);
setDecisionNotice(undefined);
}}
/>
<section className="tgu-center">
<TguTimeline
rows={rows}
range={appliedRange}
invalidRange={invalidRange}
selectedPlanId={selectedPlanId}
onSelectPlan={(planId) => {
setSelectedPlanId(planId);
const plan = plans.find((item) => item.planId === planId);
if (plan) {
setSelectedSpacecraftId(plan.spacecraftId);
}
}}
/>
</section>
<TguPlanDetails
plan={selectedPlan}
decisionInFlight={decisionInFlight}
notice={decisionNotice}
error={decisionError}
onDecision={handleDecision}
onClose={() => setSelectedPlanId(undefined)}
/>
</main>
) : (
<Tgu2DMapTab
range={appliedRange}
invalidRange={invalidRange}
selectedSpacecraftId={selectedSpacecraftId}
search={search}
platformLoadFailed={platformLoadFailed}
onSearchChange={setSearch}
selectedPlan={selectedPlan}
platforms={platforms}
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)}
/>
}
/>
)}
</TguPlanningLayout>
);
}
@@ -136,7 +136,11 @@ export function TguTimeline({ rows, range, selectedPlanId, invalidRange, onSelec
})}
{segments.map((segment) => {
const y = TIMELINE_AXIS_HEIGHT + segment.rowIndex * TIMELINE_ROW_HEIGHT + TIMELINE_ROW_HEIGHT / 2;
const y =
TIMELINE_AXIS_HEIGHT +
segment.rowIndex * TIMELINE_ROW_HEIGHT +
TIMELINE_ROW_HEIGHT / 2 +
segment.laneOffset;
const x1 = TIMELINE_LABEL_WIDTH + segment.x;
const x2 = TIMELINE_LABEL_WIDTH + segment.x + segment.width;
const style = statusStyle(segment.plan.status);
@@ -176,8 +180,8 @@ export function TguTimeline({ rows, range, selectedPlanId, invalidRange, onSelec
}
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 y1 = TIMELINE_AXIS_HEIGHT + from.rowIndex * TIMELINE_ROW_HEIGHT + TIMELINE_ROW_HEIGHT / 2 + from.laneOffset;
const y2 = TIMELINE_AXIS_HEIGHT + to.rowIndex * TIMELINE_ROW_HEIGHT + TIMELINE_ROW_HEIGHT / 2 + to.laneOffset;
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);
@@ -4,7 +4,9 @@ type TguToolbarProps = {
fromValue: string;
toValue: string;
loading: boolean;
activeTab: "timeline" | "map";
error?: string;
onTabChange: (tab: "timeline" | "map") => void;
onFromChange: (value: string) => void;
onToChange: (value: string) => void;
onApply: () => void;
@@ -16,7 +18,9 @@ export function TguToolbar({
fromValue,
toValue,
loading,
activeTab,
error,
onTabChange,
onFromChange,
onToChange,
onApply,
@@ -34,6 +38,27 @@ export function TguToolbar({
</div>
</div>
<div className="tgu-tabs" role="tablist" aria-label="Разделы планирования ТГУ">
<button
className={activeTab === "timeline" ? "is-active" : ""}
type="button"
role="tab"
aria-selected={activeTab === "timeline"}
onClick={() => onTabChange("timeline")}
>
Timeline
</button>
<button
className={activeTab === "map" ? "is-active" : ""}
type="button"
role="tab"
aria-selected={activeTab === "map"}
onClick={() => onTabChange("map")}
>
Карта 2D
</button>
</div>
<label className="tgu-field">
<span>С</span>
<input type="datetime-local" value={fromValue} onChange={(event) => onFromChange(event.target.value)} />
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import type { TguPlanUi, TimelineRow } from "../../model/timelineTypes";
import { buildSequentialLinks, clipPlanToRange, timeToX } from "./tguTimelineLayout";
import { buildSequentialLinks, buildTimelineSegments, clipPlanToRange, timeToX } from "./tguTimelineLayout";
const range = {
fromMs: Date.parse("2026-05-29T00:00:00"),
@@ -55,4 +55,29 @@ describe("tguTimelineLayout", () => {
{ spacecraftId: "56756", fromPlanId: "p1", toPlanId: "p2" }
]);
});
it("places sequential plans on alternating timeline lanes", () => {
const rows: TimelineRow[] = [
{
spacecraftId: "56756",
plans: [
plan("p1", "2026-05-29T01:00:00", "2026-05-29T02:00:00"),
plan("p2", "2026-05-29T03:00:00", "2026-05-29T04:00:00"),
plan("p3", "2026-05-29T05:00:00", "2026-05-29T06:00:00"),
plan("p4", "2026-05-29T07:00:00", "2026-05-29T08:00:00"),
plan("p5", "2026-05-29T09:00:00", "2026-05-29T10:00:00"),
plan("p6", "2026-05-29T11:00:00", "2026-05-29T12:00:00")
]
}
];
expect(buildTimelineSegments(rows, range, 1200).map((segment) => segment.laneOffset)).toEqual([
0,
-12,
0,
12,
0,
-12
]);
});
});
@@ -4,6 +4,7 @@ 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 const TIMELINE_LANE_STEP = 12;
export function timeToX(timeMs: number, range: TimelineRange, timelineWidth: number): number {
return ((timeMs - range.fromMs) / (range.toMs - range.fromMs)) * timelineWidth;
@@ -28,25 +29,32 @@ export function buildTimelineSegments(
const segments: TimelinePlanSegment[] = [];
rows.forEach((row, rowIndex) => {
for (const plan of row.plans) {
const clipped = clipPlanToRange(plan, range);
if (!clipped) continue;
const visiblePlans = row.plans
.map((plan) => ({ plan, clipped: clipPlanToRange(plan, range) }))
.filter((item): item is { plan: TguPlanUi; clipped: { startMs: number; endMs: number } } => item.clipped !== null)
.sort((a, b) => a.plan.startMs - b.plan.startMs || a.plan.endMs - b.plan.endMs);
const x = timeToX(clipped.startMs, range, timelineWidth);
const x2 = timeToX(clipped.endMs, range, timelineWidth);
visiblePlans.forEach((item, planIndex) => {
const x = timeToX(item.clipped.startMs, range, timelineWidth);
const x2 = timeToX(item.clipped.endMs, range, timelineWidth);
segments.push({
plan,
plan: item.plan,
rowIndex,
laneOffset: timelineLaneOffset(planIndex),
x,
x2,
width: Math.max(3, x2 - x)
});
}
});
});
return segments;
}
export function timelineLaneOffset(index: number): number {
return [0, -1, 0, 1][index % 4] * TIMELINE_LANE_STEP;
}
export function buildSequentialLinks(rows: TimelineRow[]): TimelineLink[] {
const links: TimelineLink[] = [];