From 42708450bbf25fa803a7d7bebe011d567ccefbfa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9=20=D0=A1=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D1=8C=D0=B5=D0=B2?= Date: Sat, 30 May 2026 23:28:58 +0300 Subject: [PATCH] feat(tgu-ops-ui): display request polygons on 2D map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Добавлен слой заявок (КНС) на 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 --- services/pcp-tgu-ops-ui/nginx.conf | 9 ++ services/pcp-tgu-ops-ui/src/api/requestApi.ts | 62 ++++++++++ .../src/features/tgu-editor/TguEditorTab.tsx | 55 +++++++-- .../src/features/tgu-map-2d/Tgu2DMapLayers.ts | 36 +++++- .../features/tgu-map-2d/Tgu2DMapSidebar.tsx | 105 +++++++++++++++- .../src/features/tgu-map-2d/Tgu2DMapTab.tsx | 114 +++++++++++++++++- .../src/features/tgu-map-2d/Tgu2DMapView.tsx | 8 ++ .../tgu-map-2d/canvas/drawRequests.ts | 52 ++++++++ .../features/tgu-map-2d/geometry/wktParser.ts | 54 +++++++++ .../tgu-map-2d/model/mapLayerTypes.ts | 10 +- .../tgu-map-2d/model/mapSelectionTypes.ts | 4 +- .../src/features/tgu-map-2d/model/mapTypes.ts | 6 +- .../tgu-map-2d/tgu2DMapInterval.test.ts | 3 +- .../pcp-tgu-ops-ui/src/model/requestTypes.ts | 65 ++++++++++ .../src/styles/tgu-planning.css | 81 +++++++++++++ services/pcp-tgu-ops-ui/vite.config.ts | 5 + 16 files changed, 647 insertions(+), 22 deletions(-) create mode 100644 services/pcp-tgu-ops-ui/src/api/requestApi.ts create mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-map-2d/canvas/drawRequests.ts create mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-map-2d/geometry/wktParser.ts create mode 100644 services/pcp-tgu-ops-ui/src/model/requestTypes.ts diff --git a/services/pcp-tgu-ops-ui/nginx.conf b/services/pcp-tgu-ops-ui/nginx.conf index e1ffd09..10cc36e 100644 --- a/services/pcp-tgu-ops-ui/nginx.conf +++ b/services/pcp-tgu-ops-ui/nginx.conf @@ -5,6 +5,15 @@ server { root /usr/share/nginx/html; index index.html; + location /api/pcp-request/ { + proxy_pass http://host.docker.internal:7005/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + location /api/ { proxy_pass http://host.docker.internal:7008/api/; proxy_http_version 1.1; diff --git a/services/pcp-tgu-ops-ui/src/api/requestApi.ts b/services/pcp-tgu-ops-ui/src/api/requestApi.ts new file mode 100644 index 0000000..c864f01 --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/api/requestApi.ts @@ -0,0 +1,62 @@ +import type { RequestFilter, RequestMapItem } from "../model/requestTypes"; + +const BASE = "/api/pcp-request/v1"; +const PAGE_SIZE = 500; +const MAX_PAGES = 10; // up to 5000 requests + +type WithGeometryPage = { + items: RequestMapItem[]; + page: number; + size: number; + totalItems: number; + totalPages: number; +}; + +async function fetchWithGeometryPage(filter: RequestFilter, page: number): Promise { + const params = new URLSearchParams(); + if (filter.beginFrom) params.set("beginFrom", filter.beginFrom); + if (filter.beginTo) params.set("beginTo", filter.beginTo); + if (filter.endFrom) params.set("endFrom", filter.endFrom); + if (filter.endTo) params.set("endTo", filter.endTo); + if (filter.status) params.set("status", filter.status); + if (filter.surveyType) params.set("surveyType", filter.surveyType); + params.set("size", String(PAGE_SIZE)); + params.set("page", String(page)); + params.set("sort", "beginDateTime,asc"); + + const response = await fetch(`${BASE}/requests/map?${params}`, { + headers: { Accept: "application/json" } + }); + + if (!response.ok) { + throw new Error(`Ошибка загрузки заявок: ${response.status}`); + } + + return response.json() as Promise; +} + +export async function fetchRequestsForMap( + filter: RequestFilter, + onProgress?: (loaded: number, total: number) => void +): Promise<{ items: RequestMapItem[]; totalItems: number; truncated: boolean }> { + const first = await fetchWithGeometryPage(filter, 0); + onProgress?.(first.items.length, first.totalItems); + + if (first.totalPages <= 1) { + return { items: first.items, totalItems: first.totalItems, truncated: false }; + } + + const pagesToLoad = Math.min(first.totalPages, MAX_PAGES) - 1; + const remaining = await Promise.all( + Array.from({ length: pagesToLoad }, (_, i) => fetchWithGeometryPage(filter, i + 1)) + ); + + const allItems = [...first.items, ...remaining.flatMap((page) => page.items)]; + onProgress?.(allItems.length, first.totalItems); + + return { + items: allItems, + totalItems: first.totalItems, + truncated: first.totalPages > MAX_PAGES + }; +} diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx index 861e990..7ae397e 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx @@ -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(); const [applyError, setApplyError] = useState(); const [contextSpacecraftIds, setContextSpacecraftIds] = useState>(new Set()); + const [requestItems, setRequestItems] = useState([]); 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 { diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapLayers.ts b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapLayers.ts index c58f5de..55634a2 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapLayers.ts +++ b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapLayers.ts @@ -1,6 +1,8 @@ +import type { RequestMapItem } from "../../model/requestTypes"; import type { TguPlanUi, TguPlatformUi, TimelineRange } from "../../model/timelineTypes"; +import { parseWktPolygon } from "./geometry/wktParser"; import type { Tgu2DMapLayersState } from "./model/mapLayerTypes"; -import type { Tgu2DMapScene } from "./model/mapTypes"; +import type { MapPolygon, Tgu2DMapScene } from "./model/mapTypes"; export type Tgu2DMapLayerAvailability = Partial>; @@ -9,18 +11,45 @@ export type Tgu2DMapSceneInput = { selectedPlan?: TguPlanUi; platforms: TguPlatformUi[]; hiddenSpacecraftIds?: ReadonlySet; + requestItems?: RequestMapItem[]; }; const UNAVAILABLE = "Данные слоя недоступны в текущем API."; -export function buildTgu2DMapScene(_input: Tgu2DMapSceneInput): Tgu2DMapScene { +export function buildTgu2DMapScene(input: Tgu2DMapSceneInput): Tgu2DMapScene { + const requestPolygons = buildRequestPolygons(input.requestItems ?? []); + return { - polygons: [], + polygons: requestPolygons, lines: [], markers: [] }; } +function buildRequestPolygons(items: RequestMapItem[]): MapPolygon[] { + const polygons: MapPolygon[] = []; + let zIndex = 0; + + for (const item of items) { + 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; +} + export function getLayerAvailability(layers: Tgu2DMapLayersState, scene: Tgu2DMapScene): Tgu2DMapLayerAvailability { const availability: Tgu2DMapLayerAvailability = {}; if (layers.tracks && scene.lines.filter((line) => line.layer === "tracks").length === 0) { @@ -40,4 +69,3 @@ export function getLayerAvailability(layers: Tgu2DMapLayersState, scene: Tgu2DMa } return availability; } - diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapSidebar.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapSidebar.tsx index cbe28de..9d85e1a 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapSidebar.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapSidebar.tsx @@ -2,6 +2,9 @@ import type { TguPlatformUi } from "../../model/timelineTypes"; import { filterPlatforms, platformLabel } from "../tgu-planning/tguTimelineMapper"; import { TGU_2D_MAP_LAYERS, type Tgu2DMapLayerKey, type Tgu2DMapLayersState } from "./model/mapLayerTypes"; import type { Tgu2DMapLayerAvailability } from "./Tgu2DMapLayers"; +import type { RequestsState } from "./Tgu2DMapTab"; + +const DAY_MS = 24 * 60 * 60 * 1000; type Tgu2DMapSidebarProps = { platforms: TguPlatformUi[]; @@ -10,10 +13,15 @@ type Tgu2DMapSidebarProps = { layers: Tgu2DMapLayersState; search: string; layerAvailability: Tgu2DMapLayerAvailability; + requestsState: RequestsState; onSearchChange: (value: string) => void; onToggleSpacecraft: (id: string) => void; onShowAll: () => void; onToggleLayer: (key: Tgu2DMapLayerKey) => void; + onRequestsFromChange: (value: string) => void; + onRequestsToChange: (value: string) => void; + onRequestsApply: () => void; + onRequestsQuickRange: (fromMs: number, toMs: number) => void; }; export function Tgu2DMapSidebar({ @@ -23,14 +31,42 @@ export function Tgu2DMapSidebar({ layers, search, layerAvailability, + requestsState, onSearchChange, onToggleSpacecraft, onShowAll, - onToggleLayer + onToggleLayer, + onRequestsFromChange, + onRequestsToChange, + onRequestsApply, + onRequestsQuickRange }: Tgu2DMapSidebarProps) { const filteredPlatforms = filterPlatforms(platforms, search); const hiddenCount = hiddenIds.size; + const quickToday = () => { + const now = new Date(); + const fromMs = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); + onRequestsQuickRange(fromMs, fromMs + DAY_MS); + }; + + const quick7days = () => { + const fromMs = Date.now(); + onRequestsQuickRange(fromMs, fromMs + 7 * DAY_MS); + }; + + const quick30days = () => { + const fromMs = Date.now(); + onRequestsQuickRange(fromMs, fromMs + 30 * DAY_MS); + }; + + const requestsFromMs = new Date(requestsState.fromValue).getTime(); + const requestsToMs = new Date(requestsState.toValue).getTime(); + const requestsRangeInvalid = + !Number.isFinite(requestsFromMs) || + !Number.isFinite(requestsToMs) || + requestsFromMs >= requestsToMs; + return (