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, 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 { return typeof value === "object" && value !== null; }