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