From 0128c5569c312c4b134266af9a2b86d44e765c2c 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: Mon, 1 Jun 2026 22:17:50 +0300 Subject: [PATCH] =?UTF-8?q?2D-=D0=BA=D0=B0=D1=80=D1=82=D0=B0=20(=D1=81?= =?UTF-8?q?=D1=82=D0=B0=D0=BD=D1=86=D0=B8=D0=B8,=20=D0=B7=D0=B0=D1=8F?= =?UTF-8?q?=D0=B2=D0=BA=D0=B8)=20=D0=B8=20=D0=B2=D0=BA=D0=BB=D0=B0=D0=B4?= =?UTF-8?q?=D0=BA=D0=B0=20=C2=AB=D0=97=D0=B0=D1=8F=D0=B2=D0=BA=D0=B8=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Новая вкладка «Заявки»: показ заявок за интервал на 2D-карте, создание заявки рисованием полигона на карте с формой параметров (Оптика/РСА) и удаление выбранной заявки (createRequest/deleteRequest, RequestCreatePanel/RequestDetailsPanel, polygonToWkt). В Tgu2DMapView добавлен изолированный режим рисования полигона. 2D-карта: панели информации о станции и о заявках, слой станций (stationsApi), поддержка выбора объектов через спатиальный индекс. Список слоёв упрощён — трасса/полоса/маркеры КА убраны из переключателя, по умолчанию включены работы плана, станции и заявки. --- services/pcp-tgu-ops-ui/src/api/requestApi.ts | 42 +- .../pcp-tgu-ops-ui/src/api/stationsApi.ts | 18 + .../src/features/tgu-map-2d/Tgu2DMapLayers.ts | 29 +- .../tgu-map-2d/Tgu2DMapRequestsPanel.tsx | 115 +++++ .../features/tgu-map-2d/Tgu2DMapSidebar.tsx | 16 +- .../tgu-map-2d/Tgu2DMapStationPanel.tsx | 43 ++ .../src/features/tgu-map-2d/Tgu2DMapTab.tsx | 88 +++- .../features/tgu-map-2d/Tgu2DMapToolbar.tsx | 2 +- .../src/features/tgu-map-2d/Tgu2DMapView.tsx | 158 ++++++- .../tgu-map-2d/geometry/spatialIndex.ts | 22 + .../tgu-map-2d/model/mapLayerTypes.ts | 23 +- .../tgu-map-2d/model/mapSelectionTypes.ts | 6 + .../src/features/tgu-map-2d/model/mapTypes.ts | 4 + .../tgu-map-2d/tgu2DMapInterval.test.ts | 8 +- .../features/tgu-planning/TguPlanningPage.tsx | 16 +- .../src/features/tgu-planning/TguSidebar.tsx | 5 + .../src/features/tgu-planning/TguTimeline.tsx | 22 +- .../src/features/tgu-planning/TguToolbar.tsx | 20 +- .../tgu-requests/RequestCreatePanel.tsx | 355 +++++++++++++++ .../tgu-requests/RequestDetailsPanel.tsx | 142 ++++++ .../features/tgu-requests/TguRequestsTab.tsx | 420 ++++++++++++++++++ .../tgu-requests/requestGeometry.test.ts | 27 ++ .../features/tgu-requests/requestGeometry.ts | 27 ++ .../pcp-tgu-ops-ui/src/model/requestTypes.ts | 41 ++ .../pcp-tgu-ops-ui/src/model/timelineTypes.ts | 2 +- .../src/styles/tgu-planning.css | 296 +++++++++++- 26 files changed, 1851 insertions(+), 96 deletions(-) create mode 100644 services/pcp-tgu-ops-ui/src/api/stationsApi.ts create mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapRequestsPanel.tsx create mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapStationPanel.tsx create mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-requests/RequestCreatePanel.tsx create mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-requests/RequestDetailsPanel.tsx create mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-requests/TguRequestsTab.tsx create mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-requests/requestGeometry.test.ts create mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-requests/requestGeometry.ts diff --git a/services/pcp-tgu-ops-ui/src/api/requestApi.ts b/services/pcp-tgu-ops-ui/src/api/requestApi.ts index c864f01..7d3e8d4 100644 --- a/services/pcp-tgu-ops-ui/src/api/requestApi.ts +++ b/services/pcp-tgu-ops-ui/src/api/requestApi.ts @@ -1,4 +1,4 @@ -import type { RequestFilter, RequestMapItem } from "../model/requestTypes"; +import type { CreateRequestPayload, CreateRequestResult, RequestFilter, RequestMapItem } from "../model/requestTypes"; const BASE = "/api/pcp-request/v1"; const PAGE_SIZE = 500; @@ -60,3 +60,43 @@ export async function fetchRequestsForMap( truncated: first.totalPages > MAX_PAGES }; } + +type ErrorDetail = { field?: string; message?: string }; +type ErrorBody = { code?: string; message?: string; details?: ErrorDetail[] }; + +async function readErrorMessage(response: Response, fallback: string): Promise { + try { + const body = (await response.json()) as ErrorBody; + const details = body.details?.map((d) => (d.field ? `${d.field}: ${d.message}` : d.message)).filter(Boolean); + const detailText = details && details.length > 0 ? ` (${details.join("; ")})` : ""; + if (body.message) return `${body.message}${detailText}`; + } catch { + // тело ответа не JSON — используем fallback + } + return `${fallback}: ${response.status}`; +} + +export async function createRequest(payload: CreateRequestPayload): Promise { + const response = await fetch(`${BASE}/requests`, { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify(payload) + }); + + if (!response.ok) { + throw new Error(await readErrorMessage(response, "Не удалось создать заявку")); + } + + return response.json() as Promise; +} + +export async function deleteRequest(id: string): Promise { + const response = await fetch(`${BASE}/requests/${encodeURIComponent(id)}`, { + method: "DELETE", + headers: { Accept: "application/json" } + }); + + if (!response.ok) { + throw new Error(await readErrorMessage(response, "Не удалось удалить заявку")); + } +} diff --git a/services/pcp-tgu-ops-ui/src/api/stationsApi.ts b/services/pcp-tgu-ops-ui/src/api/stationsApi.ts new file mode 100644 index 0000000..aede019 --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/api/stationsApi.ts @@ -0,0 +1,18 @@ +export type StationDto = { + id: string | null; + number: number; + name: string; + position: { lat: number; long: number; height: number }; + elevationMin: number; + elevationMax: number; +}; + +export async function fetchStations(): Promise { + const response = await fetch("/api/stations", { + headers: { Accept: "application/json" } + }); + if (!response.ok) { + throw new Error(`Ошибка загрузки станций: ${response.status}`); + } + return response.json() as Promise; +} 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 55634a2..53eec8a 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,8 +1,9 @@ +import type { StationDto } from "../../api/stationsApi"; 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 { MapPolygon, Tgu2DMapScene } from "./model/mapTypes"; +import type { MapMarker, MapPolygon, Tgu2DMapScene } from "./model/mapTypes"; export type Tgu2DMapLayerAvailability = Partial>; @@ -12,17 +13,19 @@ export type Tgu2DMapSceneInput = { platforms: TguPlatformUi[]; hiddenSpacecraftIds?: ReadonlySet; requestItems?: RequestMapItem[]; + stationItems?: StationDto[]; }; const UNAVAILABLE = "Данные слоя недоступны в текущем API."; export function buildTgu2DMapScene(input: Tgu2DMapSceneInput): Tgu2DMapScene { const requestPolygons = buildRequestPolygons(input.requestItems ?? []); + const stationMarkers = buildStationMarkers(input.stationItems ?? []); return { polygons: requestPolygons, lines: [], - markers: [] + markers: stationMarkers }; } @@ -50,7 +53,25 @@ function buildRequestPolygons(items: RequestMapItem[]): MapPolygon[] { return polygons; } -export function getLayerAvailability(layers: Tgu2DMapLayersState, scene: Tgu2DMapScene): Tgu2DMapLayerAvailability { +export function buildStationMarkers(items: StationDto[]): MapMarker[] { + return items.map((item, index) => ({ + id: item.id ?? `station:${index}`, + layer: "stations" as const, + point: { lon: item.position.long, lat: item.position.lat }, + label: item.name, + zIndex: index, + stationNumber: item.number, + altitude: item.position.height, + elevationMin: item.elevationMin, + elevationMax: item.elevationMax + })); +} + +export function getLayerAvailability( + layers: Tgu2DMapLayersState, + scene: Tgu2DMapScene, + stationsFetchFailed?: boolean +): Tgu2DMapLayerAvailability { const availability: Tgu2DMapLayerAvailability = {}; if (layers.tracks && scene.lines.filter((line) => line.layer === "tracks").length === 0) { availability.tracks = UNAVAILABLE; @@ -61,7 +82,7 @@ export function getLayerAvailability(layers: Tgu2DMapLayersState, scene: Tgu2DMa if (layers.planWorks && scene.polygons.filter((polygon) => polygon.layer === "planWorks").length === 0) { availability.planWorks = UNAVAILABLE; } - if (layers.stations && scene.markers.filter((marker) => marker.layer === "stations").length === 0) { + if (layers.stations && stationsFetchFailed) { availability.stations = UNAVAILABLE; } if (layers.spacecraftMarkers && scene.markers.filter((marker) => marker.layer === "spacecraftMarkers").length === 0) { diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapRequestsPanel.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapRequestsPanel.tsx new file mode 100644 index 0000000..706c160 --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapRequestsPanel.tsx @@ -0,0 +1,115 @@ +import type { RequestMapItem } from "../../model/requestTypes"; +import type { MapPolygon } from "./model/mapTypes"; + +export type RequestAtPoint = { polygon: MapPolygon; item: RequestMapItem }; + +type Props = { + requests: RequestAtPoint[]; + selectedPolygonId?: string; + onHover: (polygonId: string | undefined) => void; + onSelect: (polygonId: string) => void; + onClose: () => void; +}; + +export function Tgu2DMapRequestsPanel({ requests, selectedPolygonId, onHover, onSelect, onClose }: Props) { + const selectedEntry = selectedPolygonId ? requests.find((r) => r.polygon.id === selectedPolygonId) : undefined; + + return ( + + ); +} + +function formatDateTime(value: string): string { + return new Date(value).toLocaleString("ru-RU"); +} + +function formatDateRange(begin: string, end: string): string { + const b = new Date(begin); + const e = new Date(end); + const sameDay = + b.getFullYear() === e.getFullYear() && + b.getMonth() === e.getMonth() && + b.getDate() === e.getDate(); + const dateOpts: Intl.DateTimeFormatOptions = { day: "2-digit", month: "2-digit" }; + const timeOpts: Intl.DateTimeFormatOptions = { hour: "2-digit", minute: "2-digit" }; + const beginStr = `${b.toLocaleDateString("ru-RU", dateOpts)} ${b.toLocaleTimeString("ru-RU", timeOpts)}`; + const endStr = sameDay + ? b.toLocaleTimeString("ru-RU", timeOpts) + : `${e.toLocaleDateString("ru-RU", dateOpts)} ${e.toLocaleTimeString("ru-RU", timeOpts)}`; + return `${beginStr} – ${endStr}`; +} 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 9d85e1a..0ae04cc 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 @@ -70,7 +70,7 @@ export function Tgu2DMapSidebar({ return ( ); } diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapStationPanel.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapStationPanel.tsx new file mode 100644 index 0000000..fab4765 --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapStationPanel.tsx @@ -0,0 +1,43 @@ +import type { Tgu2DMapSelection } from "./model/mapSelectionTypes"; + +type StationSelection = Extract & { layer: "stations" }; + +type Props = { + selection: StationSelection; + onClose: () => void; +}; + +export function Tgu2DMapStationPanel({ selection, onClose }: Props) { + return ( + + ); +} diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapTab.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapTab.tsx index 8082522..fb6e699 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapTab.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapTab.tsx @@ -1,13 +1,17 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { fetchRequestsForMap } from "../../api/requestApi"; +import { fetchStations, type StationDto } from "../../api/stationsApi"; import type { RequestMapItem } from "../../model/requestTypes"; import type { TguPlanUi, TguPlatformUi, TimelineRange } from "../../model/timelineTypes"; import { platformLabel } from "../tgu-planning/tguTimelineMapper"; import { DEFAULT_TGU_2D_MAP_LAYERS, type Tgu2DMapLayerKey, type Tgu2DMapLayersState } from "./model/mapLayerTypes"; import type { Tgu2DMapSelection } from "./model/mapSelectionTypes"; +import type { MapPolygon } from "./model/mapTypes"; import { buildTgu2DMapScene, getLayerAvailability } from "./Tgu2DMapLayers"; import { Tgu2DMapDetails } from "./Tgu2DMapDetails"; +import { Tgu2DMapRequestsPanel, type RequestAtPoint } from "./Tgu2DMapRequestsPanel"; import { Tgu2DMapSidebar } from "./Tgu2DMapSidebar"; +import { Tgu2DMapStationPanel } from "./Tgu2DMapStationPanel"; import { Tgu2DMapToolbar } from "./Tgu2DMapToolbar"; import { Tgu2DMapView } from "./Tgu2DMapView"; import { getTgu2DMapIntervalState, TGU_2D_MAP_INTERVAL_WARNING } from "./tgu2DMapInterval"; @@ -56,6 +60,18 @@ export function Tgu2DMapTab({ const [selectedObject, setSelectedObject] = useState(); const [hiddenIds, setHiddenIds] = useState>(new Set()); const [requestsState, setRequestsState] = useState(() => buildInitialRequestsState(range)); + const [stations, setStations] = useState([]); + const [stationsFetchFailed, setStationsFetchFailed] = useState(false); + const [requestsAtPoint, setRequestsAtPoint] = useState([]); + const [selectedRequestPolygonId, setSelectedRequestPolygonId] = useState(); + const [hoveredRequestPolygonId, setHoveredRequestPolygonId] = useState(); + const [stationPanel, setStationPanel] = useState<(Tgu2DMapSelection & { type: "marker"; layer: "stations" }) | undefined>(); + + useEffect(() => { + fetchStations() + .then((items) => { setStations(items); setStationsFetchFailed(false); }) + .catch(() => setStationsFetchFailed(true)); + }, []); const mapInterval = getTgu2DMapIntervalState(range, selectedPlan); const mapInvalidRange = invalidRange || mapInterval.tooLarge; @@ -146,11 +162,15 @@ export function Tgu2DMapTab({ selectedPlan, platforms, hiddenSpacecraftIds: hiddenIds, - requestItems: layers.requests ? requestsState.items : [] + requestItems: layers.requests ? requestsState.items : [], + stationItems: layers.stations ? stations : [] }), - [mapInterval.range, platforms, selectedPlan, hiddenIds, requestsState.items, layers.requests] + [mapInterval.range, platforms, selectedPlan, hiddenIds, requestsState.items, layers.requests, stations, layers.stations] + ); + const layerAvailability = useMemo( + () => getLayerAvailability(layers, scene, stationsFetchFailed), + [layers, scene, stationsFetchFailed] ); - const layerAvailability = useMemo(() => getLayerAvailability(layers, scene), [layers, scene]); const overlayMessage = invalidRange ? "Некорректный интервал: from должен быть меньше to." @@ -162,6 +182,49 @@ export function Tgu2DMapTab({ setLayers((current) => ({ ...current, [key]: !current[key] })); }; + const handleObjectSelect = useCallback((selection?: Tgu2DMapSelection) => { + if (selection?.type === "marker" && selection.layer === "stations") { + setStationPanel(selection as Tgu2DMapSelection & { type: "marker"; layer: "stations" }); + setRequestsAtPoint([]); + setSelectedRequestPolygonId(undefined); + setHoveredRequestPolygonId(undefined); + setSelectedObject(undefined); + } else { + setStationPanel(undefined); + setSelectedObject(selection); + } + }, []); + + const handleRequestsAtPoint = useCallback( + (polygons: MapPolygon[]) => { + if (polygons.length === 0) { + setRequestsAtPoint([]); + setSelectedRequestPolygonId(undefined); + setHoveredRequestPolygonId(undefined); + return; + } + const joined = polygons.flatMap((polygon) => { + const item = requestsState.items.find((r) => r.id === polygon.requestId); + return item ? [{ polygon, item }] : []; + }); + setRequestsAtPoint(joined); + setSelectedRequestPolygonId(undefined); + setHoveredRequestPolygonId(undefined); + setStationPanel(undefined); + }, + [requestsState.items] + ); + + const closeRequestsPanel = useCallback(() => { + setRequestsAtPoint([]); + setSelectedRequestPolygonId(undefined); + setHoveredRequestPolygonId(undefined); + }, []); + + const closeStationPanel = useCallback(() => { + setStationPanel(undefined); + }, []); + return (
-
+
0 || stationPanel != null) ? " has-requests-panel" : ""}`}> + {requestsAtPoint.length > 0 && ( + + )} + {stationPanel != null && ( + + )}
diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapToolbar.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapToolbar.tsx index d56fae7..9057610 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapToolbar.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapToolbar.tsx @@ -18,7 +18,7 @@ export function Tgu2DMapToolbar({ return (
-
Карта 2D
+
Карта
{satelliteLabel} · {intervalLabel}
diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapView.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapView.tsx index b9908e8..c142f12 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapView.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapView.tsx @@ -7,9 +7,9 @@ import { drawSwath } from "./canvas/drawSwath"; import { drawTiles } from "./canvas/drawTiles"; import { drawTracks } from "./canvas/drawTracks"; import { createMapProjection } from "./geometry/mapProjection"; -import { createPolygonSpatialIndex, hitTestPolygons, queryPolygonsByBBox } from "./geometry/spatialIndex"; +import { createPolygonSpatialIndex, hitTestAllPolygons, hitTestPolygons, queryPolygonsByBBox } from "./geometry/spatialIndex"; import type { Tgu2DMapLayersState } from "./model/mapLayerTypes"; -import type { MapPolygon, MapSize, MapViewState, ScreenPoint, Tgu2DMapScene } from "./model/mapTypes"; +import type { GeoPoint, MapMarker, MapPolygon, MapSize, MapViewState, ScreenPoint, Tgu2DMapScene } from "./model/mapTypes"; import type { Tgu2DMapSelection } from "./model/mapSelectionTypes"; type Tgu2DMapViewProps = { @@ -17,12 +17,18 @@ type Tgu2DMapViewProps = { layers: Tgu2DMapLayersState; redrawToken: number; onObjectSelect: (selection?: Tgu2DMapSelection) => void; + onRequestsAtPoint?: (polygons: MapPolygon[]) => void; + hoveredRequestPolygonId?: string; + selectedRequestPolygonId?: string; pickMode?: boolean; onPickPoint?: (point: { lat: number; lon: number }) => void; targets?: Tgu2DMapTarget[]; pickTarget?: Tgu2DMapTarget; selectedTargetId?: string; onTargetSelect?: (id: string) => void; + drawMode?: boolean; + draftPolygon?: GeoPoint[]; + onDrawAddPoint?: (point: GeoPoint) => void; }; export type Tgu2DMapTarget = { @@ -59,17 +65,26 @@ export function Tgu2DMapView({ layers, redrawToken, onObjectSelect, + onRequestsAtPoint, + hoveredRequestPolygonId, + selectedRequestPolygonId, pickMode = false, onPickPoint, targets = [], pickTarget, selectedTargetId, - onTargetSelect + onTargetSelect, + drawMode = false, + draftPolygon = [], + onDrawAddPoint }: Tgu2DMapViewProps) { const wrapperRef = useRef(null); const canvasRef = useRef(null); const dragRef = useRef(undefined); + const hasDraggedRef = useRef(false); const hoverRef = useRef(undefined); + const hoverMarkerRef = useRef(undefined); + const selectedMarkerRef = useRef(undefined); const [size, setSize] = useState({ width: 1000, height: 560 }); const [view, setView] = useState(INITIAL_VIEW); const [tileVersion, setTileVersion] = useState(0); @@ -81,6 +96,10 @@ export function Tgu2DMapView({ [scene.polygons, layers] ); const polygonIndex = useMemo(() => createPolygonSpatialIndex(activePolygons), [activePolygons]); + const activeMarkers = useMemo( + () => scene.markers.filter((marker) => layers[marker.layer]), + [scene.markers, layers] + ); const projection = useMemo(() => createMapProjection(view, size), [view, size]); const visiblePolygons = useMemo(() => { // When the canvas is wider than the world tile, the entire globe is visible — @@ -170,11 +189,15 @@ export function Tgu2DMapView({ const updateHover = useCallback( (event: React.PointerEvent) => { const screenPoint = screenPointFromEvent(event); - const polygon = hitTestPolygons(polygonIndex, projection.unproject(screenPoint)); - if (polygon?.id === hoverRef.current?.id) return; + const marker = hitTestMarkers(activeMarkers, screenPoint, projection); + const polygon = marker ? undefined : hitTestPolygons(polygonIndex, projection.unproject(screenPoint)); + if (marker?.id === hoverMarkerRef.current?.id && polygon?.id === hoverRef.current?.id) return; + + hoverMarkerRef.current = marker; hoverRef.current = polygon; - if (!polygon) { + + if (!marker && !polygon) { setTooltip(undefined); return; } @@ -182,15 +205,16 @@ export function Tgu2DMapView({ setTooltip({ x: screenPoint.x + 12, y: screenPoint.y + 12, - title: polygon.name, - subtitle: layerTitle(polygon.layer) + title: marker ? marker.label : polygon!.name, + subtitle: marker ? "Станция" : layerTitle(polygon!.layer) }); }, - [polygonIndex, projection, screenPointFromEvent] + [activeMarkers, polygonIndex, projection, screenPointFromEvent] ); const onPointerDown = (event: React.PointerEvent) => { const screenPoint = screenPointFromEvent(event); + hasDraggedRef.current = false; dragRef.current = { startX: screenPoint.x, startY: screenPoint.y, @@ -199,6 +223,8 @@ export function Tgu2DMapView({ wrapperRef.current?.setPointerCapture(event.pointerId); }; + const DRAG_THRESHOLD = 4; + const onPointerMove = (event: React.PointerEvent) => { const drag = dragRef.current; if (!drag) { @@ -209,6 +235,9 @@ export function Tgu2DMapView({ const screenPoint = screenPointFromEvent(event); const dx = screenPoint.x - drag.startX; const dy = screenPoint.y - drag.startY; + if (!hasDraggedRef.current && Math.sqrt(dx * dx + dy * dy) > DRAG_THRESHOLD) { + hasDraggedRef.current = true; + } const dragProjection = createMapProjection( { centerLon: drag.centerAtStart.lon, @@ -231,15 +260,53 @@ export function Tgu2DMapView({ }; const onClick = (event: React.MouseEvent) => { + if (hasDraggedRef.current) return; const screenPoint = screenPointFromEvent(event); + if (drawMode) { + onDrawAddPoint?.(projection.unproject(screenPoint)); + return; + } if (pickMode) { onPickPoint?.(projection.unproject(screenPoint)); onObjectSelect(undefined); return; } - const polygon = hitTestPolygons(polygonIndex, projection.unproject(screenPoint)); + const marker = hitTestMarkers(activeMarkers, screenPoint, projection); + if (marker) { + selectedMarkerRef.current = marker; + selectedPolygonRef.current = undefined; + onRequestsAtPoint?.([]); + onObjectSelect({ + type: "marker", + id: marker.id, + name: marker.label, + layer: marker.layer, + stationNumber: marker.stationNumber, + lat: marker.point.lat, + lon: marker.point.lon, + altitude: marker.altitude, + elevationMin: marker.elevationMin, + elevationMax: marker.elevationMax + }); + return; + } + + const geoPoint = projection.unproject(screenPoint); + const polygon = hitTestPolygons(polygonIndex, geoPoint); + + if (polygon?.layer === "requests" && onRequestsAtPoint) { + const allRequests = hitTestAllPolygons(polygonIndex, geoPoint).filter((p) => p.layer === "requests"); + onRequestsAtPoint(allRequests); + selectedPolygonRef.current = undefined; + selectedMarkerRef.current = undefined; + onObjectSelect(undefined); + return; + } + + onRequestsAtPoint?.([]); selectedPolygonRef.current = polygon; + selectedMarkerRef.current = undefined; if (!polygon) { onObjectSelect(undefined); @@ -276,11 +343,21 @@ export function Tgu2DMapView({ }; const selectedPolygon = selectedPolygonRef.current; - const overlayPolygons = uniquePolygons([selectedPolygon, hoverRef.current]); + const selectedMarker = selectedMarkerRef.current; + const externalSelectedPolygon = useMemo( + () => selectedRequestPolygonId ? activePolygons.find((p) => p.id === selectedRequestPolygonId) : undefined, + [selectedRequestPolygonId, activePolygons] + ); + const externalHoveredPolygon = useMemo( + () => hoveredRequestPolygonId ? activePolygons.find((p) => p.id === hoveredRequestPolygonId) : undefined, + [hoveredRequestPolygonId, activePolygons] + ); + const overlayPolygons = uniquePolygons([externalSelectedPolygon, selectedPolygon, externalHoveredPolygon, hoverRef.current]); + const overlayMarkers = uniqueMarkers([selectedMarker, hoverMarkerRef.current]); return (
{ dragRef.current = undefined; hoverRef.current = undefined; + hoverMarkerRef.current = undefined; setTooltip(undefined); }} onWheel={onWheel} @@ -299,7 +377,10 @@ export function Tgu2DMapView({ {overlayPolygons.flatMap((polygon) => { const raw = polygon.points.map((point) => projection.project(point)); const pts = unwrapX(raw, projection.worldSize); - const cls = polygon.id === selectedPolygon?.id ? "is-selected" : "is-hovered"; + const cls = + polygon.id === selectedPolygon?.id || polygon.id === externalSelectedPolygon?.id + ? "is-selected" + : "is-hovered"; return ([0, projection.worldSize, -projection.worldSize] as const).flatMap((dx) => { const visible = pts.some((p) => { const x = p.x + dx; @@ -315,6 +396,11 @@ export function Tgu2DMapView({ ); }); })} + {overlayMarkers.map((marker) => { + const pt = projection.project(marker.point); + const cls = marker.id === selectedMarker?.id ? "is-selected" : "is-hovered"; + return ; + })} {targets.map((t) => { const screen = projection.project(t); const isSelected = t.id === selectedTargetId; @@ -344,6 +430,22 @@ export function Tgu2DMapView({ ); })()} + {draftPolygon.length > 0 && (() => { + const pts = unwrapX(draftPolygon.map((point) => projection.project(point)), projection.worldSize); + const path = pts.map((p) => `${p.x},${p.y}`).join(" "); + return ( + + {pts.length >= 2 && ( + pts.length >= 3 + ? + : + )} + {pts.map((p, i) => ( + + ))} + + ); + })()} {tooltip && (
@@ -388,6 +490,36 @@ function unwrapX(points: { x: number; y: number }[], worldSize: number): { x: nu return out; } +function hitTestMarkers( + markers: MapMarker[], + screenPoint: ScreenPoint, + projection: ReturnType, + hitRadius = 10 +): MapMarker | undefined { + let best: MapMarker | undefined; + let bestDist = hitRadius; + for (const marker of markers) { + const pt = projection.project(marker.point); + const dist = Math.sqrt((pt.x - screenPoint.x) ** 2 + (pt.y - screenPoint.y) ** 2); + if (dist <= bestDist) { + bestDist = dist; + best = marker; + } + } + return best; +} + +function uniqueMarkers(markers: Array): MapMarker[] { + const result: MapMarker[] = []; + const ids = new Set(); + for (const marker of markers) { + if (!marker || ids.has(marker.id)) continue; + ids.add(marker.id); + result.push(marker); + } + return result; +} + function uniquePolygons(polygons: Array): MapPolygon[] { const result: MapPolygon[] = []; const ids = new Set(); diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/geometry/spatialIndex.ts b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/geometry/spatialIndex.ts index 44ca8eb..6cb11e0 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/geometry/spatialIndex.ts +++ b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/geometry/spatialIndex.ts @@ -20,6 +20,28 @@ export function createPolygonSpatialIndex(polygons: MapPolygon[]): PolygonSpatia }; } +export function hitTestAllPolygons(index: PolygonSpatialIndex, point: GeoPoint): MapPolygon[] { + const hits: MapPolygon[] = []; + + for (const entry of index.entries) { + const inBbox = + bboxContainsPoint(entry.bbox, point) || + bboxContainsPoint(entry.bbox, { lon: point.lon + 360, lat: point.lat }) || + bboxContainsPoint(entry.bbox, { lon: point.lon - 360, lat: point.lat }); + if (!inBbox) continue; + + const normalized = unwrapLon(entry.polygon.points); + const hit = + pointInPolygon(point, normalized) || + pointInPolygon({ lon: point.lon + 360, lat: point.lat }, normalized) || + pointInPolygon({ lon: point.lon - 360, lat: point.lat }, normalized); + + if (hit) hits.push(entry.polygon); + } + + return hits.sort((a, b) => b.zIndex - a.zIndex); +} + export function hitTestPolygons(index: PolygonSpatialIndex, point: GeoPoint): MapPolygon | undefined { let selected: MapPolygon | undefined; diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/model/mapLayerTypes.ts b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/model/mapLayerTypes.ts index 54ba229..502cb80 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/model/mapLayerTypes.ts +++ b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/model/mapLayerTypes.ts @@ -9,31 +9,16 @@ export type Tgu2DMapLayerDefinition = { }; export const TGU_2D_MAP_LAYERS: Tgu2DMapLayerDefinition[] = [ - { - key: "tracks", - label: "Трасса КА", - description: "Проекция движения КА на поверхность." - }, - { - key: "swath", - label: "Полоса обзора", - description: "Массовые полосы обзора через canvas." - }, { key: "planWorks", label: "Работы плана", - description: "Полигоны работ выбранного плана." + description: "Маршруты съёмки из выбранного плана." }, { key: "stations", label: "Станции", description: "Наземные станции приёма." }, - { - key: "spacecraftMarkers", - label: "Маркеры КА", - description: "Текущие или расчётные позиции КА." - }, { key: "requests", label: "Заявки на съёмку", @@ -42,11 +27,11 @@ export const TGU_2D_MAP_LAYERS: Tgu2DMapLayerDefinition[] = [ ]; export const DEFAULT_TGU_2D_MAP_LAYERS: Tgu2DMapLayersState = { - tracks: true, - swath: true, + tracks: false, + swath: false, planWorks: true, stations: true, - spacecraftMarkers: true, + spacecraftMarkers: false, requests: true }; diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/model/mapSelectionTypes.ts b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/model/mapSelectionTypes.ts index 98829d1..bc67087 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/model/mapSelectionTypes.ts +++ b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/model/mapSelectionTypes.ts @@ -18,5 +18,11 @@ export type Tgu2DMapSelection = name: string; layer: "stations" | "spacecraftMarkers"; spacecraftId?: string; + stationNumber?: number; + lat?: number; + lon?: number; + altitude?: number; + elevationMin?: number; + elevationMax?: number; }; diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/model/mapTypes.ts b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/model/mapTypes.ts index a4c6c85..020bbec 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/model/mapTypes.ts +++ b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/model/mapTypes.ts @@ -50,6 +50,10 @@ export type MapMarker = { point: GeoPoint; label: string; zIndex: number; + stationNumber?: number; + altitude?: number; + elevationMin?: number; + elevationMax?: number; }; export type Tgu2DMapScene = { diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/tgu2DMapInterval.test.ts b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/tgu2DMapInterval.test.ts index 5c676ae..ec5cec8 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/tgu2DMapInterval.test.ts +++ b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/tgu2DMapInterval.test.ts @@ -45,13 +45,13 @@ describe("tgu2DMapInterval", () => { }); }); - it("enables all requested 2D map layers by default", () => { + it("enables plan, stations and requests layers by default", () => { expect(DEFAULT_TGU_2D_MAP_LAYERS).toEqual({ - tracks: true, - swath: true, + tracks: false, + swath: false, planWorks: true, stations: true, - spacecraftMarkers: true, + spacecraftMarkers: false, requests: true }); }); diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningPage.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningPage.tsx index 4d0edf8..f3db83f 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningPage.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningPage.tsx @@ -4,6 +4,7 @@ import type { 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 { TguPlanDetails } from "./TguPlanDetails"; import { TguPlanningLayout } from "./TguPlanningLayout"; import { TguSidebar } from "./TguSidebar"; @@ -114,14 +115,6 @@ export function TguPlanningPage() { setDecisionNotice(undefined); }; - const refresh = () => { - if (invalidRange) { - setPageError("Некорректный интервал: from должен быть меньше to."); - return; - } - void loadData(appliedRange); - }; - const handleDecision = async (planId: string, decision: TguPlanDecision, reason?: string) => { setDecisionInFlight(true); setDecisionError(undefined); @@ -156,7 +149,6 @@ export function TguPlanningPage() { onToChange={setToValue} onApply={applyRange} onQuickRange={quickRange} - onRefresh={refresh} /> } > @@ -205,6 +197,12 @@ export function TguPlanningPage() { selectedPlan={selectedPlan} platforms={platforms} /> + ) : activeTab === "requests" ? ( + ) : ( Нет КА по текущему поиску.
}
+ +
+ +
); } diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguTimeline.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguTimeline.tsx index e23ef5e..1f4271b 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguTimeline.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguTimeline.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useLayoutEffect, useMemo, useRef, useState } from "react"; import type { TimelinePlanSegment, TimelineRange, TimelineRow } from "../../model/timelineTypes"; import { buildSequentialLinks, @@ -30,9 +30,11 @@ export function TguTimeline({ rows, range, selectedPlanId, invalidRange, onSelec const height = Math.max(260, timelineHeight(rows.length)); const nowMs = Date.now(); - useEffect(() => { + useLayoutEffect(() => { if (!containerRef.current) return; + setContainerWidth(Math.floor(containerRef.current.getBoundingClientRect().width)); + const observer = new ResizeObserver(([entry]) => { setContainerWidth(Math.floor(entry.contentRect.width)); }); @@ -55,17 +57,14 @@ export function TguTimeline({ rows, range, selectedPlanId, invalidRange, onSelec ? TIMELINE_LABEL_WIDTH + timeToX(nowMs, range, timelineWidth) : undefined; - if (invalidRange) { - return
Некорректный интервал: from должен быть меньше to.
; - } - - if (rows.length === 0 || segments.length === 0) { - return
Нет планов в выбранном интервале.
; - } - return (
- + {invalidRange ? ( +
Некорректный интервал: from должен быть меньше to.
+ ) : rows.length === 0 || segments.length === 0 ? ( +
Нет планов в выбранном интервале.
+ ) : ( + @@ -175,6 +174,7 @@ export function TguTimeline({ rows, range, selectedPlanId, invalidRange, onSelec ); })} + )}
); } diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguToolbar.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguToolbar.tsx index 111cf31..ab77150 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguToolbar.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguToolbar.tsx @@ -1,4 +1,3 @@ -import { TguLegend } from "./TguLegend"; import type { TguActiveTab } from "../../model/timelineTypes"; type TguToolbarProps = { @@ -12,7 +11,6 @@ type TguToolbarProps = { onToChange: (value: string) => void; onApply: () => void; onQuickRange: (hours: number) => void; - onRefresh: () => void; }; export function TguToolbar({ @@ -25,8 +23,7 @@ export function TguToolbar({ onFromChange, onToChange, onApply, - onQuickRange, - onRefresh + onQuickRange }: TguToolbarProps) { return (
@@ -56,7 +53,7 @@ export function TguToolbar({ aria-selected={activeTab === "map"} onClick={() => onTabChange("map")} > - Карта 2D + Карта +
- {error &&
{error}
} ); diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-requests/RequestCreatePanel.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-requests/RequestCreatePanel.tsx new file mode 100644 index 0000000..d1957d6 --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/tgu-requests/RequestCreatePanel.tsx @@ -0,0 +1,355 @@ +import { useState } from "react"; +import type { + OpticsParams, + OpticsResultType, + Polarisation, + RsaParams, + RsaResultType +} from "../../model/requestTypes"; + +export type RequestFormSubmit = { + name: string; + importance: number; + beginDateTime: string; + endDateTime: string; + kpp: number[]; + highPriorityTransmit: boolean; + optics?: OpticsParams; + rsa?: RsaParams; +}; + +type Props = { + vertexCount: number; + drawing: boolean; + defaultBegin: string; + defaultEnd: string; + submitting: boolean; + error?: string; + onUndoPoint: () => void; + onClear: () => void; + onFinishDrawing: () => void; + onRestartDrawing: () => void; + onSubmit: (form: RequestFormSubmit) => void; + onCancel: () => void; +}; + +const OPTICS_RESULT_TYPES: { value: OpticsResultType; label: string }[] = [ + { value: "PANCHROMATIC", label: "Панхроматическая" }, + { value: "MULTISPECTRAL", label: "Мультиспектральная" }, + { value: "PANSHARPENING", label: "Паншарпенинг" } +]; + +const RSA_RESULT_TYPES: { value: RsaResultType; label: string }[] = [ + { value: "SPOTLIGHT", label: "Spotlight" }, + { value: "STRIPMAP", label: "Stripmap" }, + { value: "SCANSAR", label: "ScanSAR" } +]; + +const POLARISATIONS: { value: Polarisation; label: string }[] = [ + { value: "HH", label: "HH" }, + { value: "VV", label: "VV" } +]; + +export function RequestCreatePanel({ + vertexCount, + drawing, + defaultBegin, + defaultEnd, + submitting, + error, + onUndoPoint, + onClear, + onFinishDrawing, + onRestartDrawing, + onSubmit, + onCancel +}: Props) { + const [name, setName] = useState(""); + const [importance, setImportance] = useState("1"); + const [begin, setBegin] = useState(defaultBegin); + const [end, setEnd] = useState(defaultEnd); + const [kpp, setKpp] = useState(""); + const [highPriority, setHighPriority] = useState(false); + + const [opticsEnabled, setOpticsEnabled] = useState(true); + const [opticsResultType, setOpticsResultType] = useState("PANCHROMATIC"); + const [opticsResolution, setOpticsResolution] = useState("1"); + const [sunAngleMin, setSunAngleMin] = useState("10"); + const [sunAngleMax, setSunAngleMax] = useState("90"); + const [clouds, setClouds] = useState("100"); + + const [rsaEnabled, setRsaEnabled] = useState(false); + const [rsaResultType, setRsaResultType] = useState("STRIPMAP"); + const [interferometry, setInterferometry] = useState(false); + const [polarisation, setPolarisation] = useState("HH"); + const [rsaResolution, setRsaResolution] = useState("1"); + + const importanceNum = Number(importance); + const beginMs = new Date(begin).getTime(); + const endMs = new Date(end).getTime(); + + const kppList = parseKpp(kpp); + const kppValid = kppList !== null; + + const geometryReady = vertexCount >= 3 && !drawing; + + const validationError = buildValidationError({ + geometryReady, + drawing, + vertexCount, + name, + importance, + importanceNum, + beginMs, + endMs, + kppValid, + opticsEnabled, + opticsResolution, + rsaEnabled, + rsaResolution + }); + + const handleSubmit = () => { + if (validationError || !kppList) return; + const optics: OpticsParams | undefined = opticsEnabled + ? { + resultType: opticsResultType, + resolution: Number(opticsResolution), + sunAngleMin: Number(sunAngleMin), + sunAngleMax: Number(sunAngleMax), + clouds: Number(clouds) + } + : undefined; + const rsa: RsaParams | undefined = rsaEnabled + ? { + resultType: rsaResultType, + interferometry, + polarisation, + resolution: Number(rsaResolution) + } + : undefined; + + onSubmit({ + name: name.trim(), + importance: importanceNum, + beginDateTime: begin, + endDateTime: end, + kpp: kppList, + highPriorityTransmit: highPriority, + optics, + rsa + }); + }; + + return ( + + ); +} + +function parseKpp(raw: string): number[] | null { + const trimmed = raw.trim(); + if (trimmed === "") return []; + const parts = trimmed.split(/[,\s]+/).filter(Boolean); + const result: number[] = []; + for (const part of parts) { + const n = Number(part); + if (!Number.isInteger(n) || n < 0) return null; + result.push(n); + } + return result; +} + +function buildValidationError(input: { + geometryReady: boolean; + drawing: boolean; + vertexCount: number; + name: string; + importance: string; + importanceNum: number; + beginMs: number; + endMs: number; + kppValid: boolean; + opticsEnabled: boolean; + opticsResolution: string; + rsaEnabled: boolean; + rsaResolution: string; +}): string | null { + if (input.drawing) return "Завершите рисование полигона (≥3 вершин)."; + if (!input.geometryReady) return "Задайте полигон минимум из 3 вершин."; + if (input.name.trim() === "") return "Укажите название заявки."; + if (input.importance.trim() === "" || !Number.isFinite(input.importanceNum) || input.importanceNum < 0) { + return "Важность должна быть числом ≥ 0."; + } + if (!Number.isFinite(input.beginMs) || !Number.isFinite(input.endMs) || input.beginMs >= input.endMs) { + return "Начало должно быть раньше конца."; + } + if (!input.kppValid) return "КПП — список целых чисел через запятую."; + if (!input.opticsEnabled && !input.rsaEnabled) { + return "Включите хотя бы один блок съёмки: Оптика или РСА."; + } + if (input.opticsEnabled && !(Number(input.opticsResolution) >= 0.1)) { + return "Разрешение оптики должно быть ≥ 0.1 м."; + } + if (input.rsaEnabled && !(Number(input.rsaResolution) >= 0.1)) { + return "Разрешение РСА должно быть ≥ 0.1 м."; + } + return null; +} diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-requests/RequestDetailsPanel.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-requests/RequestDetailsPanel.tsx new file mode 100644 index 0000000..1f19094 --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/tgu-requests/RequestDetailsPanel.tsx @@ -0,0 +1,142 @@ +import type { RequestAtPoint } from "../tgu-map-2d/Tgu2DMapRequestsPanel"; + +type Props = { + requests: RequestAtPoint[]; + selectedPolygonId?: string; + deleting: boolean; + deleteError?: string; + onHover: (polygonId: string | undefined) => void; + onSelect: (polygonId: string) => void; + onDelete: (requestId: string) => void; + onClose: () => void; +}; + +export function RequestDetailsPanel({ + requests, + selectedPolygonId, + deleting, + deleteError, + onHover, + onSelect, + onDelete, + onClose +}: Props) { + const selectedEntry = selectedPolygonId + ? requests.find((r) => r.polygon.id === selectedPolygonId) + : requests.length === 1 + ? requests[0] + : undefined; + + return ( + + ); +} + +function formatDateTime(value: string): string { + return new Date(value).toLocaleString("ru-RU"); +} + +function surveyTypeLabel(surveyType: string): string { + switch (surveyType) { + case "OPTICS": + return "Оптическая"; + case "RSA": + return "Радиолокационная (РСА)"; + case "COMBINED": + return "Комбинированная"; + default: + return surveyType; + } +} diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-requests/TguRequestsTab.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-requests/TguRequestsTab.tsx new file mode 100644 index 0000000..261814e --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/tgu-requests/TguRequestsTab.tsx @@ -0,0 +1,420 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { createRequest, deleteRequest, fetchRequestsForMap } from "../../api/requestApi"; +import { fetchStations, type StationDto } from "../../api/stationsApi"; +import type { RequestMapItem } from "../../model/requestTypes"; +import type { TguPlatformUi, TimelineRange } from "../../model/timelineTypes"; +import { buildTgu2DMapScene } from "../tgu-map-2d/Tgu2DMapLayers"; +import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView"; +import type { RequestAtPoint } from "../tgu-map-2d/Tgu2DMapRequestsPanel"; +import type { Tgu2DMapSelection } from "../tgu-map-2d/model/mapSelectionTypes"; +import type { GeoPoint, MapPolygon } from "../tgu-map-2d/model/mapTypes"; +import { RequestCreatePanel, type RequestFormSubmit } from "./RequestCreatePanel"; +import { RequestDetailsPanel } from "./RequestDetailsPanel"; +import { polygonToWkt } from "./requestGeometry"; + +type TguRequestsTabProps = { + range: TimelineRange; + invalidRange: boolean; + platforms: TguPlatformUi[]; +}; + +type RequestsLoadState = { + fromValue: string; + toValue: string; + items: RequestMapItem[]; + loading: boolean; + loadedCount: number; + totalItems: number; + truncated: boolean; + error?: string; + loaded: boolean; +}; + +type PanelMode = "none" | "details" | "create"; + +function buildInitialLoadState(range: TimelineRange): RequestsLoadState { + return { + fromValue: formatDateTimeLocal(range.fromMs), + toValue: formatDateTimeLocal(range.toMs), + items: [], + loading: false, + loadedCount: 0, + totalItems: 0, + truncated: false, + loaded: false + }; +} + +export function TguRequestsTab({ range, invalidRange, platforms }: TguRequestsTabProps) { + const [requestsState, setRequestsState] = useState(() => buildInitialLoadState(range)); + const [stations, setStations] = useState([]); + const [showStations, setShowStations] = useState(true); + const [redrawToken, setRedrawToken] = useState(0); + + const [panelMode, setPanelMode] = useState("none"); + const [requestsAtPoint, setRequestsAtPoint] = useState([]); + const [selectedRequestPolygonId, setSelectedRequestPolygonId] = useState(); + const [hoveredRequestPolygonId, setHoveredRequestPolygonId] = useState(); + + const [draftPolygon, setDraftPolygon] = useState([]); + const [drawing, setDrawing] = useState(false); + const [creating, setCreating] = useState(false); + const [createError, setCreateError] = useState(); + + const [deleting, setDeleting] = useState(false); + const [deleteError, setDeleteError] = useState(); + const [notice, setNotice] = useState(); + + useEffect(() => { + fetchStations() + .then((items) => setStations(items)) + .catch(() => setStations([])); + }, []); + + const loadRequests = useCallback(async (fromValue: string, toValue: string) => { + const fromMs = new Date(fromValue).getTime(); + const toMs = new Date(toValue).getTime(); + if (!Number.isFinite(fromMs) || !Number.isFinite(toMs) || fromMs >= toMs) return; + + setRequestsState((current) => ({ ...current, loading: true, error: undefined, loadedCount: 0 })); + try { + const result = await fetchRequestsForMap( + { endFrom: new Date(fromMs).toISOString(), beginTo: new Date(toMs).toISOString() }, + (loaded, total) => setRequestsState((current) => ({ ...current, loadedCount: loaded, totalItems: total })) + ); + setRequestsState((current) => ({ + ...current, + items: result.items, + loadedCount: result.items.length, + totalItems: result.totalItems, + truncated: result.truncated, + loading: false, + loaded: true + })); + } catch (error) { + setRequestsState((current) => ({ + ...current, + loading: false, + error: error instanceof Error ? error.message : "Не удалось загрузить заявки." + })); + } + }, []); + + useEffect(() => { + setRequestsState(buildInitialLoadState(range)); + }, [range]); + + const applyRange = useCallback(() => { + void loadRequests(requestsState.fromValue, requestsState.toValue); + }, [loadRequests, requestsState.fromValue, requestsState.toValue]); + + const scene = useMemo( + () => + buildTgu2DMapScene({ + range, + platforms, + requestItems: requestsState.items, + stationItems: showStations ? stations : [] + }), + [range, platforms, requestsState.items, showStations, stations] + ); + + const layers = useMemo( + () => ({ tracks: false, swath: false, planWorks: false, stations: showStations, spacecraftMarkers: false, requests: true }), + [showStations] + ); + + const closeAll = useCallback(() => { + setRequestsAtPoint([]); + setSelectedRequestPolygonId(undefined); + setHoveredRequestPolygonId(undefined); + setDeleteError(undefined); + }, []); + + const handleRequestsAtPoint = useCallback( + (polygons: MapPolygon[]) => { + if (panelMode === "create") return; + if (polygons.length === 0) { + closeAll(); + setPanelMode("none"); + return; + } + const joined = polygons.flatMap((polygon) => { + const item = requestsState.items.find((r) => r.id === polygon.requestId); + return item ? [{ polygon, item }] : []; + }); + setRequestsAtPoint(joined); + setSelectedRequestPolygonId(joined.length === 1 ? joined[0].polygon.id : undefined); + setHoveredRequestPolygonId(undefined); + setDeleteError(undefined); + setPanelMode(joined.length > 0 ? "details" : "none"); + }, + [panelMode, requestsState.items, closeAll] + ); + + // Детали заявок открываются через onRequestsAtPoint. onObjectSelect карта + // вызывает с undefined одновременно с onRequestsAtPoint, поэтому здесь no-op, + // чтобы не затирать только что открытую панель (станции/пустой клик уже + // закрывают панель через onRequestsAtPoint([])). + const handleObjectSelect = useCallback((_selection?: Tgu2DMapSelection) => {}, []); + + const startCreate = useCallback(() => { + closeAll(); + setDraftPolygon([]); + setDrawing(true); + setCreateError(undefined); + setNotice(undefined); + setPanelMode("create"); + }, [closeAll]); + + const cancelCreate = useCallback(() => { + setDraftPolygon([]); + setDrawing(false); + setCreateError(undefined); + setPanelMode("none"); + }, []); + + const handleDrawAddPoint = useCallback( + (point: GeoPoint) => { + if (panelMode !== "create" || !drawing) return; + setDraftPolygon((current) => [...current, point]); + }, + [panelMode, drawing] + ); + + const submitCreate = useCallback( + async (form: RequestFormSubmit) => { + let geometry: string; + try { + geometry = polygonToWkt(draftPolygon); + } catch (error) { + setCreateError(error instanceof Error ? error.message : "Некорректная геометрия."); + return; + } + + setCreating(true); + setCreateError(undefined); + try { + const id = crypto.randomUUID(); + await createRequest({ + id, + name: form.name, + geometry, + importance: form.importance, + beginDateTime: new Date(form.beginDateTime).toISOString(), + endDateTime: new Date(form.endDateTime).toISOString(), + kpp: form.kpp, + highPriorityTransmit: form.highPriorityTransmit, + optics: form.optics, + rsa: form.rsa + }); + setDraftPolygon([]); + setDrawing(false); + setPanelMode("none"); + setNotice(`Заявка «${form.name}» создана.`); + void loadRequests(requestsState.fromValue, requestsState.toValue); + } catch (error) { + setCreateError(error instanceof Error ? error.message : "Не удалось создать заявку."); + } finally { + setCreating(false); + } + }, + [draftPolygon, loadRequests, requestsState.fromValue, requestsState.toValue] + ); + + const handleDelete = useCallback( + async (requestId: string) => { + setDeleting(true); + setDeleteError(undefined); + try { + await deleteRequest(requestId); + closeAll(); + setPanelMode("none"); + setNotice("Заявка удалена."); + void loadRequests(requestsState.fromValue, requestsState.toValue); + } catch (error) { + setDeleteError(error instanceof Error ? error.message : "Не удалось удалить заявку."); + } finally { + setDeleting(false); + } + }, + [closeAll, loadRequests, requestsState.fromValue, requestsState.toValue] + ); + + const fromMs = new Date(requestsState.fromValue).getTime(); + const toMs = new Date(requestsState.toValue).getTime(); + const rangeInvalid = !Number.isFinite(fromMs) || !Number.isFinite(toMs) || fromMs >= toMs; + + const hasPanel = panelMode !== "none"; + + return ( +
+
+
+
Заявки
+
+ {requestsState.loaded ? `Загружено: ${requestsState.items.length}` : "Заявки не загружены"} +
+
+
+ + +
+ + +
+ +
+ + +
+ {invalidRange &&
Некорректный интервал: from должен быть меньше to.
} + {panelMode === "create" && drawing && ( +
+ Кликайте по карте, чтобы добавить вершины полигона. Нажмите «Завершить» (≥3 вершин). +
+ )} + +
+ + {panelMode === "details" && ( + { + closeAll(); + setPanelMode("none"); + }} + /> + )} + + {panelMode === "create" && ( + setDraftPolygon((cur) => cur.slice(0, -1))} + onClear={() => setDraftPolygon([])} + onFinishDrawing={() => setDrawing(false)} + onRestartDrawing={() => { + setDraftPolygon([]); + setDrawing(true); + }} + onSubmit={submitCreate} + onCancel={cancelCreate} + /> + )} +
+
+ ); +} + +function formatDateTimeLocal(ms: number): string { + const date = new Date(ms); + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + const hours = String(date.getHours()).padStart(2, "0"); + const minutes = String(date.getMinutes()).padStart(2, "0"); + return `${year}-${month}-${day}T${hours}:${minutes}`; +} diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-requests/requestGeometry.test.ts b/services/pcp-tgu-ops-ui/src/features/tgu-requests/requestGeometry.test.ts new file mode 100644 index 0000000..4469fab --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/tgu-requests/requestGeometry.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { polygonToWkt } from "./requestGeometry"; + +describe("polygonToWkt", () => { + it("формирует WKT POLYGON с замыканием кольца", () => { + const wkt = polygonToWkt([ + { lon: 10, lat: 20 }, + { lon: 30, lat: 20 }, + { lon: 30, lat: 40 } + ]); + expect(wkt).toBe("POLYGON((10 20, 30 20, 30 40, 10 20))"); + }); + + it("не дублирует замыкающую точку, если кольцо уже замкнуто", () => { + const wkt = polygonToWkt([ + { lon: 0, lat: 0 }, + { lon: 5, lat: 0 }, + { lon: 5, lat: 5 }, + { lon: 0, lat: 0 } + ]); + expect(wkt).toBe("POLYGON((0 0, 5 0, 5 5, 0 0))"); + }); + + it("бросает ошибку при менее чем 3 вершинах", () => { + expect(() => polygonToWkt([{ lon: 0, lat: 0 }, { lon: 1, lat: 1 }])).toThrow(); + }); +}); diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-requests/requestGeometry.ts b/services/pcp-tgu-ops-ui/src/features/tgu-requests/requestGeometry.ts new file mode 100644 index 0000000..f85be6a --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/tgu-requests/requestGeometry.ts @@ -0,0 +1,27 @@ +import type { GeoPoint } from "../tgu-map-2d/model/mapTypes"; + +/** + * Преобразует набор вершин полигона в WKT POLYGON. + * Порядок координат WKT — "lon lat". Кольцо замыкается первой вершиной, + * если оно ещё не замкнуто. Требуется минимум 3 вершины. + */ +export function polygonToWkt(points: GeoPoint[]): string { + if (points.length < 3) { + throw new Error("Полигон должен содержать минимум 3 вершины."); + } + + const ring = [...points]; + const first = ring[0]; + const last = ring[ring.length - 1]; + if (first.lon !== last.lon || first.lat !== last.lat) { + ring.push(first); + } + + const coords = ring.map((p) => `${formatCoord(p.lon)} ${formatCoord(p.lat)}`).join(", "); + return `POLYGON((${coords}))`; +} + +function formatCoord(value: number): string { + // ограничиваем разрядность, убираем хвостовые нули + return Number(value.toFixed(6)).toString(); +} diff --git a/services/pcp-tgu-ops-ui/src/model/requestTypes.ts b/services/pcp-tgu-ops-ui/src/model/requestTypes.ts index 6c46c12..fb916c4 100644 --- a/services/pcp-tgu-ops-ui/src/model/requestTypes.ts +++ b/services/pcp-tgu-ops-ui/src/model/requestTypes.ts @@ -63,3 +63,44 @@ export type RequestMapItem = { createdAt: string; updatedAt: string; }; + +export type OpticsResultType = "PANCHROMATIC" | "MULTISPECTRAL" | "PANSHARPENING"; +export type RsaResultType = "SPOTLIGHT" | "STRIPMAP" | "SCANSAR"; +export type Polarisation = "HH" | "VV"; + +export type OpticsParams = { + resultType: OpticsResultType; + resolution: number; + sunAngleMin: number; + sunAngleMax: number; + clouds: number; +}; + +export type RsaParams = { + resultType: RsaResultType; + interferometry: boolean; + polarisation: Polarisation; + resolution: number; +}; + +export type CreateRequestPayload = { + id: string; + name: string; + geometry: string; + importance: number; + beginDateTime: string; + endDateTime: string; + kpp: number[]; + highPriorityTransmit: boolean; + optics?: OpticsParams; + rsa?: RsaParams; +}; + +export type CreateRequestResult = { + id: string; + name: string; + status: RequestStatus; + surveyType: SurveyType; + importance: number; + message?: string; +}; diff --git a/services/pcp-tgu-ops-ui/src/model/timelineTypes.ts b/services/pcp-tgu-ops-ui/src/model/timelineTypes.ts index cfc1137..8e19390 100644 --- a/services/pcp-tgu-ops-ui/src/model/timelineTypes.ts +++ b/services/pcp-tgu-ops-ui/src/model/timelineTypes.ts @@ -22,7 +22,7 @@ export type TimelineRange = { toMs: number; }; -export type TguActiveTab = "timeline" | "map" | "editor"; +export type TguActiveTab = "timeline" | "map" | "editor" | "requests"; export type TimelineRow = { spacecraftId: string; diff --git a/services/pcp-tgu-ops-ui/src/styles/tgu-planning.css b/services/pcp-tgu-ops-ui/src/styles/tgu-planning.css index 0e1999f..40e9ae7 100644 --- a/services/pcp-tgu-ops-ui/src/styles/tgu-planning.css +++ b/services/pcp-tgu-ops-ui/src/styles/tgu-planning.css @@ -16,7 +16,7 @@ .tgu-toolbar__row { display: flex; - align-items: end; + align-items: center; gap: 0.75rem; min-height: 4.4rem; padding: 0.75rem 1rem 0.65rem; @@ -53,8 +53,9 @@ } .tgu-field { - display: grid; - gap: 0.25rem; + display: flex; + align-items: center; + gap: 0.45rem; color: var(--text-muted); font-size: 0.72rem; } @@ -114,7 +115,6 @@ .tgu-tabs { display: inline-flex; gap: 0.15rem; - align-self: center; border: 1px solid var(--line); border-radius: 7px; padding: 0.18rem; @@ -219,6 +219,17 @@ border-right: 1px solid var(--line); } +.tgu-sidebar__legend { + border-top: 1px solid var(--line); + margin-top: auto; +} + +.tgu-sidebar__legend .tgu-legend { + flex-direction: column; + align-items: flex-start; + padding: 0.75rem 1rem; +} + .tgu-panel-heading { display: flex; justify-content: space-between; @@ -344,7 +355,6 @@ .tgu-timeline__svg { display: block; width: 100%; - min-height: 26rem; border: 1px solid var(--line); border-radius: 8px; background: #151819; @@ -832,6 +842,18 @@ stroke-width: 2; } +.tgu-2d-map__overlay circle { + fill: rgba(104, 195, 189, 0.12); + stroke: rgba(130, 222, 216, 0.9); + stroke-width: 2; +} + +.tgu-2d-map__overlay circle.is-selected { + fill: rgba(240, 173, 46, 0.15); + stroke: #f0ad2e; + stroke-width: 2.5; +} + .tgu-2d-map__tooltip { position: absolute; z-index: 8; @@ -998,6 +1020,7 @@ } .tgu-map-requests-filter .tgu-field { + display: grid; grid-template-columns: 2rem minmax(0, 1fr); align-items: center; } @@ -1059,3 +1082,266 @@ background: rgba(192, 128, 255, 0.25); border-color: rgba(192, 128, 255, 0.85); } + +/* ── Requests-at-point panel ───────────────────────────── */ + +.tgu-map-content.has-requests-panel { + grid-template-columns: 19rem minmax(0, 1fr) 20rem; +} + +.tgu-map-requests-panel { + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; + border-left: 1px solid var(--line); + background: var(--bg-panel); +} + +.tgu-map-requests-list { + display: flex; + flex: 1; + flex-direction: column; + gap: 1px; + overflow-y: auto; + background: var(--line-soft); +} + +.tgu-map-request-row { + display: flex; + gap: 0.65rem; + align-items: flex-start; + width: 100%; + padding: 0.6rem 0.9rem; + border: none; + background: var(--bg-panel); + color: var(--text); + cursor: pointer; + text-align: left; +} + +.tgu-map-request-row:hover { + background: var(--bg-hover); +} + +.tgu-map-request-row.is-selected { + background: rgba(240, 173, 46, 0.1); + outline: 1px solid rgba(240, 173, 46, 0.4); + outline-offset: -1px; +} + +.tgu-map-request-row__type { + flex-shrink: 0; + align-self: center; + width: 1rem; + height: 0.4rem; + border-radius: 3px; + border: 1.5px dashed; +} + +.tgu-map-request-row__body { + display: flex; + flex-direction: column; + gap: 0.15rem; + min-width: 0; +} + +.tgu-map-request-row__name { + overflow: hidden; + color: var(--text); + font-size: 0.82rem; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tgu-map-request-row__meta { + color: var(--text-dim); + font-size: 0.72rem; + line-height: 1.4; +} + +.tgu-map-request-details { + display: flex; + flex-direction: column; + gap: 0.5rem; + overflow-y: auto; + border-top: 1px solid var(--line); + padding: 0.75rem 0.9rem; +} + +.tgu-map-request-details .tgu-map-panel__label { + margin-bottom: 0.1rem; +} + +.tgu-map-request-details__grid { + display: grid; + grid-template-columns: auto 1fr; + gap: 0.3rem 0.8rem; + margin: 0; +} + +.tgu-map-request-details__grid dt { + color: var(--text-dim); + font-size: 0.72rem; + font-weight: 600; + white-space: nowrap; + line-height: 1.5; +} + +.tgu-map-request-details__grid dd { + overflow-wrap: anywhere; + margin: 0; + color: var(--text); + font-size: 0.8rem; + line-height: 1.5; +} + +@media (max-width: 1100px) { + .tgu-map-content.has-requests-panel { + grid-template-columns: 1fr; + } + + .tgu-map-requests-panel { + max-height: 18rem; + border-left: 0; + border-top: 1px solid var(--line); + } +} + +/* ── Вкладка «Заявки» ───────────────────────────────────── */ + +.tgu-map-content.has-side-panel { + grid-template-columns: 19rem minmax(0, 1fr) 24rem; +} + +.tgu-map-overlay--hint { + border-color: rgba(104, 195, 189, 0.5); + color: #bdeee9; +} + +/* Черновой полигон при рисовании заявки */ +.tgu-2d-map__overlay .tgu-2d-map__draft-fill { + fill: rgba(240, 173, 46, 0.16); + stroke: #f0ad2e; + stroke-width: 2; + stroke-dasharray: 6 4; +} + +.tgu-2d-map__overlay .tgu-2d-map__draft-line { + fill: none; + stroke: #f0ad2e; + stroke-width: 2; + stroke-dasharray: 6 4; +} + +.tgu-2d-map__overlay .tgu-2d-map__draft-vertex { + fill: #f0ad2e; + stroke: #1a1c1e; + stroke-width: 1.5; +} + +/* Панель-форма создания заявки */ +.tgu-requests-form__body { + display: flex; + flex: 1; + flex-direction: column; + gap: 0.2rem; + overflow-y: auto; +} + +.tgu-requests-form__section { + display: grid; + gap: 0.5rem; + padding: 0.75rem 0.9rem; + border-bottom: 1px solid var(--line-soft); +} + +.tgu-requests-field { + display: grid; + grid-template-columns: 9rem minmax(0, 1fr); + gap: 0.5rem; + align-items: center; + font-size: 0.8rem; +} + +.tgu-requests-field > span { + color: var(--text-dim); + font-size: 0.74rem; +} + +.tgu-requests-field input, +.tgu-requests-field select { + width: 100%; + min-height: 1.9rem; + border: 1px solid var(--line); + border-radius: 6px; + padding: 0.25rem 0.45rem; + background: #101214; + color: var(--text); + font-size: 0.8rem; +} + +.tgu-requests-field input:focus, +.tgu-requests-field select:focus { + outline: none; + border-color: rgba(104, 195, 189, 0.6); +} + +.tgu-requests-checkbox { + display: flex; + gap: 0.5rem; + align-items: center; + color: var(--text); + font-size: 0.8rem; +} + +.tgu-requests-checkbox input { + accent-color: var(--accent); +} + +.tgu-requests-form__block-toggle { + font-weight: 650; +} + +.tgu-requests-form__block { + display: grid; + gap: 0.5rem; + padding: 0.55rem 0.6rem; + border: 1px solid var(--line-soft); + border-radius: 7px; + background: #111416; +} + +.tgu-requests-form__actions { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; +} + +.tgu-requests-form__hint { + color: var(--text-dim); + font-size: 0.75rem; + line-height: 1.4; +} + +.tgu-requests-form__geo-status { + color: var(--text); + font-size: 0.78rem; + font-weight: 650; +} + +.tgu-requests-form__footer { + padding: 0.75rem 0.9rem; + border-top: 1px solid var(--line); +} + +.tgu-requests-form__footer .tgu-button { + width: 100%; +} + +@media (max-width: 1100px) { + .tgu-map-content.has-side-panel { + grid-template-columns: 1fr; + } +}