+ {planEndMs != null && planEndMs >= timelineWindow.fromMs && planEndMs <= timelineWindow.toMs && (
+
+ Конец плана
+
+ )}
+
+
+
{activeLabel}
редактируется
+ Съёмка
+ Сброс
- {draft.map((work) => {
+ {works.map((work) => {
const left = toX(work.startMs);
const width = Math.max(6, toX(work.endMs) - toX(work.startMs));
const color = kindColor(work.kind);
@@ -169,8 +135,9 @@ export function EditorTimeline({
const hasConflict = Boolean(conflicts[work.id]?.length);
return (
-
startDrag(event, work, "move")}
onClick={(event) => {
event.stopPropagation();
onSelectWork(work.id);
}}
>
- startDrag(event, work, "left")} />
- startDrag(event, work, "right")} />
{width > 54 && {work.label}}
{work.isNew && NEW}
{width > 82 && {formatTime(work.startMs)}-{formatTime(work.endMs)}}
-
+
);
})}
diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx
index 4be10f9..0919a4a 100644
--- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx
+++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx
@@ -6,6 +6,7 @@ import type { RequestMapItem } from "../../model/requestTypes";
import { stationPasses, type OrbitPassInfo, type PassRange } from "../tgu-map-2d/geometry/passes";
import { fetchPointVisibility, fetchSatelliteRva, type PointVisibilityParam, type RvaZone } from "../../api/ballisticsApi";
import { buildSurveyRoute, saveDropRoute, saveSurveyRoute, type SurveyRoute, type SurveyRouteAnchor } from "../../api/missionPlanningApi";
+import { parseUtc } from "../../utils/utcTime";
import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView";
import { parseWktPolygon } from "../tgu-map-2d/geometry/wktParser";
import { buildStationMarkers } from "../tgu-map-2d/Tgu2DMapLayers";
@@ -19,6 +20,7 @@ import {
deleteWork,
duplicateWork,
mutate,
+ planModesToWorks,
redo,
seedDraft,
undo,
@@ -40,6 +42,7 @@ import type {
EditorTimelineWindow,
EditorVisibilityTrack,
EditorVisibilityWindow,
+ EditorWork,
EditorWorkPatch,
TguEditorTabProps
} from "./model/editorTypes";
@@ -68,7 +71,9 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
const idCounterRef = useRef(0);
const targetCounterRef = useRef(0);
const centerRef = useRef
(null);
- const [mapRatio, setMapRatio] = useState(0.72);
+ // Доля высоты под карту: по умолчанию карта крупная, а нижняя панель компактно
+ // примыкает к низу. Подъём резайзера вверх уменьшает долю и расширяет панель.
+ const [mapRatio, setMapRatio] = useState(0.8);
const [history, setHistory] = useState(() => ({
draft: seedDraft(selectedPlan, selectedSpacecraftId),
past: [],
@@ -124,14 +129,17 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
.map(([revolution, pts], idx) => ({
index: idx + 1,
revolution,
- startMs: Date.parse(pts[0].time),
- endMs: Date.parse(pts[pts.length - 1].time),
+ startMs: parseUtc(pts[0].time),
+ endMs: parseUtc(pts[pts.length - 1].time),
}));
}, [flightLineData]);
const modes: EditorMode[] = DEFAULT_EDITOR_MODES;
+ // Существующие включения плана (read-only) + правки из черновика — то, что видно на таймлайне.
+ const planWorks = useMemo(() => planModesToWorks(planModes), [planModes]);
+ const displayWorks = useMemo(() => [...planWorks, ...draft.works], [planWorks, draft.works]);
const liveConflicts = useMemo(() => overlaps(draft.works), [draft.works]);
const conflicts = useMemo(() => mergeConflictMaps(liveConflicts, serviceConflicts), [liveConflicts, serviceConflicts]);
- const selectedWork = draft.works.find((work) => work.id === selectedWorkId);
+ const selectedWork = displayWorks.find((work) => work.id === selectedWorkId);
const activeLabel = platformLabel(platforms, selectedSpacecraftId);
const [shootPasses, setShootPasses] = useState([]);
const [shootPassesLoading, setShootPassesLoading] = useState(false);
@@ -187,6 +195,10 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
const dirty = history.past.length > 0 || draft.works.some((work) => work.isNew);
const addedCount = draft.works.filter((work) => work.isNew).length;
+ // Реальный состав плана берём из включений (plan modes), а не из черновика:
+ // черновик сидируется заглушкой, когда у плана нет встроенных works.
+ const planSurveyCount = useMemo(() => planModes.filter((mode) => mode.type === "SURVEY").length, [planModes]);
+ const planDownlinkCount = useMemo(() => planModes.filter((mode) => mode.type === "DROP").length, [planModes]);
useEffect(() => {
setHistory({ draft: seedDraft(selectedPlan, selectedSpacecraftId), past: [], future: [] });
@@ -559,7 +571,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
const el = centerRef.current;
if (!el) return;
const dy = ev.clientY - startY;
- setMapRatio(Math.max(0.15, Math.min(0.80, startRatio + dy / el.clientHeight)));
+ setMapRatio(Math.max(0.15, Math.min(0.86, startRatio + dy / el.clientHeight)));
};
const onUp = () => {
@@ -618,7 +630,8 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
selectedSpacecraftId={selectedSpacecraftId}
selectedPlan={selectedPlan}
platforms={platforms}
- draft={draft}
+ surveyCount={planSurveyCount}
+ downlinkCount={planDownlinkCount}
conflictCount={conflictCount(conflicts)}
contextSpacecraftIds={contextSpacecraftIds}
onToggleContext={(spacecraftId) => {
@@ -681,10 +694,11 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
@@ -711,6 +724,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
modes={modes}
stations={stations}
conflicts={conflicts[selectedWork.id] ?? []}
+ readOnly={selectedWork.origin === "plan"}
onUpdate={onChangeWork}
onDelete={(workId) => {
changeDraft((current) => deleteWork(current, workId));
@@ -810,7 +824,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
}
function visibilityWindowFromParam(param: PointVisibilityParam): EditorVisibilityWindow {
- const timeMs = Date.parse(param.time);
+ const timeMs = parseUtc(param.time);
const half = SHOOT_WINDOW_MS / 2;
return {
startMs: timeMs - half,
@@ -851,8 +865,7 @@ function buildStations(selectedPlan: TguEditorTabProps["selectedPlan"]): EditorG
* зоны ЗРВ приходят без таймзоны, а времена съёмок — с offset; приводим к одной шкале.
*/
function localDateTimeMs(s: string): number {
- const hasZone = /[zZ]$|[+-]\d{2}:?\d{2}$/.test(s);
- return Date.parse(hasZone ? s : `${s}Z`);
+ return parseUtc(s);
}
function buildVisibilityTracks(
diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorDraft.test.ts b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorDraft.test.ts
index f6ec4ec..56401a8 100644
--- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorDraft.test.ts
+++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorDraft.test.ts
@@ -6,11 +6,13 @@ import {
deleteWork,
duplicateWork,
mutate,
+ planModesToWorks,
redo,
seedDraft,
undo,
updateWork
} from "./editorDraft";
+import type { PlanMode } from "./editorApi";
import type { DraftHistory } from "./model/editorTypes";
const plan: TguPlanUi = {
@@ -25,24 +27,81 @@ const plan: TguPlanUi = {
};
describe("editorDraft", () => {
- it("seeds a draft from the current API plan envelope when embedded works are absent", () => {
+ it("seeds an empty draft when the plan has no embedded works", () => {
+ // Существующие включения грузятся отдельно (planModesToWorks); черновик хранит только правки.
expect(seedDraft(plan)).toEqual({
planId: "plan-1",
spacecraftId: "56756",
- works: [
- {
- id: "plan-1:plan-window",
- kind: "downlink",
- label: "План ТГУ · KPP-1",
- startMs: plan.startMs,
- endMs: plan.endMs,
- stationId: "KPP-1",
- origin: "plan"
- }
- ]
+ works: []
});
});
+ it("maps plan modes to read-only timeline works", () => {
+ const modes: PlanMode[] = [
+ {
+ id: 11,
+ planId: 1,
+ timeStart: "2026-05-29T01:10:00",
+ revolution: 4321,
+ type: "SURVEY",
+ status: "PLANNED",
+ lat: 55.7,
+ longitude: 37.6,
+ duration: 180,
+ contourWkt: null,
+ roll: 0
+ },
+ {
+ id: 22,
+ planId: 1,
+ timeStart: "2026-05-29T01:40:00",
+ revolution: 4322,
+ type: "DROP",
+ station: "102",
+ duration: 120,
+ surveys: [11]
+ },
+ {
+ id: 33,
+ planId: 1,
+ timeStart: null,
+ revolution: 4323,
+ type: "SURVEY",
+ status: "PLANNED",
+ lat: 0,
+ longitude: 0,
+ duration: 60,
+ contourWkt: null,
+ roll: 0
+ }
+ ];
+
+ const works = planModesToWorks(modes);
+
+ // Включение без времени старта пропускается, оба валидных — read-only (origin: "plan").
+ expect(works).toEqual([
+ {
+ id: "survey-11",
+ kind: "shoot",
+ label: "Съёмка · виток 4321",
+ startMs: Date.parse("2026-05-29T01:10:00Z"),
+ endMs: Date.parse("2026-05-29T01:10:00Z") + 180_000,
+ origin: "plan",
+ targetLat: 55.7,
+ targetLon: 37.6
+ },
+ {
+ id: "drop-22",
+ kind: "downlink",
+ label: "Сброс · 102",
+ startMs: Date.parse("2026-05-29T01:40:00Z"),
+ endMs: Date.parse("2026-05-29T01:40:00Z") + 120_000,
+ origin: "plan",
+ stationId: "102"
+ }
+ ]);
+ });
+
it("adds shoot and downlink works with deterministic ids", () => {
const draft = seedDraft(undefined, "56756");
const withShoot = addShoot(draft, {
@@ -88,10 +147,16 @@ describe("editorDraft", () => {
});
it("updates, duplicates and deletes works without generating ids internally", () => {
- const draft = seedDraft(plan);
- const updated = updateWork(draft, "plan-1:plan-window", { label: "Сеанс", startMs: plan.startMs + 60_000 });
- const duplicated = duplicateWork(updated, "plan-1:plan-window", "copy-1", 120_000);
- const deleted = deleteWork(duplicated, "plan-1:plan-window");
+ const draft = addDownlink(seedDraft(plan), {
+ id: "downlink-1",
+ label: "Сброс на НКПОР",
+ startMs: plan.startMs,
+ endMs: plan.startMs + 120_000,
+ station: { id: "KPP-1", name: "KPP-1" }
+ });
+ const updated = updateWork(draft, "downlink-1", { label: "Сеанс", startMs: plan.startMs + 60_000 });
+ const duplicated = duplicateWork(updated, "downlink-1", "copy-1", 120_000);
+ const deleted = deleteWork(duplicated, "downlink-1");
expect(updated.works[0].label).toBe("Сеанс");
expect(duplicated.works[1]).toMatchObject({
diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorDraft.ts b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorDraft.ts
index 0153d3e..d3b1b4e 100644
--- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorDraft.ts
+++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorDraft.ts
@@ -1,4 +1,6 @@
import type { TguPlanUi } from "../../model/timelineTypes";
+import { parseUtc } from "../../utils/utcTime";
+import type { PlanMode } from "./editorApi";
import type {
Draft,
DraftHistory,
@@ -35,14 +37,53 @@ export function seedDraft(plan?: TguPlanUi, spacecraftId?: string): Draft {
return { spacecraftId, works: [] };
}
- const embeddedWorks = readEmbeddedWorks(plan);
return {
planId: plan.planId,
spacecraftId: plan.spacecraftId,
- works: embeddedWorks.length > 0 ? embeddedWorks : [fallbackPlanWork(plan)]
+ // Черновик содержит только правки, внесённые в редакторе. Существующие включения
+ // плана грузятся отдельно (fetchPlanModes) и показываются как read-only.
+ works: readEmbeddedWorks(plan)
};
}
+/**
+ * Преобразует включения плана (plan modes из pcp-tgu-service) в работы для отображения
+ * на таймлайне редактора. Это read-only представление фактического состава плана:
+ * SURVEY → съёмка, DROP → сброс на НКПОР.
+ */
+export function planModesToWorks(modes: PlanMode[]): EditorWork[] {
+ return modes.flatMap((mode): EditorWork[] => {
+ if (mode.timeStart == null) return [];
+ const startMs = parseUtc(mode.timeStart);
+ if (!Number.isFinite(startMs)) return [];
+ const endMs = startMs + Math.max(1, mode.duration) * 1000;
+
+ if (mode.type === "SURVEY") {
+ return [{
+ id: `survey-${mode.id}`,
+ kind: "shoot",
+ label: `Съёмка · виток ${mode.revolution}`,
+ startMs,
+ endMs,
+ origin: "plan",
+ targetLat: mode.lat,
+ targetLon: mode.longitude
+ }];
+ }
+
+ return [{
+ id: `drop-${mode.id}`,
+ kind: "downlink",
+ label: mode.station ? `Сброс · ${mode.station}` : "Сброс на НКПОР",
+ startMs,
+ endMs,
+ origin: "plan",
+ stationId: mode.station ?? undefined
+ }];
+ });
+}
+
+
export function mutate(
history: DraftHistory,
transform: (draft: Draft) => Draft,
@@ -154,18 +195,6 @@ export function duplicateWork(
return { ...draft, works: [...draft.works, duplicated] };
}
-function fallbackPlanWork(plan: TguPlanUi): EditorDownlinkWork {
- return {
- id: `${plan.planId}:plan-window`,
- kind: "downlink",
- label: `План ТГУ · ${plan.kppId}`,
- startMs: plan.startMs,
- endMs: plan.endMs,
- stationId: plan.kppId,
- origin: "plan"
- };
-}
-
function readEmbeddedWorks(plan: TguPlanUi): EditorWork[] {
const rawWorks = (plan as TguPlanUi & { works?: unknown }).works;
if (!Array.isArray(rawWorks)) {
@@ -217,7 +246,7 @@ function readTime(record: Record
, key: string): number | undefi
return value;
}
if (typeof value === "string") {
- const parsed = new Date(value).getTime();
+ const parsed = parseUtc(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
return undefined;
diff --git a/services/pcp-tgu-ops-ui/src/styles/tgu-editor.css b/services/pcp-tgu-ops-ui/src/styles/tgu-editor.css
index 0361f45..ab64a8e 100644
--- a/services/pcp-tgu-ops-ui/src/styles/tgu-editor.css
+++ b/services/pcp-tgu-ops-ui/src/styles/tgu-editor.css
@@ -147,6 +147,25 @@
font-weight: 700;
}
+.tgu-editor-rail__plan-dates {
+ display: grid;
+ grid-template-columns: auto 1fr;
+ gap: 0.2rem 0.6rem;
+ margin: 0;
+}
+
+.tgu-editor-rail__plan-dates dt {
+ color: var(--text-dim);
+ font-size: 0.66rem;
+}
+
+.tgu-editor-rail__plan-dates dd {
+ margin: 0;
+ color: var(--text);
+ font-size: 0.7rem;
+ text-align: right;
+}
+
.tgu-editor-stats {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -553,26 +572,34 @@
border-left: 1px solid var(--grid);
}
-.tgu-editor-timeline__now {
+.tgu-editor-timeline__bound {
position: absolute;
bottom: 0;
z-index: 8;
- border-left: 1.5px dashed var(--now);
+ border-left: 1.5px solid var(--accent);
pointer-events: none;
}
-.tgu-editor-timeline__now span {
+.tgu-editor-timeline__bound span {
position: absolute;
- left: 0.18rem;
top: 0;
border-radius: 3px;
padding: 0 0.24rem;
- background: var(--now);
+ background: var(--accent);
color: var(--bg-base);
font-family: var(--mono);
font-size: 0.55rem;
font-weight: 700;
text-transform: uppercase;
+ white-space: nowrap;
+}
+
+.tgu-editor-timeline__bound--start span {
+ left: 0.18rem;
+}
+
+.tgu-editor-timeline__bound--end span {
+ right: 0.18rem;
}
.tgu-editor-timeline__lane {
@@ -622,11 +649,15 @@
gap: 0.28rem;
height: calc(100% - 1.5rem);
overflow: hidden;
+ appearance: none;
border: 1px solid var(--line);
border-left: 3px solid var(--accent);
border-radius: 5px;
padding: 0.25rem 0.45rem;
- cursor: grab;
+ color: inherit;
+ font: inherit;
+ text-align: left;
+ cursor: pointer;
user-select: none;
}
@@ -649,20 +680,43 @@
pointer-events: none;
}
-.tgu-editor-work__resize {
+/* Активная дорожка разбита на два ряда: верхний — съёмка, нижний — сброс.
+ Бар центрируется по своему ряду и растёт вместе с ним, но не выше потолка —
+ чтобы узкие включения не растягивались в непропорционально высокие полоски. */
+.tgu-editor-work--shoot,
+.tgu-editor-work--downlink {
+ height: min(56px, calc(50% - 0.55rem));
+ transform: translateY(-50%);
+}
+
+.tgu-editor-work--shoot {
+ top: 25%;
+}
+
+.tgu-editor-work--downlink {
+ top: 75%;
+}
+
+.tgu-editor-timeline__row-hint {
position: absolute;
- top: 0;
- bottom: 0;
- width: 0.45rem;
- cursor: ew-resize;
+ right: 0.65rem;
+ font-style: normal;
+ font-size: 0.56rem;
+ font-weight: 700;
+ letter-spacing: 0.02em;
+ text-transform: uppercase;
}
-.tgu-editor-work__resize--left {
- left: 0;
+.tgu-editor-timeline__row-hint--shoot {
+ top: 25%;
+ transform: translateY(-50%);
+ color: var(--t-optical);
}
-.tgu-editor-work__resize--right {
- right: 0;
+.tgu-editor-timeline__row-hint--downlink {
+ top: 75%;
+ transform: translateY(-50%);
+ color: var(--accent);
}
.tgu-editor-work__dot {