pcp-tgu: пауза цепочки после REJECTED + режим «Черновики» + UI цепочек/ЗРВ
Бэкенд (pcp-tgu-service): - REJECTED трактуется как «план неправильный»: авто-перевыдача больше НЕ взводится, цепочка встаёт на паузу (новый флаг SpacecraftPlanningStateEntity.pausedAfterReject, миграция V13). Технические негативы («не заложен»/«не принят») по-прежнему дают срочную перевыдачу. - Пауза централизована в PlanProjectionService.project(): для отклонённой цепочки проекция пустая, воркер выдачи ничего не материализует. Флаг снимается при ручной выдаче/промоуте черновика/приёмке (оператор вмешался). - Режим «Черновики»: GET /plans/drafts (опц. ?spacecraftId) -> только DRAFT. - Гард дублей черновиков: (КА + начало + конец) уникальны среди DRAFT, повторное создание окна -> 409. - ZrvWindowMatcher: сравнение по истинной abs-длительности Duration (<=1с), устойчиво к усечению ISO на фронте до целых секунд. Фронт (pcp-tgu-ops-ui): вкладка «Планы» — таймлайн цепочек, детали плана, панель ЗРВ (TguZrvPanel), диалог создания цепочки, режим черновиков. Удалён устаревший docs/tasks/TASK_tgu-plan-versioning-manual-reissue.md.
This commit is contained in:
@@ -20,7 +20,7 @@ import { parseWktPolygon } from "../tgu-map-2d/geometry/wktParser";
|
||||
import { buildStationMarkers } from "../tgu-map-2d/Tgu2DMapLayers";
|
||||
import type { Tgu2DMapLayersState } from "../tgu-map-2d/model/mapLayerTypes";
|
||||
import type { Tgu2DMapSelection } from "../tgu-map-2d/model/mapSelectionTypes";
|
||||
import type { MapLine, MapPolygon, Tgu2DMapScene } from "../tgu-map-2d/model/mapTypes";
|
||||
import type { GeoPoint, MapLine, MapPolygon, Tgu2DMapScene } from "../tgu-map-2d/model/mapTypes";
|
||||
import { fetchPlanModes, type PlanMode, type PlanSurveyMode } from "./editorApi";
|
||||
import { validationToConflicts } from "./editorValidation";
|
||||
import {
|
||||
@@ -124,6 +124,8 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
||||
const [passFrom, setPassFrom] = useState(1);
|
||||
const [passTo, setPassTo] = useState(999);
|
||||
const [flightLineData, setFlightLineData] = useState<FlightLinePoint[]>([]);
|
||||
// Команда карте центрироваться/подсветить объект выбранного включения (клик по включению).
|
||||
const [mapFocus, setMapFocus] = useState<{ point?: GeoPoint; objectId?: string; token: number }>({ token: 0 });
|
||||
const draft = history.draft;
|
||||
const stations = useMemo(() => buildStations(selectedPlan), [selectedPlan]);
|
||||
const mapRange = useMemo<PassRange>(() => {
|
||||
@@ -192,6 +194,15 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
||||
() => buildEditorMapScene(selectedSpacecraftId, flightLineData, orbitPasses, stationDtos, requestItems, planModes, effectiveFrom, effectiveTo, surveyRoute),
|
||||
[selectedSpacecraftId, flightLineData, orbitPasses, stationDtos, requestItems, planModes, effectiveFrom, effectiveTo, surveyRoute]
|
||||
);
|
||||
// Клик по включению = «как клик по объекту на карте»: центрируем карту на нём и подсвечиваем.
|
||||
const focusWorkOnMap = useCallback(
|
||||
(workId: string) => {
|
||||
const work = displayWorks.find((w) => w.id === workId);
|
||||
const focus = workMapFocus(work, mapScene);
|
||||
setMapFocus((prev) => ({ point: focus.point, objectId: focus.objectId, token: prev.token + 1 }));
|
||||
},
|
||||
[displayWorks, mapScene]
|
||||
);
|
||||
const contextLanes = useMemo(
|
||||
() =>
|
||||
Array.from(contextSpacecraftIds).map((spacecraftId) => ({
|
||||
@@ -264,8 +275,8 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
void fetchRequestsForMap({
|
||||
endFrom: new Date(window.fromMs).toISOString(),
|
||||
beginTo: new Date(window.toMs).toISOString()
|
||||
endFrom: new Date(window.fromMs).toISOString().slice(0, 19),
|
||||
beginTo: new Date(window.toMs).toISOString().slice(0, 19)
|
||||
}).then((result) => {
|
||||
if (!controller.signal.aborted) {
|
||||
setRequestItems(result.items);
|
||||
@@ -411,9 +422,13 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
||||
};
|
||||
}, [rightMode, noradId, window.fromMs, window.toMs]);
|
||||
|
||||
// Смена выбранного плана сбрасывает открытый черновик: правки относятся к конкретному плану.
|
||||
// Если выбран уже существующий черновик (status DRAFT — из режима «Черновики»/диалога цепочки),
|
||||
// редактор работает с ним напрямую; иначе черновик создаётся лениво на первую правку (replace).
|
||||
// createdImplicitlyRef различает эти случаи: авто-удалять при уходе можно только неявно созданный.
|
||||
const createdImplicitlyRef = useRef(false);
|
||||
useEffect(() => {
|
||||
setDraftPlanId(undefined);
|
||||
setDraftPlanId(selectedPlan?.status === "DRAFT" ? selectedPlan.planId : undefined);
|
||||
createdImplicitlyRef.current = false;
|
||||
setServiceConflicts({});
|
||||
}, [selectedPlan]);
|
||||
|
||||
@@ -423,7 +438,8 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
||||
useEffect(() => { draftPlanIdRef.current = draftPlanId; }, [draftPlanId]);
|
||||
useEffect(() => () => {
|
||||
const stale = draftPlanIdRef.current;
|
||||
if (stale) {
|
||||
// Авто-отмена только для неявно созданного черновика; явно открытый DRAFT не трогаем.
|
||||
if (stale && createdImplicitlyRef.current) {
|
||||
// best-effort: применённый/уже отменённый черновик сюда не попадёт (ref обнулён).
|
||||
void abandonDraft(stale).catch(() => {});
|
||||
}
|
||||
@@ -459,7 +475,13 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
||||
const ensureDraft = useCallback(async (): Promise<string> => {
|
||||
if (draftPlanId) return draftPlanId;
|
||||
if (!selectedPlan) throw new Error("План не выбран — нечего редактировать.");
|
||||
// Выбран уже существующий черновик — правим его напрямую, новый клон не создаём.
|
||||
if (selectedPlan.status === "DRAFT") {
|
||||
setDraftPlanId(selectedPlan.planId);
|
||||
return selectedPlan.planId;
|
||||
}
|
||||
const created = await createDraft(selectedPlan.planId, { mode: "replace" });
|
||||
createdImplicitlyRef.current = true;
|
||||
setDraftPlanId(created.draftPlanId);
|
||||
return created.draftPlanId;
|
||||
}, [draftPlanId, selectedPlan]);
|
||||
@@ -863,6 +885,9 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
||||
setNotice(undefined);
|
||||
}}
|
||||
onObjectSelect={handleMapObjectSelect}
|
||||
focusPoint={mapFocus.point}
|
||||
focusToken={mapFocus.token}
|
||||
selectedObjectId={mapFocus.objectId}
|
||||
/>
|
||||
<div className="tgu-editor-map-resizer" onMouseDown={handleResizerDown} />
|
||||
</div>
|
||||
@@ -880,11 +905,13 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
||||
setSelectedWorkId(workId);
|
||||
setRightMode("inspect");
|
||||
setNotice(undefined);
|
||||
focusWorkOnMap(workId);
|
||||
}}
|
||||
onClearSelection={() => {
|
||||
if (rightMode === "inspect") {
|
||||
setSelectedWorkId(undefined);
|
||||
setRightMode("none");
|
||||
setMapFocus((prev) => ({ ...prev, objectId: undefined }));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -1168,11 +1195,34 @@ function buildEditorMapScene(
|
||||
layer: "planWorks",
|
||||
points: ring,
|
||||
status: "PLANNED",
|
||||
modeId: mode.id,
|
||||
zIndex: zIndex++
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Сбросы (DROP) — яркая толстая линия на трассе: берём точки трассы за интервал сеанса
|
||||
// [timeStart, timeStart + duration]. Это и есть участок витка, над которым идёт сброс.
|
||||
for (const mode of planModes) {
|
||||
if (mode.type !== "DROP" || mode.timeStart == null) continue;
|
||||
const startMs = localDateTimeMs(mode.timeStart);
|
||||
const endMs = startMs + mode.duration * 1000;
|
||||
const pts = flightLineData
|
||||
.map((p) => ({ t: parseUtc(p.time), lat: p.lat, lon: p.long }))
|
||||
.filter((p) => p.t >= startMs && p.t <= endMs)
|
||||
.sort((a, b) => a.t - b.t)
|
||||
.map((p) => ({ lat: p.lat, lon: p.lon }));
|
||||
if (pts.length < 2) continue;
|
||||
lines.push({
|
||||
id: `drop:${mode.id}`,
|
||||
layer: "tracks",
|
||||
kind: "drop",
|
||||
modeId: mode.id,
|
||||
points: pts,
|
||||
zIndex: zIndex++
|
||||
});
|
||||
}
|
||||
|
||||
for (const item of requestItems) {
|
||||
const rings = parseWktPolygon(item.geometry);
|
||||
for (const ring of rings) {
|
||||
@@ -1226,3 +1276,43 @@ function nextWorkId(ref: MutableRefObject<number>): string {
|
||||
ref.current += 1;
|
||||
return `NW-${String(ref.current).padStart(4, "0")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Объект карты, соответствующий включению, и точка для центрирования:
|
||||
* - съёмка → контур (planWorks) по modeId либо только что построенный маршрут; точка — центр контура;
|
||||
* - сброс → яркая линия сброса (drop) по modeId; точка — середина линии.
|
||||
*/
|
||||
function workMapFocus(work: EditorWork | undefined, scene: Tgu2DMapScene): { point?: GeoPoint; objectId?: string } {
|
||||
if (!work) return {};
|
||||
if (work.kind === "shoot") {
|
||||
const polygon =
|
||||
work.planModeId != null
|
||||
? scene.polygons.find((p) => p.modeId === work.planModeId)
|
||||
: scene.polygons.find((p) => p.id.startsWith("surveyRoute:"));
|
||||
const point: GeoPoint | undefined =
|
||||
work.centerLat != null && work.centerLon != null
|
||||
? { lat: work.centerLat, lon: work.centerLon }
|
||||
: polygon
|
||||
? polygonCentroid(polygon.points)
|
||||
: undefined;
|
||||
return { point, objectId: polygon?.id };
|
||||
}
|
||||
|
||||
const line =
|
||||
work.planModeId != null
|
||||
? scene.lines.find((l) => l.kind === "drop" && l.modeId === work.planModeId)
|
||||
: undefined;
|
||||
const point = line && line.points.length > 0 ? line.points[Math.floor(line.points.length / 2)] : undefined;
|
||||
return { point, objectId: line?.id };
|
||||
}
|
||||
|
||||
function polygonCentroid(points: GeoPoint[]): GeoPoint | undefined {
|
||||
if (points.length === 0) return undefined;
|
||||
let lat = 0;
|
||||
let lon = 0;
|
||||
for (const p of points) {
|
||||
lat += p.lat;
|
||||
lon += p.lon;
|
||||
}
|
||||
return { lat: lat / points.length, lon: lon / points.length };
|
||||
}
|
||||
|
||||
@@ -20,6 +20,11 @@ type Tgu2DMapViewProps = {
|
||||
onRequestsAtPoint?: (polygons: MapPolygon[]) => void;
|
||||
hoveredRequestPolygonId?: string;
|
||||
selectedRequestPolygonId?: string;
|
||||
/** Внешняя подсветка объекта (контур съёмки или линия сброса) по его id — «как будто кликнули на карте». */
|
||||
selectedObjectId?: string;
|
||||
/** Точка, на которую центрировать карту; перецентрирование срабатывает при смене focusToken. */
|
||||
focusPoint?: GeoPoint;
|
||||
focusToken?: number;
|
||||
pickMode?: boolean;
|
||||
onPickPoint?: (point: { lat: number; lon: number }) => void;
|
||||
targets?: Tgu2DMapTarget[];
|
||||
@@ -68,6 +73,9 @@ export function Tgu2DMapView({
|
||||
onRequestsAtPoint,
|
||||
hoveredRequestPolygonId,
|
||||
selectedRequestPolygonId,
|
||||
selectedObjectId,
|
||||
focusPoint,
|
||||
focusToken,
|
||||
pickMode = false,
|
||||
onPickPoint,
|
||||
targets = [],
|
||||
@@ -139,6 +147,19 @@ export function Tgu2DMapView({
|
||||
return () => resizeObserver.disconnect();
|
||||
}, []);
|
||||
|
||||
// Перецентрирование по внешней команде (клик по включению в редакторе): центрируем карту
|
||||
// на объекте и подтягиваем зум, чтобы он был хорошо виден. Триггер — смена focusToken,
|
||||
// поэтому повторный клик по тому же включению снова возвращает карту к объекту.
|
||||
useEffect(() => {
|
||||
if (!focusPoint || focusToken === undefined) return;
|
||||
setView((current) => ({
|
||||
centerLon: focusPoint.lon,
|
||||
centerLat: Math.max(-82, Math.min(82, focusPoint.lat)),
|
||||
zoom: Math.max(current.zoom, 4.5)
|
||||
}));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [focusToken]);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
@@ -344,9 +365,14 @@ export function Tgu2DMapView({
|
||||
|
||||
const selectedPolygon = selectedPolygonRef.current;
|
||||
const selectedMarker = selectedMarkerRef.current;
|
||||
const externalSelectedPolygon = useMemo(
|
||||
() => selectedRequestPolygonId ? activePolygons.find((p) => p.id === selectedRequestPolygonId) : undefined,
|
||||
[selectedRequestPolygonId, activePolygons]
|
||||
const externalSelectedPolygon = useMemo(() => {
|
||||
const id = selectedRequestPolygonId ?? selectedObjectId;
|
||||
return id ? activePolygons.find((p) => p.id === id) : undefined;
|
||||
}, [selectedRequestPolygonId, selectedObjectId, activePolygons]);
|
||||
// Подсветка линии (сброс/трасса) по тому же selectedObjectId — линии живут вне activePolygons.
|
||||
const externalSelectedLine = useMemo(
|
||||
() => (selectedObjectId ? scene.lines.find((line) => line.id === selectedObjectId) : undefined),
|
||||
[selectedObjectId, scene.lines]
|
||||
);
|
||||
const externalHoveredPolygon = useMemo(
|
||||
() => hoveredRequestPolygonId ? activePolygons.find((p) => p.id === hoveredRequestPolygonId) : undefined,
|
||||
@@ -396,6 +422,24 @@ export function Tgu2DMapView({
|
||||
);
|
||||
});
|
||||
})}
|
||||
{externalSelectedLine && externalSelectedLine.points.length >= 2 && (() => {
|
||||
const pts = unwrapX(externalSelectedLine.points.map((point) => projection.project(point)), projection.worldSize);
|
||||
return ([0, projection.worldSize, -projection.worldSize] as const).flatMap((dx) => {
|
||||
const visible = pts.some((p) => {
|
||||
const x = p.x + dx;
|
||||
return x >= -64 && x <= size.width + 64 && p.y >= -64 && p.y <= size.height + 64;
|
||||
});
|
||||
if (!visible) return [];
|
||||
return (
|
||||
<polyline
|
||||
key={`line:${externalSelectedLine.id}:${dx}`}
|
||||
points={pts.map((p) => `${p.x + dx},${p.y}`).join(" ")}
|
||||
className="is-selected"
|
||||
fill="none"
|
||||
/>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
{overlayMarkers.map((marker) => {
|
||||
const pt = projection.project(marker.point);
|
||||
const cls = marker.id === selectedMarker?.id ? "is-selected" : "is-hovered";
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
import type { MapProjection } from "../geometry/mapProjection";
|
||||
import type { MapLine } from "../model/mapTypes";
|
||||
|
||||
const TRACK_STYLE = { stroke: "rgba(104, 195, 189, 0.76)", width: 1.2 };
|
||||
// Сброс — яркая толстая линия поверх трассы, на отрезке от начала до конца сеанса.
|
||||
const DROP_STYLE = { stroke: "#ffcf3f", width: 5 };
|
||||
|
||||
export function drawTracks(ctx: CanvasRenderingContext2D, lines: MapLine[], projection: MapProjection) {
|
||||
ctx.save();
|
||||
ctx.lineCap = "round";
|
||||
ctx.lineJoin = "round";
|
||||
ctx.strokeStyle = "rgba(104, 195, 189, 0.76)";
|
||||
ctx.lineWidth = 1.2;
|
||||
|
||||
const wrapThreshold = projection.worldSize / 2;
|
||||
for (const line of lines) {
|
||||
// Сбросы рисуем после трасс, чтобы их яркая линия легла поверх.
|
||||
const ordered = [...lines].sort((a, b) => Number(a.kind === "drop") - Number(b.kind === "drop"));
|
||||
for (const line of ordered) {
|
||||
if (line.points.length < 2) continue;
|
||||
const style = line.kind === "drop" ? DROP_STYLE : TRACK_STYLE;
|
||||
ctx.strokeStyle = style.stroke;
|
||||
ctx.lineWidth = style.width;
|
||||
ctx.beginPath();
|
||||
let prevX: number | undefined;
|
||||
for (const point of line.points) {
|
||||
@@ -27,4 +34,3 @@ export function drawTracks(ctx: CanvasRenderingContext2D, lines: MapLine[], proj
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,8 @@ export type MapPolygon = {
|
||||
spacecraftId?: string;
|
||||
requestId?: string;
|
||||
surveyType?: string;
|
||||
/** id режима плана (survey), к которому относится контур — для связи «включение ↔ объект на карте». */
|
||||
modeId?: number;
|
||||
zIndex: number;
|
||||
};
|
||||
|
||||
@@ -42,6 +44,10 @@ export type MapLine = {
|
||||
spacecraftId?: string;
|
||||
passIndex?: number;
|
||||
zIndex: number;
|
||||
/** Вид линии: трасса КА (по умолчанию) или сеанс сброса — яркая толстая линия на трассе. */
|
||||
kind?: "track" | "drop";
|
||||
/** id режима плана (survey/drop), к которому относится объект — для связи «включение ↔ объект на карте». */
|
||||
modeId?: number;
|
||||
};
|
||||
|
||||
export type MapMarker = {
|
||||
|
||||
@@ -17,6 +17,8 @@ type CreateChainPlanDialogProps = {
|
||||
onClose: () => void;
|
||||
/** Вызвать после ручной отправки созданного плана (для перезагрузки таймлайна). */
|
||||
onSent: () => void;
|
||||
/** Вызвать после создания черновика — чтобы он сразу появился на таймлайне (режим «Черновики»). */
|
||||
onCreated?: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -25,7 +27,7 @@ type CreateChainPlanDialogProps = {
|
||||
* показывается прогноз будущей цепочки от него; кандидат становится рабочим после ручной отправки
|
||||
* («Отправить вручную» = promote) и последующей приёмки.
|
||||
*/
|
||||
export function CreateChainPlanDialog({ spacecraftId, plans, onClose, onSent }: CreateChainPlanDialogProps) {
|
||||
export function CreateChainPlanDialog({ spacecraftId, plans, onClose, onSent, onCreated }: CreateChainPlanDialogProps) {
|
||||
const [zrvOptions, setZrvOptions] = useState<ZrvOption[]>([]);
|
||||
const [zrvLoading, setZrvLoading] = useState(true);
|
||||
const [zrvError, setZrvError] = useState<string>();
|
||||
@@ -78,6 +80,7 @@ export function CreateChainPlanDialog({ spacecraftId, plans, onClose, onSent }:
|
||||
try {
|
||||
const request = buildChainPlanRequest(spacecraftId, basePlanId || null, start, end);
|
||||
setResult(await createChainPlan(request));
|
||||
onCreated?.();
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof TguApiError ? err.message : err instanceof Error ? err.message : "Не удалось создать план."
|
||||
|
||||
@@ -13,14 +13,33 @@ const DECISION_GRACE_MINUTES = 30;
|
||||
|
||||
type TguPlanDetailsProps = {
|
||||
plan?: TguPlanUi;
|
||||
/** Цепочка КА на паузе после REJECTED — показываем подсказку про ручной исправленный план. */
|
||||
chainPaused?: boolean;
|
||||
decisionInFlight: boolean;
|
||||
notice?: string;
|
||||
error?: string;
|
||||
onDecision: (planId: string, decision: TguPlanDecision, reason?: string) => void;
|
||||
/** Действия над черновиком (показываются только для status === "DRAFT"). */
|
||||
draftActionInFlight?: boolean;
|
||||
onEditDraft?: (planId: string) => void;
|
||||
onPromoteDraft?: (planId: string) => void;
|
||||
onDeleteDraft?: (planId: string) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function TguPlanDetails({ plan, decisionInFlight, notice, error, onDecision, onClose }: TguPlanDetailsProps) {
|
||||
export function TguPlanDetails({
|
||||
plan,
|
||||
chainPaused,
|
||||
decisionInFlight,
|
||||
notice,
|
||||
error,
|
||||
onDecision,
|
||||
draftActionInFlight,
|
||||
onEditDraft,
|
||||
onPromoteDraft,
|
||||
onDeleteDraft,
|
||||
onClose
|
||||
}: TguPlanDetailsProps) {
|
||||
if (!plan) {
|
||||
return (
|
||||
<aside className="tgu-details tgu-details--empty">
|
||||
@@ -60,6 +79,13 @@ export function TguPlanDetails({ plan, decisionInFlight, notice, error, onDecisi
|
||||
{notice && <div className="tgu-alert tgu-alert--success">{notice}</div>}
|
||||
{error && <div className="tgu-alert tgu-alert--danger">{error}</div>}
|
||||
|
||||
{chainPaused && (
|
||||
<div className="tgu-alert tgu-alert--warning">
|
||||
⏸ Цепочка на паузе: план отклонён — создайте исправленный план вручную («Создать план»).
|
||||
Автоматической перевыдачи отклонённого окна не будет.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<dl className="tgu-details-grid">
|
||||
<Detail label="planId" value={plan.planId} mono />
|
||||
<Detail label="spacecraftId" value={plan.spacecraftId} mono />
|
||||
@@ -71,6 +97,35 @@ export function TguPlanDetails({ plan, decisionInFlight, notice, error, onDecisi
|
||||
<Detail label="status" value={plan.status} mono />
|
||||
</dl>
|
||||
|
||||
{plan.status === "DRAFT" && (
|
||||
<div className="tgu-details__actions tgu-details__actions--draft">
|
||||
<button
|
||||
className="tgu-button tgu-button--primary"
|
||||
type="button"
|
||||
disabled={draftActionInFlight}
|
||||
onClick={() => onEditDraft?.(plan.planId)}
|
||||
>
|
||||
Редактировать
|
||||
</button>
|
||||
<button
|
||||
className="tgu-button tgu-button--accept"
|
||||
type="button"
|
||||
disabled={draftActionInFlight}
|
||||
onClick={() => onPromoteDraft?.(plan.planId)}
|
||||
>
|
||||
Отправить
|
||||
</button>
|
||||
<button
|
||||
className="tgu-button tgu-button--reject"
|
||||
type="button"
|
||||
disabled={draftActionInFlight}
|
||||
onClick={() => onDeleteDraft?.(plan.planId)}
|
||||
>
|
||||
Удалить
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{plan.status === "WAITING_DECISION" &&
|
||||
(decisionWindowClosed ? (
|
||||
<div className="tgu-alert tgu-alert--warning">
|
||||
|
||||
@@ -1,23 +1,31 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { fetchPlans, fetchPlatforms, sendPlanDecision } from "../../api/tguApi";
|
||||
import type { TguPlanDecision, TguPlatform } from "../../model/tguTypes";
|
||||
import type { TguPlan, TguPlanDecision, TguPlatform } from "../../model/tguTypes";
|
||||
import type { TguActiveTab, TguPlanUi, TimelineRange } from "../../model/timelineTypes";
|
||||
import { TguEditorTab } from "../tgu-editor/TguEditorTab";
|
||||
import { Tgu2DMapTab } from "../tgu-map-2d/Tgu2DMapTab";
|
||||
import { TguRequestsTab } from "../tgu-requests/TguRequestsTab";
|
||||
import { fetchZrvWindows, type ZrvWindowResponse } from "../../api/tguPlanApi";
|
||||
import {
|
||||
abandonDraft,
|
||||
fetchDraftPlans,
|
||||
fetchZrvWindows,
|
||||
promoteDraft,
|
||||
type ZrvWindowResponse
|
||||
} from "../../api/tguPlanApi";
|
||||
import { CreateChainPlanDialog } from "./CreateChainPlanDialog";
|
||||
import { TguPlanDetails } from "./TguPlanDetails";
|
||||
import { TguPlanningLayout } from "./TguPlanningLayout";
|
||||
import { TguSidebar } from "./TguSidebar";
|
||||
import { TguTimeline } from "./TguTimeline";
|
||||
import { TguTimeline, type ZrvWindow } from "./TguTimeline";
|
||||
import { TguToolbar } from "./TguToolbar";
|
||||
import { TguZrvPanel } from "./TguZrvPanel";
|
||||
import {
|
||||
buildPlatformsForInterval,
|
||||
buildTimelineRows,
|
||||
isOperationalPlatform,
|
||||
mapPlans,
|
||||
operationalSpacecraftIds
|
||||
operationalSpacecraftIds,
|
||||
pausedSpacecraftIds
|
||||
} from "./tguTimelineMapper";
|
||||
import { parseUtc, toUtcInputValue } from "../../utils/utcTime";
|
||||
|
||||
@@ -39,8 +47,16 @@ export function TguPlanningPage() {
|
||||
const [selectedPlanId, setSelectedPlanId] = useState<string>();
|
||||
const [activeTab, setActiveTab] = useState<TguActiveTab>("timeline");
|
||||
const [createChainOpen, setCreateChainOpen] = useState(false);
|
||||
// Черновики (DRAFT) — план-кандидаты, скрытые из основного read-path; грузим отдельно.
|
||||
const [drafts, setDrafts] = useState<TguPlanUi[]>([]);
|
||||
const [draftActionInFlight, setDraftActionInFlight] = useState(false);
|
||||
// Тумблеры видимости «непринятого будущего» на схеме.
|
||||
const [showDrafts, setShowDrafts] = useState(false);
|
||||
const [showProjection, setShowProjection] = useState(true);
|
||||
// ЗРВ выбранного КА — подсветка доступных зон на таймлайне (на какие зоны можно опереться).
|
||||
const [zrvWindows, setZrvWindows] = useState<ZrvWindowResponse[]>([]);
|
||||
// Выбор ЗРВ на таймлайне: пересекающиеся в точке зоны + индекс активной (панель справа).
|
||||
const [zrvSelection, setZrvSelection] = useState<{ windows: ZrvWindow[]; index: number }>();
|
||||
// Вкладки рендерятся в режиме keep-alive: каждую монтируем при первом открытии
|
||||
// и дальше держим смонтированной (скрытой через display:none), чтобы её
|
||||
// внутреннее состояние — загруженные заявки, выбор, слои, скролл — не сбрасывалось
|
||||
@@ -69,15 +85,17 @@ export function TguPlanningPage() {
|
||||
setDecisionError(undefined);
|
||||
|
||||
const historyDays = historyDaysForRange(range);
|
||||
const [platformResult, planResult] = await Promise.allSettled([
|
||||
const [platformResult, planResult, draftResult] = await Promise.allSettled([
|
||||
fetchPlatforms(),
|
||||
fetchPlans(historyDays)
|
||||
fetchPlans(historyDays),
|
||||
fetchDraftPlans()
|
||||
]);
|
||||
|
||||
// На морде работаем только с эксплуатируемыми КА — остальные отсекаем сразу
|
||||
// на входе, чтобы они не всплывали ни в списке, ни как fallback-строки по планам.
|
||||
const operationalPlatforms =
|
||||
platformResult.status === "fulfilled" ? platformResult.value.filter(isOperationalPlatform) : [];
|
||||
const operationalIds = operationalSpacecraftIds(operationalPlatforms);
|
||||
|
||||
if (platformResult.status === "rejected") {
|
||||
setPlatformLoadFailed(true);
|
||||
@@ -90,10 +108,19 @@ export function TguPlanningPage() {
|
||||
setPlans([]);
|
||||
setPageError(planResult.reason instanceof Error ? planResult.reason.message : "Не удалось загрузить планы.");
|
||||
} else {
|
||||
const operationalIds = operationalSpacecraftIds(operationalPlatforms);
|
||||
setPlans(mapPlans(planResult.value, operationalPlatforms).filter((plan) => operationalIds.has(plan.spacecraftId)));
|
||||
}
|
||||
|
||||
// Черновики читаем из pcp-tgu-service отдельно; сбой непрозрачен — просто нет черновиков.
|
||||
if (draftResult.status === "fulfilled") {
|
||||
setDrafts(
|
||||
mapPlans(draftResult.value as unknown as TguPlan[], operationalPlatforms)
|
||||
.filter((plan) => operationalIds.has(plan.spacecraftId))
|
||||
);
|
||||
} else {
|
||||
setDrafts([]);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
},
|
||||
[]
|
||||
@@ -130,29 +157,56 @@ export function TguPlanningPage() {
|
||||
if (!selectedSpacecraftId || zrvWindows.length === 0) return undefined;
|
||||
return {
|
||||
spacecraftId: selectedSpacecraftId,
|
||||
windows: zrvWindows.map((zrv) => ({ startMs: parseUtc(zrv.startTime), endMs: parseUtc(zrv.endTime) }))
|
||||
windows: zrvWindows.map((zrv) => ({
|
||||
startMs: parseUtc(zrv.startTime),
|
||||
endMs: parseUtc(zrv.endTime),
|
||||
kppId: zrv.kppId
|
||||
}))
|
||||
};
|
||||
}, [selectedSpacecraftId, zrvWindows]);
|
||||
|
||||
// Сбрасываем выбранную ЗРВ при смене КА (зоны перечитываются) — панель не должна показывать чужие.
|
||||
useEffect(() => {
|
||||
setZrvSelection(undefined);
|
||||
}, [selectedSpacecraftId]);
|
||||
|
||||
const selectedZrvId = useMemo(() => {
|
||||
if (!zrvSelection) return undefined;
|
||||
const zrv = zrvSelection.windows[zrvSelection.index];
|
||||
return zrv ? `${zrv.kppId}|${zrv.startMs}` : undefined;
|
||||
}, [zrvSelection]);
|
||||
|
||||
// display:contents для активной вкладки сохраняет grid-раскладку shell
|
||||
// (внутренний <main> остаётся прямым grid-элементом), display:none скрывает остальные.
|
||||
const tabStyle = (tab: TguActiveTab) =>
|
||||
({ display: activeTab === tab ? "contents" : "none" }) as const;
|
||||
|
||||
// Все планы для выбора/поиска (включая черновики), независимо от тумблеров видимости.
|
||||
const allPlans = useMemo(() => [...plans, ...drafts], [plans, drafts]);
|
||||
// Планы для отрисовки на таймлайне с учётом тумблеров: проекция PLANNED и черновики.
|
||||
const visiblePlans = useMemo(() => {
|
||||
const base = showProjection ? plans : plans.filter((plan) => plan.status !== "PLANNED");
|
||||
return showDrafts ? [...base, ...drafts] : base;
|
||||
}, [plans, drafts, showProjection, showDrafts]);
|
||||
|
||||
// КА с цепочкой на паузе после REJECTED (см. pausedSpacecraftIds): считаем по persisted-планам,
|
||||
// черновики/проекция паузу не снимают. Совпадает с серверной паузой воркера выдачи.
|
||||
const paused = useMemo(() => pausedSpacecraftIds(plans), [plans]);
|
||||
|
||||
const platforms = useMemo(
|
||||
() => buildPlatformsForInterval(rawPlatforms, plans, appliedRange),
|
||||
[rawPlatforms, plans, appliedRange]
|
||||
() => buildPlatformsForInterval(rawPlatforms, allPlans, appliedRange),
|
||||
[rawPlatforms, allPlans, appliedRange]
|
||||
);
|
||||
const rows = useMemo(
|
||||
() => buildTimelineRows(plans, platforms, appliedRange, selectedSpacecraftId),
|
||||
[plans, platforms, appliedRange, selectedSpacecraftId]
|
||||
() => buildTimelineRows(visiblePlans, platforms, appliedRange, selectedSpacecraftId),
|
||||
[visiblePlans, platforms, appliedRange, selectedSpacecraftId]
|
||||
);
|
||||
const selectedPlan = useMemo(() => {
|
||||
const plan = plans.find((item) => item.planId === selectedPlanId);
|
||||
const plan = allPlans.find((item) => item.planId === selectedPlanId);
|
||||
if (!plan) return undefined;
|
||||
const platform = platforms.find((item) => item.spacecraftId === plan.spacecraftId);
|
||||
return { ...plan, platform };
|
||||
}, [plans, platforms, selectedPlanId]);
|
||||
}, [allPlans, platforms, selectedPlanId]);
|
||||
|
||||
const applyRange = () => {
|
||||
setDecisionNotice(undefined);
|
||||
@@ -194,6 +248,48 @@ export function TguPlanningPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// Открыть черновик в редакторе: выбираем его план/КА и переключаемся на вкладку «Редактор».
|
||||
const handleEditDraft = (planId: string) => {
|
||||
const draft = drafts.find((item) => item.planId === planId);
|
||||
setSelectedPlanId(planId);
|
||||
if (draft) setSelectedSpacecraftId(draft.spacecraftId);
|
||||
setDecisionNotice(undefined);
|
||||
setActiveTab("editor");
|
||||
};
|
||||
|
||||
// Отправить черновик вручную (promote): уходит в выдачу, рабочим станет после приёмки.
|
||||
const handlePromoteDraft = async (planId: string) => {
|
||||
setDraftActionInFlight(true);
|
||||
setDecisionError(undefined);
|
||||
setDecisionNotice(undefined);
|
||||
try {
|
||||
await promoteDraft(planId);
|
||||
setDecisionNotice("Черновик отправлен на выдачу. Рабочим (якорем цепочки) он станет после приёмки.");
|
||||
await loadData(appliedRange);
|
||||
} catch (error) {
|
||||
setDecisionError(error instanceof Error ? error.message : "Не удалось отправить черновик.");
|
||||
} finally {
|
||||
setDraftActionInFlight(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Удалить черновик (abandon): убирает DRAFT и его клон-миссию.
|
||||
const handleDeleteDraft = async (planId: string) => {
|
||||
setDraftActionInFlight(true);
|
||||
setDecisionError(undefined);
|
||||
setDecisionNotice(undefined);
|
||||
try {
|
||||
await abandonDraft(planId);
|
||||
setSelectedPlanId(undefined);
|
||||
setDecisionNotice("Черновик удалён.");
|
||||
await loadData(appliedRange);
|
||||
} catch (error) {
|
||||
setDecisionError(error instanceof Error ? error.message : "Не удалось удалить черновик.");
|
||||
} finally {
|
||||
setDraftActionInFlight(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<TguPlanningLayout
|
||||
toolbar={
|
||||
@@ -227,6 +323,14 @@ export function TguPlanningPage() {
|
||||
/>
|
||||
<section className="tgu-center">
|
||||
<div className="tgu-center__actions">
|
||||
<label className="tgu-center__toggle">
|
||||
<input type="checkbox" checked={showDrafts} onChange={(e) => setShowDrafts(e.target.checked)} />
|
||||
Черновики
|
||||
</label>
|
||||
<label className="tgu-center__toggle">
|
||||
<input type="checkbox" checked={showProjection} onChange={(e) => setShowProjection(e.target.checked)} />
|
||||
Возможные будущие планы
|
||||
</label>
|
||||
<button
|
||||
className="tgu-button tgu-button--primary"
|
||||
type="button"
|
||||
@@ -242,24 +346,45 @@ export function TguPlanningPage() {
|
||||
range={appliedRange}
|
||||
invalidRange={invalidRange}
|
||||
zrvBands={zrvBands}
|
||||
selectedZrvId={selectedZrvId}
|
||||
pausedSpacecraftIds={paused}
|
||||
selectedPlanId={selectedPlanId}
|
||||
onSelectPlan={(planId) => {
|
||||
setZrvSelection(undefined);
|
||||
setSelectedPlanId(planId);
|
||||
const plan = plans.find((item) => item.planId === planId);
|
||||
const plan = allPlans.find((item) => item.planId === planId);
|
||||
if (plan) {
|
||||
setSelectedSpacecraftId(plan.spacecraftId);
|
||||
}
|
||||
}}
|
||||
onSelectZrv={(windows, index) => {
|
||||
setSelectedPlanId(undefined);
|
||||
setZrvSelection({ windows, index });
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
<TguPlanDetails
|
||||
plan={selectedPlan}
|
||||
decisionInFlight={decisionInFlight}
|
||||
notice={decisionNotice}
|
||||
error={decisionError}
|
||||
onDecision={handleDecision}
|
||||
onClose={() => setSelectedPlanId(undefined)}
|
||||
/>
|
||||
{zrvSelection ? (
|
||||
<TguZrvPanel
|
||||
windows={zrvSelection.windows}
|
||||
selectedIndex={zrvSelection.index}
|
||||
onSelect={(index) => setZrvSelection((current) => (current ? { ...current, index } : current))}
|
||||
onClose={() => setZrvSelection(undefined)}
|
||||
/>
|
||||
) : (
|
||||
<TguPlanDetails
|
||||
plan={selectedPlan}
|
||||
chainPaused={selectedPlan ? paused.has(selectedPlan.spacecraftId) : false}
|
||||
decisionInFlight={decisionInFlight}
|
||||
notice={decisionNotice}
|
||||
error={decisionError}
|
||||
onDecision={handleDecision}
|
||||
draftActionInFlight={draftActionInFlight}
|
||||
onEditDraft={handleEditDraft}
|
||||
onPromoteDraft={handlePromoteDraft}
|
||||
onDeleteDraft={handleDeleteDraft}
|
||||
onClose={() => setSelectedPlanId(undefined)}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
{createChainOpen && selectedSpacecraftId && (
|
||||
<CreateChainPlanDialog
|
||||
@@ -267,6 +392,10 @@ export function TguPlanningPage() {
|
||||
plans={plans}
|
||||
onClose={() => setCreateChainOpen(false)}
|
||||
onSent={() => void loadData(appliedRange)}
|
||||
onCreated={() => {
|
||||
setShowDrafts(true);
|
||||
void loadData(appliedRange);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -3,21 +3,22 @@ import type { TimelinePlanSegment, TimelineRange, TimelineRow } from "../../mode
|
||||
import {
|
||||
buildSequentialLinks,
|
||||
buildTimeTicks,
|
||||
buildTimelineSegments,
|
||||
buildTimelineLayout,
|
||||
TIMELINE_AXIS_HEIGHT,
|
||||
TIMELINE_BOTTOM_PADDING,
|
||||
TIMELINE_LABEL_WIDTH,
|
||||
TIMELINE_ROW_HEIGHT,
|
||||
timeToX,
|
||||
timelineHeight
|
||||
TIMELINE_ZRV_BAND_HEIGHT,
|
||||
timeToX
|
||||
} from "./tguTimelineLayout";
|
||||
import { platformLabel } from "./tguTimelineMapper";
|
||||
import { statusStyle } from "./tguStatus";
|
||||
import { formatUtcDate, formatUtcDateTime, formatUtcTime, parseUtc } from "../../utils/utcTime";
|
||||
|
||||
export type ZrvWindow = { startMs: number; endMs: number; kppId: string };
|
||||
|
||||
type ZrvBands = {
|
||||
spacecraftId: string;
|
||||
windows: { startMs: number; endMs: number }[];
|
||||
windows: ZrvWindow[];
|
||||
};
|
||||
|
||||
type TguTimelineProps = {
|
||||
@@ -27,15 +28,34 @@ type TguTimelineProps = {
|
||||
invalidRange?: boolean;
|
||||
/** ЗРВ выбранного КА — подсветка доступных зон на его строке. */
|
||||
zrvBands?: ZrvBands;
|
||||
/** id выбранной ЗРВ (`${kppId}|${startMs}`) — подсветка кликнутой зоны. */
|
||||
selectedZrvId?: string;
|
||||
/** КА с цепочкой на паузе после REJECTED — рисуем пометку у имени КА. */
|
||||
pausedSpacecraftIds?: ReadonlySet<string>;
|
||||
onSelectPlan: (planId: string) => void;
|
||||
/** Клик по ЗРВ: передаём пересекающиеся в этой точке зоны и индекс кликнутой. */
|
||||
onSelectZrv?: (windows: ZrvWindow[], selectedIndex: number) => void;
|
||||
};
|
||||
|
||||
export function TguTimeline({ rows, range, selectedPlanId, invalidRange, zrvBands, onSelectPlan }: TguTimelineProps) {
|
||||
function zrvId(zrv: ZrvWindow): string {
|
||||
return `${zrv.kppId}|${zrv.startMs}`;
|
||||
}
|
||||
|
||||
export function TguTimeline({
|
||||
rows,
|
||||
range,
|
||||
selectedPlanId,
|
||||
invalidRange,
|
||||
zrvBands,
|
||||
selectedZrvId,
|
||||
pausedSpacecraftIds,
|
||||
onSelectPlan,
|
||||
onSelectZrv
|
||||
}: TguTimelineProps) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [containerWidth, setContainerWidth] = useState(1200);
|
||||
const timelineWidth = Math.max(720, containerWidth - TIMELINE_LABEL_WIDTH - 28);
|
||||
const fullWidth = TIMELINE_LABEL_WIDTH + timelineWidth;
|
||||
const height = Math.max(260, timelineHeight(rows.length));
|
||||
const nowMs = Date.now();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
@@ -50,10 +70,12 @@ export function TguTimeline({ rows, range, selectedPlanId, invalidRange, zrvBand
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const segments = useMemo(
|
||||
() => buildTimelineSegments(rows, range, timelineWidth),
|
||||
const layout = useMemo(
|
||||
() => buildTimelineLayout(rows, range, timelineWidth),
|
||||
[rows, range, timelineWidth]
|
||||
);
|
||||
const segments = layout.segments;
|
||||
const height = Math.max(260, layout.height);
|
||||
const segmentByPlanId = useMemo(
|
||||
() => new Map(segments.map((segment) => [segment.plan.planId, segment])),
|
||||
[segments]
|
||||
@@ -114,43 +136,72 @@ export function TguTimeline({ rows, range, selectedPlanId, invalidRange, zrvBand
|
||||
</g>
|
||||
)}
|
||||
|
||||
{rows.map((row, rowIndex) => {
|
||||
const y = TIMELINE_AXIS_HEIGHT + rowIndex * TIMELINE_ROW_HEIGHT;
|
||||
{layout.rowLayouts.map(({ row, rowIndex, y, height: rowHeight, zrvBandY }) => {
|
||||
return (
|
||||
<g key={row.spacecraftId}>
|
||||
<rect
|
||||
x="0"
|
||||
y={y}
|
||||
width={fullWidth}
|
||||
height={TIMELINE_ROW_HEIGHT}
|
||||
height={rowHeight}
|
||||
className={rowIndex % 2 === 0 ? "tgu-timeline__row" : "tgu-timeline__row tgu-timeline__row--alt"}
|
||||
/>
|
||||
<text x="16" y={y + 24} className="tgu-timeline__row-title">
|
||||
<text x="16" y={y + 22} className="tgu-timeline__row-title">
|
||||
{platformLabel(row.platform, row.spacecraftId)}
|
||||
</text>
|
||||
<text x="16" y={y + 42} className="tgu-timeline__row-meta">
|
||||
<text x="16" y={y + 38} className="tgu-timeline__row-meta">
|
||||
NORAD {row.platform?.noradId || row.spacecraftId}
|
||||
</text>
|
||||
{zrvBands?.spacecraftId === row.spacecraftId &&
|
||||
zrvBands.windows.map((zrv, zrvIndex) => {
|
||||
const from = Math.max(zrv.startMs, range.fromMs);
|
||||
const to = Math.min(zrv.endMs, range.toMs);
|
||||
if (to <= from) return null;
|
||||
const bx = TIMELINE_LABEL_WIDTH + timeToX(from, range, timelineWidth);
|
||||
const bw = Math.max(2, timeToX(to, range, timelineWidth) - timeToX(from, range, timelineWidth));
|
||||
return (
|
||||
<rect
|
||||
key={`zrv-${zrvIndex}`}
|
||||
className="tgu-timeline__zrv"
|
||||
x={bx}
|
||||
y={y + 6}
|
||||
width={bw}
|
||||
height={TIMELINE_ROW_HEIGHT - 12}
|
||||
>
|
||||
<title>ЗРВ {formatUtcDateTime(zrv.startMs)} – {formatUtcDateTime(zrv.endMs)}</title>
|
||||
</rect>
|
||||
);
|
||||
})}
|
||||
{pausedSpacecraftIds?.has(row.spacecraftId) && (
|
||||
<text
|
||||
x={TIMELINE_LABEL_WIDTH - 12}
|
||||
y={y + 22}
|
||||
textAnchor="end"
|
||||
className="tgu-timeline__paused-badge"
|
||||
>
|
||||
⏸ на паузе
|
||||
<title>Цепочка на паузе: план отклонён — создайте исправленный план («Создать план»).</title>
|
||||
</text>
|
||||
)}
|
||||
{zrvBands?.spacecraftId === row.spacecraftId && (
|
||||
<>
|
||||
<text x="16" y={zrvBandY + TIMELINE_ZRV_BAND_HEIGHT - 3} className="tgu-timeline__zrv-label">
|
||||
ЗРВ
|
||||
</text>
|
||||
{zrvBands.windows.map((zrv, zrvIndex) => {
|
||||
const from = Math.max(zrv.startMs, range.fromMs);
|
||||
const to = Math.min(zrv.endMs, range.toMs);
|
||||
if (to <= from) return null;
|
||||
const bx = TIMELINE_LABEL_WIDTH + timeToX(from, range, timelineWidth);
|
||||
const bw = Math.max(2, timeToX(to, range, timelineWidth) - timeToX(from, range, timelineWidth));
|
||||
const id = zrvId(zrv);
|
||||
return (
|
||||
<rect
|
||||
key={`zrv-${zrvIndex}`}
|
||||
className={`tgu-timeline__zrv${selectedZrvId === id ? " is-selected" : ""}`}
|
||||
x={bx}
|
||||
y={zrvBandY}
|
||||
width={bw}
|
||||
height={TIMELINE_ZRV_BAND_HEIGHT}
|
||||
style={onSelectZrv ? { cursor: "pointer" } : undefined}
|
||||
onClick={(event) => {
|
||||
if (!onSelectZrv) return;
|
||||
event.stopPropagation();
|
||||
// Зоны «в этой точке» — все ЗРВ, пересекающие кликнутую по времени
|
||||
// (разные КПП могут перекрываться), как «заявки в точке» на карте.
|
||||
const overlapping = zrvBands.windows.filter(
|
||||
(w) => w.startMs < zrv.endMs && w.endMs > zrv.startMs
|
||||
);
|
||||
const idx = overlapping.findIndex((w) => zrvId(w) === id);
|
||||
onSelectZrv(overlapping, Math.max(0, idx));
|
||||
}}
|
||||
>
|
||||
<title>ЗРВ {zrv.kppId} · {formatUtcDateTime(zrv.startMs)} – {formatUtcDateTime(zrv.endMs)}</title>
|
||||
</rect>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
@@ -163,11 +214,7 @@ export function TguTimeline({ rows, range, selectedPlanId, invalidRange, zrvBand
|
||||
})}
|
||||
|
||||
{segments.map((segment) => {
|
||||
const y =
|
||||
TIMELINE_AXIS_HEIGHT +
|
||||
segment.rowIndex * TIMELINE_ROW_HEIGHT +
|
||||
TIMELINE_ROW_HEIGHT / 2 +
|
||||
segment.laneOffset;
|
||||
const y = segment.centerY;
|
||||
const x1 = TIMELINE_LABEL_WIDTH + segment.x;
|
||||
const x2 = TIMELINE_LABEL_WIDTH + segment.x + segment.width;
|
||||
const style = statusStyle(segment.plan.status);
|
||||
@@ -208,8 +255,8 @@ export function TguTimeline({ rows, range, selectedPlanId, invalidRange, zrvBand
|
||||
}
|
||||
|
||||
function TimelineArrow({ from, to }: { from: TimelinePlanSegment; to: TimelinePlanSegment }) {
|
||||
const y1 = TIMELINE_AXIS_HEIGHT + from.rowIndex * TIMELINE_ROW_HEIGHT + TIMELINE_ROW_HEIGHT / 2 + from.laneOffset;
|
||||
const y2 = TIMELINE_AXIS_HEIGHT + to.rowIndex * TIMELINE_ROW_HEIGHT + TIMELINE_ROW_HEIGHT / 2 + to.laneOffset;
|
||||
const y1 = from.centerY;
|
||||
const y2 = to.centerY;
|
||||
const x1 = TIMELINE_LABEL_WIDTH + from.x + from.width + 4;
|
||||
const x2 = TIMELINE_LABEL_WIDTH + to.x - 6;
|
||||
const mid = x1 + Math.max(18, (x2 - x1) / 2);
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { ZrvWindow } from "./TguTimeline";
|
||||
import { formatUtcDateTime } from "../../utils/utcTime";
|
||||
|
||||
type Props = {
|
||||
windows: ZrvWindow[];
|
||||
selectedIndex: number;
|
||||
onSelect: (index: number) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Панель по выбранной ЗРВ в окне «Планы». Если в точке пересекается несколько зон
|
||||
* (разные КПП), показываем список — по примеру панели заявок на вкладке «Карта».
|
||||
*/
|
||||
export function TguZrvPanel({ windows, selectedIndex, onSelect, onClose }: Props) {
|
||||
const selected = windows[selectedIndex];
|
||||
|
||||
return (
|
||||
<aside className="tgu-map-requests-panel">
|
||||
<div className="tgu-panel-heading">
|
||||
<span>Зоны радиовидимости ({windows.length})</span>
|
||||
<button className="tgu-link-button" type="button" onClick={onClose}>
|
||||
Закрыть
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="tgu-map-requests-list">
|
||||
{windows.map((zrv, index) => (
|
||||
<button
|
||||
key={`${zrv.kppId}|${zrv.startMs}`}
|
||||
className={`tgu-map-request-row ${index === selectedIndex ? "is-selected" : ""}`}
|
||||
type="button"
|
||||
onClick={() => onSelect(index)}
|
||||
>
|
||||
<span className="tgu-map-request-row__body">
|
||||
<span className="tgu-map-request-row__name">КПП {zrv.kppId}</span>
|
||||
<span className="tgu-map-request-row__meta">
|
||||
{formatUtcDateTime(zrv.startMs)} – {formatUtcDateTime(zrv.endMs)}
|
||||
{" · "}
|
||||
{formatDuration(zrv.endMs - zrv.startMs)}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selected && (
|
||||
<div className="tgu-map-request-details">
|
||||
<div className="tgu-map-panel__label">Детали ЗРВ</div>
|
||||
<dl className="tgu-map-request-details__grid">
|
||||
<dt>КПП</dt>
|
||||
<dd className="mono">{selected.kppId}</dd>
|
||||
|
||||
<dt>Начало</dt>
|
||||
<dd className="mono">{formatUtcDateTime(selected.startMs)}</dd>
|
||||
|
||||
<dt>Конец</dt>
|
||||
<dd className="mono">{formatUtcDateTime(selected.endMs)}</dd>
|
||||
|
||||
<dt>Длительность</dt>
|
||||
<dd className="mono">{formatDuration(selected.endMs - selected.startMs)}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
const totalSeconds = Math.max(0, Math.round(ms / 1000));
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return minutes > 0 ? `${minutes} мин ${seconds} с` : `${seconds} с`;
|
||||
}
|
||||
@@ -7,7 +7,8 @@ export const STATUS_STYLES: TimelineStatusStyle[] = [
|
||||
{ status: "WAITING_DECISION", label: "WAITING_DECISION", color: "#f0ad2e", className: "status-waiting" },
|
||||
{ status: "PLANNED", label: "PLANNED", color: "#8d99a6", className: "status-planned" },
|
||||
{ status: "ISSUING", label: "ISSUING", color: "#2f80ed", className: "status-issuing" },
|
||||
{ status: "START_AMBIGUOUS", label: "START_AMBIGUOUS", color: "#b42318", className: "status-ambiguous" }
|
||||
{ status: "START_AMBIGUOUS", label: "START_AMBIGUOUS", color: "#b42318", className: "status-ambiguous" },
|
||||
{ status: "DRAFT", label: "ЧЕРНОВИК", color: "#a98bdb", className: "status-draft" }
|
||||
];
|
||||
|
||||
const STATUS_BY_NAME = new Map(STATUS_STYLES.map((style) => [style.status, style]));
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { TguPlanUi, TimelineRow } from "../../model/timelineTypes";
|
||||
import { buildSequentialLinks, buildTimelineSegments, clipPlanToRange, timeToX } from "./tguTimelineLayout";
|
||||
import { buildSequentialLinks, buildTimelineLayout, clipPlanToRange, timeToX } from "./tguTimelineLayout";
|
||||
import { parseUtc } from "../../utils/utcTime";
|
||||
|
||||
const range = {
|
||||
@@ -57,28 +57,59 @@ describe("tguTimelineLayout", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("places sequential plans on alternating timeline lanes", () => {
|
||||
it("keeps non-overlapping plans on a single lane", () => {
|
||||
const rows: TimelineRow[] = [
|
||||
{
|
||||
spacecraftId: "56756",
|
||||
plans: [
|
||||
plan("p1", "2026-05-29T01:00:00", "2026-05-29T02:00:00"),
|
||||
plan("p2", "2026-05-29T03:00:00", "2026-05-29T04:00:00"),
|
||||
plan("p3", "2026-05-29T05:00:00", "2026-05-29T06:00:00"),
|
||||
plan("p4", "2026-05-29T07:00:00", "2026-05-29T08:00:00"),
|
||||
plan("p5", "2026-05-29T09:00:00", "2026-05-29T10:00:00"),
|
||||
plan("p6", "2026-05-29T11:00:00", "2026-05-29T12:00:00")
|
||||
plan("p3", "2026-05-29T05:00:00", "2026-05-29T06:00:00")
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
expect(buildTimelineSegments(rows, range, 1200).map((segment) => segment.laneOffset)).toEqual([
|
||||
0,
|
||||
-12,
|
||||
0,
|
||||
12,
|
||||
0,
|
||||
-12
|
||||
]);
|
||||
const lanes = buildTimelineLayout(rows, range, 1200).segments.map((segment) => segment.lane);
|
||||
expect(lanes).toEqual([0, 0, 0]);
|
||||
});
|
||||
|
||||
it("stacks overlapping plans onto separate lanes", () => {
|
||||
const rows: TimelineRow[] = [
|
||||
{
|
||||
spacecraftId: "56756",
|
||||
plans: [
|
||||
plan("d1", "2026-05-29T02:00:00", "2026-05-29T04:00:00"),
|
||||
plan("d2", "2026-05-29T02:00:00", "2026-05-29T04:00:00"),
|
||||
plan("d3", "2026-05-29T02:30:00", "2026-05-29T04:30:00")
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const layout = buildTimelineLayout(rows, range, 1200);
|
||||
// Все три пересекаются — каждый на своей дорожке.
|
||||
expect(new Set(layout.segments.map((segment) => segment.lane))).toEqual(new Set([0, 1, 2]));
|
||||
// Разные дорожки — разные вертикальные координаты.
|
||||
expect(new Set(layout.segments.map((segment) => segment.centerY)).size).toBe(3);
|
||||
// Строка с тремя дорожками выше строки с одной.
|
||||
expect(layout.rowLayouts[0].laneCount).toBe(3);
|
||||
});
|
||||
|
||||
it("grows row height with the deepest lane stack", () => {
|
||||
const single: TimelineRow[] = [
|
||||
{ spacecraftId: "56756", plans: [plan("p1", "2026-05-29T01:00:00", "2026-05-29T02:00:00")] }
|
||||
];
|
||||
const stacked: TimelineRow[] = [
|
||||
{
|
||||
spacecraftId: "56756",
|
||||
plans: [
|
||||
plan("d1", "2026-05-29T02:00:00", "2026-05-29T04:00:00"),
|
||||
plan("d2", "2026-05-29T02:00:00", "2026-05-29T04:00:00")
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
expect(buildTimelineLayout(stacked, range, 1200).rowLayouts[0].height).toBeGreaterThan(
|
||||
buildTimelineLayout(single, range, 1200).rowLayouts[0].height
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,29 @@
|
||||
import type { TguPlanUi, TimelineLink, TimelinePlanSegment, TimelineRange, TimelineRow } from "../../model/timelineTypes";
|
||||
import type {
|
||||
TguPlanUi,
|
||||
TimelineLink,
|
||||
TimelinePlanSegment,
|
||||
TimelineRange,
|
||||
TimelineRow,
|
||||
TimelineRowLayout
|
||||
} from "../../model/timelineTypes";
|
||||
|
||||
export const TIMELINE_LABEL_WIDTH = 220;
|
||||
export const TIMELINE_ROW_HEIGHT = 58;
|
||||
export const TIMELINE_AXIS_HEIGHT = 46;
|
||||
export const TIMELINE_BOTTOM_PADDING = 18;
|
||||
export const TIMELINE_LANE_STEP = 12;
|
||||
|
||||
/**
|
||||
* Вертикальная раскладка строки КА. Планы, которые не пересекаются по горизонтали, лежат на одной
|
||||
* дорожке (чистая цепочка — прямая линия). Если план пересёкся бы с уже размещённым, он уходит на
|
||||
* следующую дорожку (выше/ниже), чтобы планы не сливались. Высота строки растёт под самый глубокий
|
||||
* стек дорожек, а страйп ЗРВ опускается ниже планов.
|
||||
*/
|
||||
const PLAN_TOP_Y = 28; // центр верхней дорожки (lane 0) относительно верха строки
|
||||
export const TIMELINE_LANE_STEP = 16; // шаг между дорожками пересекающихся планов
|
||||
const ZRV_GAP = 16; // отступ от нижней дорожки планов до страйпа ЗРВ
|
||||
const ZRV_BAND_MIN_Y = 52; // минимум: строка с одной дорожкой выглядит как раньше (высота 74)
|
||||
export const TIMELINE_ZRV_BAND_HEIGHT = 14;
|
||||
const ROW_BOTTOM_PAD = 8;
|
||||
const SEGMENT_GAP_PX = 6; // минимальный горизонтальный зазор, иначе считаем планы пересекающимися
|
||||
|
||||
export function timeToX(timeMs: number, range: TimelineRange, timelineWidth: number): number {
|
||||
return ((timeMs - range.fromMs) / (range.toMs - range.fromMs)) * timelineWidth;
|
||||
@@ -21,38 +40,82 @@ export function clipPlanToRange(plan: TguPlanUi, range: TimelineRange): { startM
|
||||
};
|
||||
}
|
||||
|
||||
export function buildTimelineSegments(
|
||||
type PlacedPlan = { plan: TguPlanUi; x: number; x2: number; width: number };
|
||||
|
||||
/**
|
||||
* Жадная раскладка по дорожкам (interval graph): для каждого плана берём первую дорожку, чья правая
|
||||
* граница уже освободилась (с зазором SEGMENT_GAP_PX). Так пересекающиеся по горизонтали планы
|
||||
* гарантированно оказываются на разных дорожках. Планы должны быть отсортированы по x.
|
||||
*/
|
||||
function packLanes(items: PlacedPlan[]): number[] {
|
||||
const laneRightEdge: number[] = [];
|
||||
return items.map((item) => {
|
||||
let lane = laneRightEdge.findIndex((edge) => item.x >= edge + SEGMENT_GAP_PX);
|
||||
if (lane === -1) {
|
||||
lane = laneRightEdge.length;
|
||||
}
|
||||
laneRightEdge[lane] = item.x + item.width;
|
||||
return lane;
|
||||
});
|
||||
}
|
||||
|
||||
function zrvBandYFor(rowTop: number, laneCount: number): number {
|
||||
return rowTop + Math.max(ZRV_BAND_MIN_Y, PLAN_TOP_Y + (laneCount - 1) * TIMELINE_LANE_STEP + ZRV_GAP);
|
||||
}
|
||||
|
||||
export type TimelineLayout = {
|
||||
rowLayouts: TimelineRowLayout[];
|
||||
segments: TimelinePlanSegment[];
|
||||
height: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Строит полную раскладку таймлайна: переменная высота строк (по числу дорожек), абсолютные
|
||||
* y-координаты планов и страйпов ЗРВ. Единственный источник правды по вертикали — компонент только
|
||||
* рисует по готовым координатам.
|
||||
*/
|
||||
export function buildTimelineLayout(
|
||||
rows: TimelineRow[],
|
||||
range: TimelineRange,
|
||||
timelineWidth: number
|
||||
): TimelinePlanSegment[] {
|
||||
): TimelineLayout {
|
||||
const rowLayouts: TimelineRowLayout[] = [];
|
||||
const segments: TimelinePlanSegment[] = [];
|
||||
let cursorY = TIMELINE_AXIS_HEIGHT;
|
||||
|
||||
rows.forEach((row, rowIndex) => {
|
||||
const visiblePlans = row.plans
|
||||
const placed: PlacedPlan[] = row.plans
|
||||
.map((plan) => ({ plan, clipped: clipPlanToRange(plan, range) }))
|
||||
.filter((item): item is { plan: TguPlanUi; clipped: { startMs: number; endMs: number } } => item.clipped !== null)
|
||||
.sort((a, b) => a.plan.startMs - b.plan.startMs || a.plan.endMs - b.plan.endMs);
|
||||
.map(({ plan, clipped }) => {
|
||||
const x = timeToX(clipped.startMs, range, timelineWidth);
|
||||
const x2 = timeToX(clipped.endMs, range, timelineWidth);
|
||||
return { plan, x, x2, width: Math.max(3, x2 - x) };
|
||||
})
|
||||
.sort((a, b) => a.x - b.x || a.x2 - b.x2);
|
||||
|
||||
visiblePlans.forEach((item, planIndex) => {
|
||||
const x = timeToX(item.clipped.startMs, range, timelineWidth);
|
||||
const x2 = timeToX(item.clipped.endMs, range, timelineWidth);
|
||||
const lanes = packLanes(placed);
|
||||
const laneCount = lanes.length === 0 ? 1 : Math.max(...lanes) + 1;
|
||||
const zrvBandY = zrvBandYFor(cursorY, laneCount);
|
||||
const height = zrvBandY - cursorY + TIMELINE_ZRV_BAND_HEIGHT + ROW_BOTTOM_PAD;
|
||||
|
||||
placed.forEach((item, planIndex) => {
|
||||
segments.push({
|
||||
plan: item.plan,
|
||||
rowIndex,
|
||||
laneOffset: timelineLaneOffset(planIndex),
|
||||
x,
|
||||
x2,
|
||||
width: Math.max(3, x2 - x)
|
||||
lane: lanes[planIndex],
|
||||
centerY: cursorY + PLAN_TOP_Y + lanes[planIndex] * TIMELINE_LANE_STEP,
|
||||
x: item.x,
|
||||
x2: item.x2,
|
||||
width: item.width
|
||||
});
|
||||
});
|
||||
|
||||
rowLayouts.push({ row, rowIndex, y: cursorY, height, laneCount, zrvBandY });
|
||||
cursorY += height;
|
||||
});
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
export function timelineLaneOffset(index: number): number {
|
||||
return [0, -1, 0, 1][index % 4] * TIMELINE_LANE_STEP;
|
||||
return { rowLayouts, segments, height: cursorY + TIMELINE_BOTTOM_PADDING };
|
||||
}
|
||||
|
||||
export function buildSequentialLinks(rows: TimelineRow[]): TimelineLink[] {
|
||||
@@ -95,7 +158,3 @@ export function buildTimeTicks(range: TimelineRange, targetCount = 8): number[]
|
||||
|
||||
return ticks;
|
||||
}
|
||||
|
||||
export function timelineHeight(rowCount: number): number {
|
||||
return TIMELINE_AXIS_HEIGHT + rowCount * TIMELINE_ROW_HEIGHT + TIMELINE_BOTTOM_PADDING;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { TguPlatform } from "../../model/tguTypes";
|
||||
import { isOperationalPlatform, operationalSpacecraftIds } from "./tguTimelineMapper";
|
||||
import type { TguPlatform, TguPlanStatus } from "../../model/tguTypes";
|
||||
import type { TguPlanUi } from "../../model/timelineTypes";
|
||||
import { isOperationalPlatform, operationalSpacecraftIds, pausedSpacecraftIds } from "./tguTimelineMapper";
|
||||
|
||||
function platform(overrides: Partial<TguPlatform>): TguPlatform {
|
||||
return { id: "x", noradId: 1, status: "OPERATIONAL", ...overrides };
|
||||
}
|
||||
|
||||
function uiPlan(spacecraftId: string, status: TguPlanStatus, startMs: number): TguPlanUi {
|
||||
return {
|
||||
planId: `${spacecraftId}-${status}-${startMs}`,
|
||||
spacecraftId,
|
||||
startTime: new Date(startMs).toISOString().slice(0, 19),
|
||||
endTime: new Date(startMs + 3_600_000).toISOString().slice(0, 19),
|
||||
kppId: "KPP-1",
|
||||
status,
|
||||
startMs,
|
||||
endMs: startMs + 3_600_000
|
||||
};
|
||||
}
|
||||
|
||||
describe("isOperationalPlatform", () => {
|
||||
it("принимает только статус OPERATIONAL без учёта регистра и пробелов", () => {
|
||||
expect(isOperationalPlatform(platform({ status: "OPERATIONAL" }))).toBe(true);
|
||||
@@ -28,3 +42,38 @@ describe("operationalSpacecraftIds", () => {
|
||||
expect(ids).toEqual(new Set(["2", "30"]));
|
||||
});
|
||||
});
|
||||
|
||||
describe("pausedSpacecraftIds", () => {
|
||||
const t0 = Date.UTC(2026, 5, 4, 0, 0, 0);
|
||||
const hour = 3_600_000;
|
||||
|
||||
it("помечает КА паузой, если есть REJECTED и нет продвинувшего цепочку плана после него", () => {
|
||||
const paused = pausedSpacecraftIds([
|
||||
uiPlan("100", "REJECTED", t0),
|
||||
// проекция и черновик паузу не снимают
|
||||
uiPlan("100", "PLANNED", t0 + 2 * hour),
|
||||
uiPlan("100", "DRAFT", t0)
|
||||
]);
|
||||
expect(paused).toEqual(new Set(["100"]));
|
||||
});
|
||||
|
||||
it("снимает паузу, если оператор отправил исправленный план (в полёте) на том же/более позднем окне", () => {
|
||||
const paused = pausedSpacecraftIds([
|
||||
uiPlan("100", "REJECTED", t0),
|
||||
uiPlan("100", "WAITING_DECISION", t0)
|
||||
]);
|
||||
expect(paused.has("100")).toBe(false);
|
||||
});
|
||||
|
||||
it("не помечает паузой, если цепочка ушла вперёд (ACCEPTED после отказа)", () => {
|
||||
const paused = pausedSpacecraftIds([
|
||||
uiPlan("100", "REJECTED", t0),
|
||||
uiPlan("100", "ACCEPTED", t0 + 5 * hour)
|
||||
]);
|
||||
expect(paused.has("100")).toBe(false);
|
||||
});
|
||||
|
||||
it("без REJECTED никого не помечает", () => {
|
||||
expect(pausedSpacecraftIds([uiPlan("100", "WAITING_DECISION", t0)])).toEqual(new Set());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -76,6 +76,47 @@ export function buildPlatformsForInterval(
|
||||
return platformItems.sort((a, b) => platformLabel(a).localeCompare(platformLabel(b), "ru"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Статусы, означающие, что цепочка после отказа уже сдвинулась с места: оператор создал и отправил
|
||||
* исправленный план (он в полёте) либо план уже принят/заложен. Проекция PLANNED и черновики DRAFT
|
||||
* сюда НЕ входят — это ещё не действие оператора, пауза не снимается.
|
||||
*/
|
||||
const CHAIN_ADVANCED_STATUSES = new Set([
|
||||
"ISSUING",
|
||||
"WAITING_DECISION",
|
||||
"AWAITING_LAYIN",
|
||||
"LAID_IN",
|
||||
"ACCEPTED"
|
||||
]);
|
||||
|
||||
/**
|
||||
* КА, у которых цепочка стоит на паузе после REJECTED: есть отклонённый план и НЕТ ни одного
|
||||
* «продвинувшего цепочку» плана (см. [CHAIN_ADVANCED_STATUSES]) со стартом не раньше последнего
|
||||
* отклонённого окна. Совпадает с серверной паузой ([IssueDuePlanWorker] не выдаёт окно с REJECTED) —
|
||||
* оператор должен вручную создать исправленный план.
|
||||
*/
|
||||
export function pausedSpacecraftIds(plans: TguPlanUi[]): Set<string> {
|
||||
const latestRejectedStart = new Map<string, number>();
|
||||
for (const plan of plans) {
|
||||
if (plan.status === "REJECTED") {
|
||||
const prev = latestRejectedStart.get(plan.spacecraftId) ?? Number.NEGATIVE_INFINITY;
|
||||
if (plan.startMs > prev) latestRejectedStart.set(plan.spacecraftId, plan.startMs);
|
||||
}
|
||||
}
|
||||
|
||||
const paused = new Set<string>();
|
||||
for (const [spacecraftId, rejectedStart] of latestRejectedStart) {
|
||||
const advanced = plans.some(
|
||||
(plan) =>
|
||||
plan.spacecraftId === spacecraftId &&
|
||||
CHAIN_ADVANCED_STATUSES.has(plan.status) &&
|
||||
plan.startMs >= rejectedStart
|
||||
);
|
||||
if (!advanced) paused.add(spacecraftId);
|
||||
}
|
||||
return paused;
|
||||
}
|
||||
|
||||
export function buildTimelineRows(
|
||||
plans: TguPlanUi[],
|
||||
platforms: TguPlatformUi[],
|
||||
|
||||
Reference in New Issue
Block a user