Files
dc-observatio/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorDraft.ts
T

292 lines
8.1 KiB
TypeScript

import type { TguPlanUi } from "../../model/timelineTypes";
import { parseUtc } from "../../utils/utcTime";
import type { PlanMode } from "./editorApi";
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;
/** id сохранённого режима съёмки в БД черновика (если съёмка персистнута). */
planModeId?: number;
/** Параметры построенного маршрута (если съёмка строилась с превью). */
revolution?: number;
rollDeg?: number;
captureAngleDeg?: number;
centerLat?: number;
centerLon?: number;
};
export type AddDownlinkInput = {
id: string;
startMs: number;
endMs: number;
label: string;
station: EditorGroundStation;
/** id сохранённого режима сброса в БД черновика (если сброс персистнут). */
planModeId?: number;
};
export function seedDraft(plan?: TguPlanUi, spacecraftId?: string): Draft {
if (!plan) {
return { spacecraftId, works: [] };
}
return {
planId: plan.planId,
spacecraftId: plan.spacecraftId,
// Черновик содержит только правки, внесённые в редакторе. Существующие включения
// плана грузятся отдельно (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",
planModeId: mode.id,
revolution: mode.revolution,
rollDeg: mode.roll,
centerLat: mode.lat,
centerLon: mode.longitude,
// Центр контура — он же опорная точка для проверки видимости.
targetLat: mode.lat,
targetLon: mode.longitude
}];
}
return [{
id: `drop-${mode.id}`,
kind: "downlink",
label: mode.station ? `Сброс · ${mode.station}` : "Сброс на НКПОР",
startMs,
endMs,
origin: "plan",
planModeId: mode.id,
revolution: mode.revolution,
stationId: mode.station ?? undefined,
surveyIds: mode.surveys
}];
});
}
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,
planModeId: input.planModeId,
revolution: input.revolution,
rollDeg: input.rollDeg,
captureAngleDeg: input.captureAngleDeg,
centerLat: input.centerLat,
centerLon: input.centerLon,
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,
planModeId: input.planModeId,
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 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 = parseUtc(value);
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;
}