pcp-tgu-ops-ui: вкладка Редактор — план, включения, нижняя панель

- Слева выводим даты начала/конца плана и корректные счётчики
  съёмки/сброса (из plan modes, а не из заглушки-черновика).
- На таймлайне показываем реальные включения плана (read-only): убран
  фейковый «сброс на весь интервал»; вместо линии «сейчас» — маркеры
  начала и конца плана; клик по включению открывает его инфо справа.
- Включения съёмки и сброса разнесены на два ряда (верх/низ) и различаются
  цветом, слева — подписи рядов.
- Нижняя панель: активная дорожка резиновая — примыкает к низу и не
  пустует, при подъёме панели ряды расширяются; высота бара ограничена
  потолком и центрируется в ряду, чтобы узкие включения не вытягивались.
- Время в редакторе парсится/форматируется через utils/utcTime.
This commit is contained in:
Дмитрий Соловьев
2026-06-02 16:54:01 +03:00
parent fb0332a030
commit ac2220d063
7 changed files with 340 additions and 181 deletions
@@ -1,4 +1,6 @@
import type { TguPlanUi } from "../../model/timelineTypes";
import { parseUtc } from "../../utils/utcTime";
import type { PlanMode } from "./editorApi";
import type {
Draft,
DraftHistory,
@@ -35,14 +37,53 @@ export function seedDraft(plan?: TguPlanUi, spacecraftId?: string): Draft {
return { spacecraftId, works: [] };
}
const embeddedWorks = readEmbeddedWorks(plan);
return {
planId: plan.planId,
spacecraftId: plan.spacecraftId,
works: embeddedWorks.length > 0 ? embeddedWorks : [fallbackPlanWork(plan)]
// Черновик содержит только правки, внесённые в редакторе. Существующие включения
// плана грузятся отдельно (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",
targetLat: mode.lat,
targetLon: mode.longitude
}];
}
return [{
id: `drop-${mode.id}`,
kind: "downlink",
label: mode.station ? `Сброс · ${mode.station}` : "Сброс на НКПОР",
startMs,
endMs,
origin: "plan",
stationId: mode.station ?? undefined
}];
});
}
export function mutate(
history: DraftHistory,
transform: (draft: Draft) => Draft,
@@ -154,18 +195,6 @@ export function duplicateWork(
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)) {
@@ -217,7 +246,7 @@ function readTime(record: Record<string, unknown>, key: string): number | undefi
return value;
}
if (typeof value === "string") {
const parsed = new Date(value).getTime();
const parsed = parseUtc(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
return undefined;