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:
@@ -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[] = [];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user