feat(tgu-ops-ui): display plan works and new shoots on editor map

На вкладке Редактор 2D-карта теперь показывает включения выбранного плана
и новые съёмки, добавленные в текущей сессии.

- editorApi: типы PlanSurveyMode/PlanDropMode/PlanMode и fetchPlanModes —
  вызывает GET /api/current-plans/missions/{planId}/modes на pcp-mission-planing-service,
  где planId TGU совпадает с missionId (выяснено по Camunda-процессу)
- TguEditorTab: загружает включения при выборе плана, строит MapPolygon[]
  из SURVEY-режимов через parseWktPolygon(contourWkt), включён слой planWorks
- Tgu2DMapView: разделены pending-маркер (оранжевый, выбор точки)
  и confirmed-маркеры (бирюзовые, подтверждённые съёмки из драфта);
  клик на маркер съёмки открывает WorkInspector

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Дмитрий Соловьев
2026-05-31 09:18:53 +03:00
parent 42708450bb
commit 637a5e8848
4 changed files with 140 additions and 11 deletions
@@ -6,7 +6,7 @@ import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView";
import { parseWktPolygon } from "../tgu-map-2d/geometry/wktParser";
import type { Tgu2DMapLayersState } from "../tgu-map-2d/model/mapLayerTypes";
import type { MapPolygon, Tgu2DMapScene } from "../tgu-map-2d/model/mapTypes";
import { saveEditedPlan } from "./editorApi";
import { fetchPlanModes, saveEditedPlan, type PlanMode } from "./editorApi";
import {
addDownlink,
addShoot,
@@ -28,6 +28,7 @@ import type {
DraftHistory,
EditorGroundStation,
EditorMode,
EditorShootWork,
EditorTarget,
EditorTimelineWindow,
EditorVisibilityTrack,
@@ -42,7 +43,7 @@ const HOUR_MS = 60 * 60 * 1000;
const EDITOR_MAP_LAYERS: Tgu2DMapLayersState = {
tracks: true,
swath: false,
planWorks: false,
planWorks: true,
stations: true,
spacecraftMarkers: false,
requests: true
@@ -72,6 +73,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
const [applyError, setApplyError] = useState<string>();
const [contextSpacecraftIds, setContextSpacecraftIds] = useState<Set<string>>(new Set());
const [requestItems, setRequestItems] = useState<RequestMapItem[]>([]);
const [planModes, setPlanModes] = useState<PlanMode[]>([]);
const draft = history.draft;
const stations = useMemo(() => buildStations(selectedPlan), [selectedPlan]);
const modes: EditorMode[] = DEFAULT_EDITOR_MODES;
@@ -92,8 +94,15 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
[selectedSpacecraftId, selectedWork, shootPasses, stations, target, window]
);
const mapScene = useMemo(
() => buildEditorMapScene(selectedSpacecraftId, window, stations, requestItems),
[selectedSpacecraftId, stations, window, requestItems]
() => buildEditorMapScene(selectedSpacecraftId, window, stations, requestItems, planModes),
[selectedSpacecraftId, stations, window, requestItems, planModes]
);
const shootTargets = useMemo(
() =>
draft.works
.filter((w): w is EditorShootWork => w.kind === "shoot" && w.targetLat !== undefined && w.targetLon !== undefined)
.map((w) => ({ id: w.id, name: w.label, lat: w.targetLat!, lon: w.targetLon! })),
[draft.works]
);
const contextLanes = useMemo(
() =>
@@ -118,6 +127,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
setServiceConflicts({});
setNotice(undefined);
setApplyError(undefined);
setPlanModes([]);
}, [appliedRange, selectedPlan, selectedSpacecraftId]);
useEffect(() => {
@@ -135,6 +145,18 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
return () => controller.abort();
}, [window.fromMs, window.toMs]);
useEffect(() => {
if (!selectedPlan) return;
const planId = selectedPlan.planId;
let cancelled = false;
void fetchPlanModes(planId).then((modes) => {
if (!cancelled) setPlanModes(modes);
}).catch(() => {
// silently ignore — plan modes are supplementary display data
});
return () => { cancelled = true; };
}, [selectedPlan]);
const changeDraft = (transform: (draft: DraftHistory["draft"]) => DraftHistory["draft"]) => {
setHistory((current) => mutate(current, transform));
setServiceConflicts({});
@@ -330,7 +352,14 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
layers={EDITOR_MAP_LAYERS}
redrawToken={0}
pickMode={rightMode === "shoot"}
targets={target ? [target] : []}
targets={shootTargets}
pickTarget={target ?? undefined}
selectedTargetId={selectedWork?.kind === "shoot" ? selectedWorkId : undefined}
onTargetSelect={(id) => {
setSelectedWorkId(id);
setRightMode("inspect");
setNotice(undefined);
}}
onPickPoint={(point) => {
targetCounterRef.current += 1;
setTarget({
@@ -527,7 +556,8 @@ function buildEditorMapScene(
spacecraftId: string | undefined,
window: EditorTimelineWindow,
stations: EditorGroundStation[],
requestItems: RequestMapItem[]
requestItems: RequestMapItem[],
planModes: PlanMode[]
): Tgu2DMapScene {
const center = (window.fromMs + window.toMs) / 2;
const mapRange = { fromMs: center - MAP_TRACK_WINDOW_MS / 2, toMs: center + MAP_TRACK_WINDOW_MS / 2 };
@@ -557,6 +587,24 @@ function buildEditorMapScene(
const polygons: MapPolygon[] = [];
let zIndex = 0;
for (const mode of planModes) {
if (mode.type !== "SURVEY" || !mode.contourWkt) continue;
const rings = parseWktPolygon(mode.contourWkt);
for (const ring of rings) {
if (ring.length < 3) continue;
polygons.push({
id: `planWork:${mode.id}:${zIndex}`,
name: `Съёмка витка ${mode.revolution}`,
kind: "planWork",
layer: "planWorks",
points: ring,
status: "PLANNED",
zIndex: zIndex++
});
}
}
for (const item of requestItems) {
const rings = parseWktPolygon(item.geometry);
for (const ring of rings) {
@@ -17,3 +17,40 @@ export async function saveEditedPlan(_request: SaveEditedPlanRequest): Promise<S
message: "Сохранение правок плана во внешний сервис ещё не реализовано в backend."
};
}
export type PlanSurveyMode = {
id: number;
planId: number;
timeStart: string | null;
revolution: number;
type: "SURVEY";
status: string;
lat: number;
longitude: number;
duration: number;
contourWkt: string | null;
roll: number;
};
export type PlanDropMode = {
id: number;
planId: number;
timeStart: string | null;
revolution: number;
type: "DROP";
station: string | null;
duration: number;
surveys: number[];
};
export type PlanMode = PlanSurveyMode | PlanDropMode;
export async function fetchPlanModes(planId: string): Promise<PlanMode[]> {
const response = await fetch(`/api/current-plans/missions/${encodeURIComponent(planId)}/modes`, {
headers: { Accept: "application/json" }
});
if (!response.ok) {
throw new Error(`Ошибка загрузки включений плана: ${response.status}`);
}
return response.json() as Promise<PlanMode[]>;
}
@@ -20,6 +20,9 @@ type Tgu2DMapViewProps = {
pickMode?: boolean;
onPickPoint?: (point: { lat: number; lon: number }) => void;
targets?: Tgu2DMapTarget[];
pickTarget?: Tgu2DMapTarget;
selectedTargetId?: string;
onTargetSelect?: (id: string) => void;
};
export type Tgu2DMapTarget = {
@@ -58,7 +61,10 @@ export function Tgu2DMapView({
onObjectSelect,
pickMode = false,
onPickPoint,
targets = []
targets = [],
pickTarget,
selectedTargetId,
onTargetSelect
}: Tgu2DMapViewProps) {
const wrapperRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
@@ -299,17 +305,35 @@ export function Tgu2DMapView({
className={polygon.id === selectedPolygon?.id ? "is-selected" : "is-hovered"}
/>
))}
{targets.map((target) => {
const screen = projection.project(target);
{targets.map((t) => {
const screen = projection.project(t);
const isSelected = t.id === selectedTargetId;
return (
<g className="tgu-2d-map__target" key={target.id} transform={`translate(${screen.x} ${screen.y})`}>
<g
className={`tgu-2d-map__target tgu-2d-map__target--confirmed${isSelected ? " is-selected" : ""}`}
key={t.id}
transform={`translate(${screen.x} ${screen.y})`}
onClick={(e) => { e.stopPropagation(); onTargetSelect?.(t.id); }}
style={{ cursor: "pointer" }}
>
<circle r="6" />
<line x1="-10" x2="10" y1="0" y2="0" />
<line x1="0" x2="0" y1="-10" y2="10" />
<text x="10" y="-8">{target.name}</text>
<text x="10" y="-8">{t.name}</text>
</g>
);
})}
{pickTarget && (() => {
const screen = projection.project(pickTarget);
return (
<g className="tgu-2d-map__target tgu-2d-map__target--pending" key="pick" transform={`translate(${screen.x} ${screen.y})`}>
<circle r="6" />
<line x1="-10" x2="10" y1="0" y2="0" />
<line x1="0" x2="0" y1="-10" y2="10" />
<text x="10" y="-8">{pickTarget.name}</text>
</g>
);
})()}
</svg>
{tooltip && (
<div className="tgu-2d-map__tooltip" style={{ left: tooltip.x, top: tooltip.y }}>
@@ -291,6 +291,26 @@
stroke-width: 3px;
}
.tgu-2d-map__target--confirmed circle {
fill: var(--t-optical-dim);
stroke: var(--t-optical);
}
.tgu-2d-map__target--confirmed line {
stroke: var(--t-optical);
}
.tgu-2d-map__target--confirmed.is-selected circle {
fill: rgba(104, 195, 189, 0.35);
stroke: var(--t-optical);
stroke-width: 2.5;
r: 8;
}
.tgu-2d-map__target--confirmed.is-selected line {
stroke-width: 2;
}
.tgu-editor-timeline {
min-width: 0;
min-height: 0;