292 lines
8.1 KiB
TypeScript
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;
|
|
}
|