feat(tgu-ops-ui): add spacecraft work editor tab

Port the TGU editor prototype into pcp-tgu-ops-ui as a new editor tab, add draft/conflict/pass logic with tests, extend the 2D map picking API, and include editor styles/theme aliases and task/prototype docs.

Validation: npm run test; npm run build in services/pcp-tgu-ops-ui.
This commit is contained in:
Дмитрий Соловьев
2026-05-30 15:28:34 +03:00
parent c3a1a8b4a1
commit f084775cbb
58 changed files with 6609 additions and 7 deletions
@@ -0,0 +1,236 @@
import type { TguPlanUi } from "../../model/timelineTypes";
import type {
Draft,
DraftHistory,
EditorDownlinkWork,
EditorGroundStation,
EditorShootWork,
EditorTarget,
EditorWork,
EditorWorkPatch
} from "./model/editorTypes";
const DEFAULT_DUPLICATE_OFFSET_MS = 20 * 60 * 1000;
const DEFAULT_HISTORY_LIMIT = 50;
export type AddShootInput = {
id: string;
startMs: number;
endMs: number;
label: string;
modeId?: string;
target: EditorTarget;
};
export type AddDownlinkInput = {
id: string;
startMs: number;
endMs: number;
label: string;
station: EditorGroundStation;
};
export function seedDraft(plan?: TguPlanUi, spacecraftId?: string): Draft {
if (!plan) {
return { spacecraftId, works: [] };
}
const embeddedWorks = readEmbeddedWorks(plan);
return {
planId: plan.planId,
spacecraftId: plan.spacecraftId,
works: embeddedWorks.length > 0 ? embeddedWorks : [fallbackPlanWork(plan)]
};
}
export function mutate(
history: DraftHistory,
transform: (draft: Draft) => Draft,
historyLimit = DEFAULT_HISTORY_LIMIT
): DraftHistory {
const nextDraft = transform(history.draft);
if (nextDraft === history.draft) {
return history;
}
return {
draft: nextDraft,
past: [...history.past, history.draft].slice(-historyLimit),
future: []
};
}
export function undo(history: DraftHistory): DraftHistory {
if (history.past.length === 0) {
return history;
}
return {
draft: history.past[history.past.length - 1],
past: history.past.slice(0, -1),
future: [history.draft, ...history.future]
};
}
export function redo(history: DraftHistory): DraftHistory {
if (history.future.length === 0) {
return history;
}
return {
draft: history.future[0],
past: [...history.past, history.draft],
future: history.future.slice(1)
};
}
export function addShoot(draft: Draft, input: AddShootInput): Draft {
const work: EditorShootWork = {
id: input.id,
kind: "shoot",
label: input.label,
startMs: input.startMs,
endMs: input.endMs,
modeId: input.modeId,
targetName: input.target.name,
targetLat: input.target.lat,
targetLon: input.target.lon,
origin: "added",
isNew: true
};
return { ...draft, works: [...draft.works, work] };
}
export function addDownlink(draft: Draft, input: AddDownlinkInput): Draft {
const work: EditorDownlinkWork = {
id: input.id,
kind: "downlink",
label: input.label,
startMs: input.startMs,
endMs: input.endMs,
stationId: input.station.id,
origin: "added",
isNew: true
};
return { ...draft, works: [...draft.works, work] };
}
export function updateWork(draft: Draft, workId: string, patch: EditorWorkPatch): Draft {
return {
...draft,
works: draft.works.map((work) => (work.id === workId ? ({ ...work, ...patch } as EditorWork) : work))
};
}
export function deleteWork(draft: Draft, workId: string): Draft {
return {
...draft,
works: draft.works.filter((work) => work.id !== workId)
};
}
export function duplicateWork(
draft: Draft,
workId: string,
newId: string,
offsetMs = DEFAULT_DUPLICATE_OFFSET_MS
): Draft {
const source = draft.works.find((work) => work.id === workId);
if (!source) {
return draft;
}
const duplicated: EditorWork = {
...source,
id: newId,
startMs: source.startMs + offsetMs,
endMs: source.endMs + offsetMs,
origin: "added",
isNew: true
};
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)) {
return [];
}
return rawWorks.flatMap((value, index): EditorWork[] => {
if (!isRecord(value)) return [];
const kind = value.kind === "shoot" || value.kind === "downlink" ? value.kind : undefined;
const startMs = readTime(value, "startMs") ?? readTime(value, "start");
const endMs = readTime(value, "endMs") ?? readTime(value, "end");
if (!kind || startMs === undefined || endMs === undefined || startMs >= endMs) {
return [];
}
const id = readString(value.id) ?? `${plan.planId}:work-${index + 1}`;
const label = readString(value.label) ?? (kind === "shoot" ? "Съёмка" : "Сброс на НКПОР");
const base = {
id,
kind,
label,
startMs,
endMs,
origin: "plan" as const
};
if (kind === "shoot") {
return [{
...base,
kind,
modeId: readString(value.modeId),
targetName: readString(value.targetName),
targetLat: readNumber(value.targetLat),
targetLon: readNumber(value.targetLon)
}];
}
return [{
...base,
kind,
stationId: readString(value.stationId) ?? readString(value.kppId)
}];
});
}
function readTime(record: Record<string, unknown>, key: string): number | undefined {
const value = record[key];
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === "string") {
const parsed = new Date(value).getTime();
return Number.isFinite(parsed) ? parsed : undefined;
}
return undefined;
}
function readString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value : undefined;
}
function readNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}