feat(tgu-ops-ui): display request polygons on 2D map

Добавлен слой заявок (КНС) на 2D карту ТГУ:
- requestApi.ts — загрузка заявок постранично через GET /v1/requests/map
  с прогресс-коллбэком; автоматически собирает все страницы
- wktParser.ts — парсинг WKT POLYGON/MULTIPOLYGON в массив координат
- drawRequests.ts — Canvas-рендеринг полигонов заявок
- Tgu2DMapLayers.ts — buildTgu2DMapScene строит MapPolygon[] из RequestMapItem[]
- Tgu2DMapTab.tsx — состояние requestsState, кнопка «Загрузить», фильтры по дате
- Tgu2DMapSidebar.tsx — панель слоя заявок: счётчик, ошибки, truncated-предупреждение
- requestTypes.ts — RequestMapItem, RequestMapPage модели
- Поддержка выбора заявки на карте (mapSelectionTypes, mapTypes)
- nginx.conf, vite.config.ts — проксирование /v1 на pcp-request-service в dev

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Дмитрий Соловьев
2026-05-30 23:28:58 +03:00
parent bb84844674
commit 42708450bb
16 changed files with 647 additions and 22 deletions
@@ -1,8 +1,11 @@
import { useEffect, useMemo, useRef, useState, useCallback, type MutableRefObject } from "react";
import { fetchRequestsForMap } from "../../api/requestApi";
import type { RequestMapItem } from "../../model/requestTypes";
import { groundTrack, stationPasses, targetPasses } from "../tgu-map-2d/geometry/passes";
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 { Tgu2DMapScene } from "../tgu-map-2d/model/mapTypes";
import type { MapPolygon, Tgu2DMapScene } from "../tgu-map-2d/model/mapTypes";
import { saveEditedPlan } from "./editorApi";
import {
addDownlink,
@@ -41,7 +44,8 @@ const EDITOR_MAP_LAYERS: Tgu2DMapLayersState = {
swath: false,
planWorks: false,
stations: true,
spacecraftMarkers: false
spacecraftMarkers: false,
requests: true
};
export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, platforms }: TguEditorTabProps) {
@@ -67,6 +71,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
const [notice, setNotice] = useState<string>();
const [applyError, setApplyError] = useState<string>();
const [contextSpacecraftIds, setContextSpacecraftIds] = useState<Set<string>>(new Set());
const [requestItems, setRequestItems] = useState<RequestMapItem[]>([]);
const draft = history.draft;
const stations = useMemo(() => buildStations(selectedPlan), [selectedPlan]);
const modes: EditorMode[] = DEFAULT_EDITOR_MODES;
@@ -87,8 +92,8 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
[selectedSpacecraftId, selectedWork, shootPasses, stations, target, window]
);
const mapScene = useMemo(
() => buildEditorMapScene(selectedSpacecraftId, window, stations),
[selectedSpacecraftId, stations, window]
() => buildEditorMapScene(selectedSpacecraftId, window, stations, requestItems),
[selectedSpacecraftId, stations, window, requestItems]
);
const contextLanes = useMemo(
() =>
@@ -115,6 +120,21 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
setApplyError(undefined);
}, [appliedRange, selectedPlan, selectedSpacecraftId]);
useEffect(() => {
const controller = new AbortController();
void fetchRequestsForMap({
endFrom: new Date(window.fromMs).toISOString(),
beginTo: new Date(window.toMs).toISOString()
}).then((result) => {
if (!controller.signal.aborted) {
setRequestItems(result.items);
}
}).catch(() => {
// silently ignore — requests are supplementary
});
return () => controller.abort();
}, [window.fromMs, window.toMs]);
const changeDraft = (transform: (draft: DraftHistory["draft"]) => DraftHistory["draft"]) => {
setHistory((current) => mutate(current, transform));
setServiceConflicts({});
@@ -506,10 +526,9 @@ const MAP_TRACK_POINTS = 720; // 2-minute step over 24h → ~45 points per LEO o
function buildEditorMapScene(
spacecraftId: string | undefined,
window: EditorTimelineWindow,
stations: EditorGroundStation[]
stations: EditorGroundStation[],
requestItems: RequestMapItem[]
): Tgu2DMapScene {
// Always show 24h of ground track centred on the timeline window,
// regardless of how wide the timeline is zoomed out.
const center = (window.fromMs + window.toMs) / 2;
const mapRange = { fromMs: center - MAP_TRACK_WINDOW_MS / 2, toMs: center + MAP_TRACK_WINDOW_MS / 2 };
@@ -522,6 +541,7 @@ function buildEditorMapScene(
zIndex: index
}))
: [];
const markers = stations.flatMap((station) => {
if (station.lat === undefined || station.lon === undefined) {
return [];
@@ -535,7 +555,26 @@ function buildEditorMapScene(
}];
});
return { polygons: [], lines, markers };
const polygons: MapPolygon[] = [];
let zIndex = 0;
for (const item of requestItems) {
const rings = parseWktPolygon(item.geometry);
for (const ring of rings) {
if (ring.length < 3) continue;
polygons.push({
id: `request:${item.id}:${zIndex}`,
name: item.name,
kind: "request",
layer: "requests",
points: ring,
requestId: item.id,
surveyType: item.surveyType,
zIndex: zIndex++
});
}
}
return { polygons, lines, markers };
}
function platformLabel(platforms: TguEditorTabProps["platforms"], spacecraftId?: string): string {