From 9e5460d646417ba4ee8650309272eff555a04482 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: Thu, 4 Jun 2026 13:08:08 +0300 Subject: [PATCH] =?UTF-8?q?pcp-tgu:=20=D1=80=D0=B5=D0=B4=D0=B0=D0=BA=D1=82?= =?UTF-8?q?=D0=BE=D1=80/=D0=BA=D0=B0=D1=80=D1=82=D0=B0=20=E2=80=94=20?= =?UTF-8?q?=D0=B2=D1=8B=D0=B1=D0=BE=D1=80=20=D0=BE=D0=B1=D1=8A=D0=B5=D0=BA?= =?UTF-8?q?=D1=82=D0=B0=20(=D0=BB=D0=B8=D0=BD=D0=B8=D0=B8/=D1=81=D0=B1?= =?UTF-8?q?=D1=80=D0=BE=D1=81=D1=8B)=20=D0=B8=20=D0=BF=D1=80=D0=BE=D0=B1?= =?UTF-8?q?=D1=80=D0=BE=D1=81=20=D1=82=D0=B5=D0=BA=D1=81=D1=82=D0=B0=20?= =?UTF-8?q?=D0=BE=D1=88=D0=B8=D0=B1=D0=BE=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Карта 2D: клик по линии (трасса КА / сеанс сброса) выбирает объект — общий инспектор с таймлайном; MapLine.name, тип selection "line" (kind track/drop), связь с работой через modeId; spatialIndex учитывает линии. - Редактор (TguEditorTab/EditorPanels): единый WorkInspector для клика по карте и по таймлайну, подсветка соответствующего включения, стили tgu-editor.css. - config-repo: server.error.include-message=always для pcp-tgu-service и pcp-mission-planing-service — UI показывает причину 4xx (reason/exception), а не только HTTP-фразу. --- config-repo/pcp-mission-planing-service.yaml | 6 + config-repo/pcp-tgu-service.yaml | 7 + .../src/features/tgu-editor/EditorPanels.tsx | 83 +++++++- .../src/features/tgu-editor/TguEditorTab.tsx | 166 ++++++++++++--- .../src/features/tgu-map-2d/Tgu2DMapView.tsx | 195 +++++++++++++++++- .../tgu-map-2d/geometry/spatialIndex.test.ts | 23 +++ .../tgu-map-2d/geometry/spatialIndex.ts | 9 +- .../tgu-map-2d/model/mapSelectionTypes.ts | 12 ++ .../src/features/tgu-map-2d/model/mapTypes.ts | 2 + .../pcp-tgu-ops-ui/src/styles/tgu-editor.css | 74 +++++++ 10 files changed, 542 insertions(+), 35 deletions(-) diff --git a/config-repo/pcp-mission-planing-service.yaml b/config-repo/pcp-mission-planing-service.yaml index 935d86b..0daf469 100644 --- a/config-repo/pcp-mission-planing-service.yaml +++ b/config-repo/pcp-mission-planing-service.yaml @@ -24,6 +24,12 @@ logging: level: root: INFO +# Возвращаем текст причины ошибки клиенту (reason/exception message в поле `message` тела ответа), +# иначе UI видит только HTTP-фразу и не понимает, почему сохранение съёмки/сброса отклонено. +server: + error: + include-message: always + management: endpoints: web: diff --git a/config-repo/pcp-tgu-service.yaml b/config-repo/pcp-tgu-service.yaml index 9aec2ab..f84c7b6 100644 --- a/config-repo/pcp-tgu-service.yaml +++ b/config-repo/pcp-tgu-service.yaml @@ -25,6 +25,13 @@ management: exposure: include: health,info,metrics,prometheus +# Возвращаем текст причины ошибки клиенту (reason из ResponseStatusException попадает в поле +# `message` тела ответа). Без этого UI видит только HTTP-фразу «Unprocessable Content» и не +# понимает, почему действие отклонено (напр. «план заложен — используйте fork-forward»). +server: + error: + include-message: always + springdoc: swagger-ui: path: /swagger/ui diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorPanels.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorPanels.tsx index 55f69fe..74769bc 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorPanels.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorPanels.tsx @@ -1,3 +1,4 @@ +import type { ReactNode } from "react"; import { formatDate, formatDateTime, formatDuration, formatTime, kindColor, kindLabel } from "./editorPresentation"; import { statusStyle } from "../tgu-planning/tguStatus"; import { formatLocalDateTime, parseLocal, toLocalIso } from "../../utils/localTime"; @@ -63,6 +64,8 @@ type ShootPanelProps = { routeLoading?: boolean; routeError?: string; route?: SurveyRoute; + /** Почему нельзя добавить включение в этот план (заложен/стартовал) — гасит «Добавить». */ + addBlockedReason?: string; onRouteStartChange: (startMs: number) => void; onRouteEndChange: (endMs: number) => void; /** Сдвиг окна целиком вперёд/назад (мс), длительность сохраняется. */ @@ -114,6 +117,8 @@ type DropPanelProps = { onToggleSurvey: (id: number) => void; saving?: boolean; saveError?: string; + /** Почему нельзя добавить включение в этот план (заложен/стартовал) — гасит «Добавить». */ + addBlockedReason?: string; onAdd: () => void; onClose: () => void; }; @@ -287,6 +292,7 @@ export function ShootPanel({ routeLoading, routeError, route, + addBlockedReason, onRouteStartChange, onRouteEndChange, onRouteShift, @@ -436,7 +442,14 @@ export function ShootPanel({ )} - @@ -499,10 +512,11 @@ export function DropPanel({ onToggleSurvey, saving, saveError, + addBlockedReason, onAdd, onClose }: DropPanelProps) { - const canAdd = !!selectedZone && selectedSurveyIds.size > 0 && !saving; + const canAdd = !!selectedZone && selectedSurveyIds.size > 0 && !saving && !addBlockedReason; return (
@@ -572,6 +586,7 @@ export function DropPanel({ )} + {addBlockedReason &&
{addBlockedReason}
} {saveError &&
{saveError}
} + {open &&
{renderDetail(hit)}
} +
+ ); + })} + + + ); +} + +/** Класс цветовой метки объекта в списке выбора. */ +function selectionSwatch(selection: Tgu2DMapSelection): string { + if (selection.type === "marker") return "station"; + if (selection.type === "line") return "drop"; + return selection.layer; +} + +/** Подпись типа объекта в списке выбора. */ +function selectionSubtitle(selection: Tgu2DMapSelection): string { + if (selection.type === "marker") { + return selection.layer === "stations" ? "Станция" : "Маркер КА"; + } + if (selection.type === "line") { + return selection.kind === "drop" ? "Сброс на НКПОР" : "Трасса КА"; + } + switch (selection.layer) { + case "requests": return "Заявка на съёмку"; + case "planWorks": return "Маршрут съёмки"; + case "swath": return "Полоса обзора"; + case "tracks": return "Трасса КА"; + default: return "Объект карты"; + } +} + function mapInfoTitle(selection: Tgu2DMapSelection): string { if (selection.type === "marker") { return selection.layer === "stations" ? "Станция" : "Маркер КА"; 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 d9ab3f3..1426371 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 @@ -37,7 +37,7 @@ import { } from "./editorDraft"; import { checkVisibility, conflictCount, mergeConflictMaps, overlaps, type EditorConflictMap } from "./editorConflicts"; import { DEFAULT_EDITOR_MODES, formatDuration } from "./editorPresentation"; -import { DropPanel, type DropCandidateSurvey, MapInfoPanel, ShootPanel, WorkInspector } from "./EditorPanels"; +import { DropPanel, type DropCandidateSurvey, MapInfoPanel, MapPickPanel, ShootPanel, WorkInspector } from "./EditorPanels"; import { MapPassSelector } from "./MapPassSelector"; import { EditorRail } from "./EditorRail"; import { EditorTimeline } from "./EditorTimeline"; @@ -55,7 +55,7 @@ import type { TguEditorTabProps } from "./model/editorTypes"; -type EditorRightMode = "none" | "inspect" | "shoot" | "downlink" | "mapInfo"; +type EditorRightMode = "none" | "inspect" | "shoot" | "downlink" | "mapInfo" | "mapPick"; const HOUR_MS = 60 * 60 * 1000; /** Фиксированная длительность окна съёмки, строится вокруг момента видимости. */ @@ -107,6 +107,9 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, const [dropSaving, setDropSaving] = useState(false); const [dropError, setDropError] = useState(); const [mapInfoSelection, setMapInfoSelection] = useState(); + // Несколько объектов под точкой клика на карте: список для правой панели + раскрытый элемент. + const [mapPickHits, setMapPickHits] = useState([]); + const [mapPickActiveId, setMapPickActiveId] = useState(); const [stationDtos, setStationDtos] = useState([]); const [serviceConflicts, setServiceConflicts] = useState({}); const [checking, setChecking] = useState(false); @@ -240,6 +243,18 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, const planSurveyCount = useMemo(() => planModes.filter((mode) => mode.type === "SURVEY").length, [planModes]); const planDownlinkCount = useMemo(() => planModes.filter((mode) => mode.type === "DROP").length, [planModes]); + // Первая правка живого плана клонирует его в DRAFT (replace). Бэк это запрещает (422) для + // заложенного (LAID_IN) или уже стартовавшего плана — нужен fork-forward со вкладки «Планы». + // Предупреждаем заранее и гасим «Добавить», чтобы не доводить до ошибки сохранения. Если + // черновик уже создан (draftPlanId) или сам план — DRAFT, правки идут напрямую и блок не нужен. + const inPlaceEditBlockedReason = useMemo(() => { + if (!selectedPlan || draftPlanId || selectedPlan.status === "DRAFT") return undefined; + const blocked = selectedPlan.status === "LAID_IN" || selectedPlan.startMs <= Date.now(); + return blocked + ? "План заложен или уже стартовал — править его на месте нельзя. Создайте исправленный план через fork-forward на вкладке «Планы»." + : undefined; + }, [selectedPlan, draftPlanId]); + useEffect(() => { setHistory({ draft: seedDraft(selectedPlan, selectedSpacecraftId), past: [], future: [] }); setWindow(defaultEditorWindow(selectedPlan, appliedRange)); @@ -254,6 +269,8 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, setDropError(undefined); setRvaZones([]); setMapInfoSelection(undefined); + setMapPickHits([]); + setMapPickActiveId(undefined); setServiceConflicts({}); setNotice(undefined); setApplyError(undefined); @@ -747,20 +764,81 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, }); }; + // Включение (съёмка/сброс), соответствующее объекту карты: контур маршрута (planWorks) или + // линия сброса (drop) → работа таймлайна по modeId ↔ planModeId. Прочие объекты → undefined. + const workForSelection = useCallback( + (selection: Tgu2DMapSelection): EditorWork | undefined => { + const modeId = + selection.type === "polygon" && selection.layer === "planWorks" + ? selection.modeId + : selection.type === "line" && selection.kind === "drop" + ? selection.modeId + : undefined; + if (modeId == null) return undefined; + return displayWorks.find((w) => w.planModeId === modeId); + }, + [displayWorks] + ); + + const clearMapPick = useCallback(() => { + setMapPickHits([]); + setMapPickActiveId(undefined); + }, []); + const handleMapObjectSelect = useCallback((selection?: Tgu2DMapSelection) => { if (rightMode === "shoot") return; + clearMapPick(); if (!selection) { - if (rightMode === "mapInfo") { + if (rightMode === "mapInfo" || rightMode === "mapPick") { setRightMode("none"); setMapInfoSelection(undefined); } return; } + // Клик по маршруту съёмки (контур) или сбросу (линия) на карте = клик по включению на + // таймлайне: открываем тот же инспектор включения (одинаковая информация в обе стороны), + // а не краткую карточку объекта. + const work = workForSelection(selection); + if (work) { + setMapInfoSelection(undefined); + setSelectedWorkId(work.id); + setRightMode("inspect"); + setNotice(undefined); + return; + } setMapInfoSelection(selection); setRightMode("mapInfo"); setSelectedWorkId(undefined); + }, [rightMode, clearMapPick, workForSelection]); + + // Несколько объектов под точкой клика — показываем их списком в правой панели (оператор + // раскрывает нужный, чтобы увидеть его данные). Здесь только наполняем список. + const handleObjectsAtPoint = useCallback((selections: Tgu2DMapSelection[]) => { + if (rightMode === "shoot") return; + setMapPickHits(selections); + setMapPickActiveId(undefined); + setRightMode("mapPick"); + setSelectedWorkId(undefined); + setMapInfoSelection(undefined); + setNotice(undefined); + setMapFocus((prev) => ({ ...prev, objectId: undefined })); }, [rightMode]); + // Раскрытие/сворачивание элемента списка: подсвечиваем объект на карте (без перецентрирования) + // и выделяем соответствующее включение на таймлайне. + const togglePickItem = useCallback((selection: Tgu2DMapSelection) => { + setMapPickActiveId((current) => { + if (current === selection.id) { + setSelectedWorkId(undefined); + setMapFocus((prev) => ({ ...prev, objectId: undefined })); + return undefined; + } + setSelectedWorkId(workForSelection(selection)?.id); + setMapFocus((prev) => ({ ...prev, objectId: selection.id })); + return selection.id; + }); + }, [workForSelection]); + const handleResizerDown = useCallback((e: React.MouseEvent) => { e.preventDefault(); const startY = e.clientY; @@ -782,6 +860,41 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, document.addEventListener("mouseup", onUp); }, [mapRatio]); + // Инспектор включения — общий и для одиночного выбора, и для раскрытого элемента в списке выбора. + const renderWorkInspector = (work: EditorWork, onClose: () => void) => ( + { void handleDeleteWork(workId); }} + onDuplicate={(workId) => { + const id = nextWorkId(idCounterRef); + changeDraft((current) => duplicateWork(current, workId, id)); + setSelectedWorkId(id); + }} + onClose={onClose} + /> + ); + + // Детальная карточка объекта из списка выбора: включение (съёмка/сброс) → инспектор, + // прочее (заявка/станция) → карточка объекта карты. + const renderPickDetail = (selection: Tgu2DMapSelection) => { + const work = workForSelection(selection); + if (work) return renderWorkInspector(work, () => togglePickItem(selection)); + return ( + togglePickItem(selection)} + /> + ); + }; + if (!selectedSpacecraftId && !selectedPlan) { return (
@@ -810,6 +923,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, setRightMode(mode); setSelectedWorkId(undefined); setMapInfoSelection(undefined); + clearMapPick(); setNotice(undefined); }} onUndo={() => setHistory((current) => undo(current))} @@ -868,6 +982,8 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, scene={mapScene} layers={EDITOR_MAP_LAYERS} redrawToken={0} + enableObjectChooser + onObjectsAtPoint={handleObjectsAtPoint} pickMode={rightMode === "shoot"} pickTarget={target ?? undefined} onPickPoint={(point) => { @@ -919,28 +1035,11 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, {rightMode !== "none" && ( )} @@ -1216,6 +1331,7 @@ function buildEditorMapScene( if (pts.length < 2) continue; lines.push({ id: `drop:${mode.id}`, + name: `Сброс · виток ${mode.revolution}`, layer: "tracks", kind: "drop", modeId: mode.id, 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 2c58958..69d9515 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 @@ -6,10 +6,10 @@ import { drawStations } from "./canvas/drawStations"; import { drawSwath } from "./canvas/drawSwath"; import { drawTiles } from "./canvas/drawTiles"; import { drawTracks } from "./canvas/drawTracks"; -import { createMapProjection } from "./geometry/mapProjection"; +import { createMapProjection, normalizeLon } from "./geometry/mapProjection"; import { createPolygonSpatialIndex, hitTestAllPolygons, hitTestPolygons, queryPolygonsByBBox } from "./geometry/spatialIndex"; import type { Tgu2DMapLayersState } from "./model/mapLayerTypes"; -import type { GeoPoint, MapMarker, MapPolygon, MapSize, MapViewState, ScreenPoint, Tgu2DMapScene } from "./model/mapTypes"; +import type { GeoPoint, MapLine, MapMarker, MapPolygon, MapSize, MapViewState, ScreenPoint, Tgu2DMapScene } from "./model/mapTypes"; import type { Tgu2DMapSelection } from "./model/mapSelectionTypes"; type Tgu2DMapViewProps = { @@ -25,6 +25,14 @@ type Tgu2DMapViewProps = { /** Точка, на которую центрировать карту; перецентрирование срабатывает при смене focusToken. */ focusPoint?: GeoPoint; focusToken?: number; + /** + * Включает выбор объекта под курсором: если в точке клика лежит несколько объектов + * (например, заявка и маршрут съёмки), их список отдаётся через onObjectsAtPoint вместо + * «молчаливого» выбора верхнего по z-индексу. Также делает кликабельными линии сброса. + */ + enableObjectChooser?: boolean; + /** Несколько объектов под точкой клика — список передаётся наверх (выбор делает оператор справа). */ + onObjectsAtPoint?: (selections: Tgu2DMapSelection[]) => void; pickMode?: boolean; onPickPoint?: (point: { lat: number; lon: number }) => void; targets?: Tgu2DMapTarget[]; @@ -76,6 +84,8 @@ export function Tgu2DMapView({ selectedObjectId, focusPoint, focusToken, + enableObjectChooser = false, + onObjectsAtPoint, pickMode = false, onPickPoint, targets = [], @@ -98,7 +108,10 @@ export function Tgu2DMapView({ const [tileVersion, setTileVersion] = useState(0); const handleTileLoad = useCallback(() => setTileVersion((v) => v + 1), []); const [tooltip, setTooltip] = useState(); + // Гео-координаты под курсором — показываем по центру внизу карты. + const [cursorGeo, setCursorGeo] = useState(); const selectedPolygonRef = useRef(undefined); + const selectedLineRef = useRef(undefined); const activePolygons = useMemo( () => scene.polygons.filter((polygon) => layers[polygon.layer]), [scene.polygons, layers] @@ -210,6 +223,7 @@ export function Tgu2DMapView({ const updateHover = useCallback( (event: React.PointerEvent) => { const screenPoint = screenPointFromEvent(event); + setCursorGeo(projection.unproject(screenPoint)); const marker = hitTestMarkers(activeMarkers, screenPoint, projection); const polygon = marker ? undefined : hitTestPolygons(polygonIndex, projection.unproject(screenPoint)); @@ -254,6 +268,7 @@ export function Tgu2DMapView({ } const screenPoint = screenPointFromEvent(event); + setCursorGeo(projection.unproject(screenPoint)); const dx = screenPoint.x - drag.startX; const dy = screenPoint.y - drag.startY; if (!hasDraggedRef.current && Math.sqrt(dx * dx + dy * dy) > DRAG_THRESHOLD) { @@ -280,6 +295,56 @@ export function Tgu2DMapView({ wrapperRef.current?.releasePointerCapture(event.pointerId); }; + // Применить выбор конкретного объекта (из меню выбора либо при единственном попадании): + // выставляем внутреннюю подсветку и сообщаем наверх через onObjectSelect. + const applyEditorHit = useCallback((hit: EditorHit) => { + selectedPolygonRef.current = hit.kind === "polygon" ? hit.polygon : undefined; + selectedMarkerRef.current = hit.kind === "marker" ? hit.marker : undefined; + selectedLineRef.current = hit.kind === "line" ? hit.line : undefined; + onObjectSelect(hit.selection); + }, [onObjectSelect]); + + // Клик в редакторе: собираем ВСЕ интерактивные объекты под точкой (заявки, маршруты съёмки, + // сбросы, станции). 0 → возможно полоса обзора под низом; 1 → выбираем сразу; + // ≥2 → отдаём список наверх (оператор выбирает нужный в правой панели). + const handleEditorClick = useCallback((screenPoint: ScreenPoint) => { + const geoPoint = projection.unproject(screenPoint); + const hits: EditorHit[] = []; + + const marker = hitTestMarkers(activeMarkers, screenPoint, projection); + if (marker) hits.push({ kind: "marker", marker, selection: markerToSelection(marker) }); + + for (const polygon of hitTestAllPolygons(polygonIndex, geoPoint)) { + if (polygon.layer !== "requests" && polygon.layer !== "planWorks") continue; + hits.push({ kind: "polygon", polygon, selection: polygonToSelection(polygon) }); + } + + const dropLines = scene.lines.filter((line) => line.kind === "drop" && layers[line.layer]); + for (const line of hitTestLines(dropLines, screenPoint, projection)) { + hits.push({ kind: "line", line, selection: lineToSelection(line) }); + } + + if (hits.length >= 2 && onObjectsAtPoint) { + // Несколько объектов — внутреннюю подсветку снимаем (её ведёт правая панель через selectedObjectId). + selectedPolygonRef.current = undefined; + selectedMarkerRef.current = undefined; + selectedLineRef.current = undefined; + onObjectsAtPoint(hits.map((hit) => hit.selection)); + return; + } + if (hits.length >= 1) { + applyEditorHit(hits[0]); + return; + } + + // Ничего «интерактивного» — выбираем верхний прочий полигон (напр. полосу обзора) либо снимаем выбор. + const fallback = hitTestPolygons(polygonIndex, geoPoint); + selectedPolygonRef.current = fallback; + selectedMarkerRef.current = undefined; + selectedLineRef.current = undefined; + onObjectSelect(fallback ? polygonToSelection(fallback) : undefined); + }, [activeMarkers, applyEditorHit, layers, onObjectSelect, onObjectsAtPoint, polygonIndex, projection, scene.lines]); + const onClick = (event: React.MouseEvent) => { if (hasDraggedRef.current) return; const screenPoint = screenPointFromEvent(event); @@ -293,6 +358,11 @@ export function Tgu2DMapView({ return; } + if (enableObjectChooser) { + handleEditorClick(screenPoint); + return; + } + const marker = hitTestMarkers(activeMarkers, screenPoint, projection); if (marker) { selectedMarkerRef.current = marker; @@ -343,7 +413,8 @@ export function Tgu2DMapView({ spacecraftId: polygon.spacecraftId, requestId: polygon.requestId, surveyType: polygon.surveyType, - status: polygon.status + status: polygon.status, + modeId: polygon.modeId }); }; @@ -374,6 +445,8 @@ export function Tgu2DMapView({ () => (selectedObjectId ? scene.lines.find((line) => line.id === selectedObjectId) : undefined), [selectedObjectId, scene.lines] ); + // Линии под подсветкой: выбранная кликом на карте (внутренняя) и заданная извне (по selectedObjectId). + const overlayLines = uniqueLines([selectedLineRef.current, externalSelectedLine]); const externalHoveredPolygon = useMemo( () => hoveredRequestPolygonId ? activePolygons.find((p) => p.id === hoveredRequestPolygonId) : undefined, [hoveredRequestPolygonId, activePolygons] @@ -395,6 +468,7 @@ export function Tgu2DMapView({ hoverRef.current = undefined; hoverMarkerRef.current = undefined; setTooltip(undefined); + setCursorGeo(undefined); }} onWheel={onWheel} > @@ -422,8 +496,9 @@ export function Tgu2DMapView({ ); }); })} - {externalSelectedLine && externalSelectedLine.points.length >= 2 && (() => { - const pts = unwrapX(externalSelectedLine.points.map((point) => projection.project(point)), projection.worldSize); + {overlayLines.flatMap((line) => { + if (line.points.length < 2) return []; + const pts = unwrapX(line.points.map((point) => projection.project(point)), projection.worldSize); return ([0, projection.worldSize, -projection.worldSize] as const).flatMap((dx) => { const visible = pts.some((p) => { const x = p.x + dx; @@ -432,14 +507,14 @@ export function Tgu2DMapView({ if (!visible) return []; return ( `${p.x + dx},${p.y}`).join(" ")} className="is-selected" fill="none" /> ); }); - })()} + })} {overlayMarkers.map((marker) => { const pt = projection.project(marker.point); const cls = marker.id === selectedMarker?.id ? "is-selected" : "is-hovered"; @@ -496,6 +571,7 @@ export function Tgu2DMapView({ {tooltip.subtitle} )} + {cursorGeo &&
{formatLatLon(cursorGeo)}
}
© OpenStreetMap contributors · © CARTO
@@ -506,6 +582,15 @@ export function Tgu2DMapView({ ); } +/** Подпись координат под курсором: широта/долгота с полушариями. */ +function formatLatLon(point: GeoPoint): string { + const lat = point.lat; + const lon = normalizeLon(point.lon); + const latStr = `${Math.abs(lat).toFixed(4)}° ${lat >= 0 ? "с.ш." : "ю.ш."}`; + const lonStr = `${Math.abs(lon).toFixed(4)}° ${lon >= 0 ? "в.д." : "з.д."}`; + return `${latStr}, ${lonStr}`; +} + function layerTitle(layer: MapPolygon["layer"]): string { switch (layer) { case "planWorks": @@ -573,3 +658,99 @@ function uniquePolygons(polygons: Array): MapPolygon[] { } return result; } + +function uniqueLines(lines: Array): MapLine[] { + const result: MapLine[] = []; + const ids = new Set(); + for (const line of lines) { + if (!line || ids.has(line.id)) continue; + ids.add(line.id); + result.push(line); + } + return result; +} + +/** Один объект-кандидат под курсором: несёт исходный объект (для подсветки) и его выделение. */ +type EditorHit = + | { kind: "marker"; marker: MapMarker; selection: Tgu2DMapSelection } + | { kind: "polygon"; polygon: MapPolygon; selection: Tgu2DMapSelection } + | { kind: "line"; line: MapLine; selection: Tgu2DMapSelection }; + +function markerToSelection(marker: MapMarker): Tgu2DMapSelection { + return { + 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 + }; +} + +function polygonToSelection(polygon: MapPolygon): Tgu2DMapSelection { + return { + type: "polygon", + id: polygon.id, + name: polygon.name, + layer: polygon.layer, + planId: polygon.planId, + spacecraftId: polygon.spacecraftId, + requestId: polygon.requestId, + surveyType: polygon.surveyType, + status: polygon.status, + modeId: polygon.modeId + }; +} + +function lineToSelection(line: MapLine): Tgu2DMapSelection { + return { + type: "line", + id: line.id, + name: line.name ?? "Сброс", + layer: line.layer, + kind: line.kind, + modeId: line.modeId + }; +} + +/** Линии, проходящие в пределах допуска (px) от точки клика, отсортированные по близости. */ +function hitTestLines( + lines: MapLine[], + screenPoint: ScreenPoint, + projection: ReturnType, + tolerance = 7 +): MapLine[] { + const worldSize = projection.worldSize; + const hits: Array<{ line: MapLine; dist: number }> = []; + for (const line of lines) { + if (line.points.length < 2) continue; + const pts = unwrapX(line.points.map((point) => projection.project(point)), worldSize); + let best = Number.POSITIVE_INFINITY; + for (const dx of [0, worldSize, -worldSize]) { + for (let i = 1; i < pts.length; i++) { + const d = distToSegment( + screenPoint, + { x: pts[i - 1].x + dx, y: pts[i - 1].y }, + { x: pts[i].x + dx, y: pts[i].y } + ); + if (d < best) best = d; + } + } + if (best <= tolerance) hits.push({ line, dist: best }); + } + return hits.sort((a, b) => a.dist - b.dist).map((h) => h.line); +} + +function distToSegment(p: ScreenPoint, a: ScreenPoint, b: ScreenPoint): number { + const dx = b.x - a.x; + const dy = b.y - a.y; + const lenSq = dx * dx + dy * dy; + if (lenSq === 0) return Math.hypot(p.x - a.x, p.y - a.y); + let t = ((p.x - a.x) * dx + (p.y - a.y) * dy) / lenSq; + t = Math.max(0, Math.min(1, t)); + return Math.hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy)); +} diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/geometry/spatialIndex.test.ts b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/geometry/spatialIndex.test.ts index 6f2d1b2..4c2b25e 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/geometry/spatialIndex.test.ts +++ b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/geometry/spatialIndex.test.ts @@ -45,5 +45,28 @@ describe("polygon spatial index", () => { expect(queryPolygonsByBBox(index, { minLon: 11, minLat: 11, maxLon: 12, maxLat: 12 }).map((polygon) => polygon.id)) .toEqual(["top"]); }); + + it("matches polygons whose longitudes are in a different 360° window (western hemisphere)", () => { + // Контур в долготах 0..360: западное полушарие ~234° вместо −126°. + const western: MapPolygon = { + id: "western", + name: "Western survey route", + kind: "planWork", + layer: "planWorks", + zIndex: 1, + points: [ + { lon: 233, lat: 48 }, + { lon: 235, lat: 48 }, + { lon: 235, lat: 50 }, + { lon: 233, lat: 50 } + ] + }; + const index = createPolygonSpatialIndex([western]); + + // Видовой bbox в нормализованных долготах [−128, −124] — те же координаты, что и контур, со сдвигом −360°. + expect( + queryPolygonsByBBox(index, { minLon: -128, minLat: 47, maxLon: -124, maxLat: 51 }).map((polygon) => polygon.id) + ).toEqual(["western"]); + }); }); 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 6cb11e0..31463fb 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 @@ -70,8 +70,15 @@ export function hitTestPolygons(index: PolygonSpatialIndex, point: GeoPoint): Ma export function queryPolygonsByBBox(index: PolygonSpatialIndex, bbox: BBox): MapPolygon[] { return index.entries - .filter((entry) => entry.bbox.minLon <= bbox.maxLon && entry.bbox.maxLon >= bbox.minLon) .filter((entry) => entry.bbox.minLat <= bbox.maxLat && entry.bbox.maxLat >= bbox.minLat) + // Долготу сверяем с учётом ±360°: контуры из бэкенда могут приходить в долготах 0..360 + // (западное полушарие как ~234° вместо −126°). Без этого они выпадают из набора отрисовки, + // хотя остаются кликабельными (hit-test уже учитывает сдвиги), — контур «пропадает» на карте. + .filter((entry) => + [0, 360, -360].some( + (shift) => entry.bbox.minLon + shift <= bbox.maxLon && entry.bbox.maxLon + shift >= bbox.minLon + ) + ) .map((entry) => entry.polygon); } 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 bc67087..89f14d3 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 @@ -11,6 +11,18 @@ export type Tgu2DMapSelection = requestId?: string; surveyType?: string; status?: TguPlanStatus; + /** id режима плана (включения) для polygon-ов слоя planWorks — связь с работой таймлайна. */ + modeId?: number; + } + | { + type: "line"; + id: string; + name: string; + layer: "tracks"; + /** Вид линии: трасса КА или сеанс сброса. */ + kind?: "track" | "drop"; + /** id режима плана (drop) — связь с работой таймлайна. */ + modeId?: number; } | { type: "marker"; 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 b885ddd..e7981e1 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 @@ -39,6 +39,8 @@ export type MapPolygon = { export type MapLine = { id: string; + /** Человекочитаемое имя (для подсказок и выбора объекта под курсором), напр. «Сброс · виток N». */ + name?: string; layer: "tracks"; points: GeoPoint[]; spacecraftId?: string; diff --git a/services/pcp-tgu-ops-ui/src/styles/tgu-editor.css b/services/pcp-tgu-ops-ui/src/styles/tgu-editor.css index 680ec9a..bf875db 100644 --- a/services/pcp-tgu-ops-ui/src/styles/tgu-editor.css +++ b/services/pcp-tgu-ops-ui/src/styles/tgu-editor.css @@ -1094,3 +1094,77 @@ max-height: 24rem; } } + +/* Список объектов под точкой клика на карте (раскрывающиеся карточки). */ +.tgu-editor-pick { + display: grid; + gap: 0.35rem; +} + +.tgu-editor-pick__row { + display: grid; + grid-template-columns: auto 1fr auto; + grid-template-rows: auto auto; + column-gap: 0.5rem; + align-items: center; + width: 100%; + border: 1px solid var(--line); + border-radius: 6px; + padding: 0.45rem 0.55rem; + background: var(--bg-panel-2); + color: var(--text); + text-align: left; + cursor: pointer; +} + +.tgu-editor-pick__row:hover { + border-color: rgba(104, 195, 189, 0.45); +} + +.tgu-editor-pick__item.is-open .tgu-editor-pick__row { + border-color: rgba(104, 195, 189, 0.58); + background: rgba(104, 195, 189, 0.14); +} + +.tgu-editor-pick__swatch { + grid-row: 1 / span 2; + width: 10px; + height: 10px; + border-radius: 2px; + background: var(--text-dim); +} + +.tgu-editor-pick__swatch--requests { background: #ffa040; } +.tgu-editor-pick__swatch--planWorks { background: #4fd2b0; } +.tgu-editor-pick__swatch--drop { background: #ffcf3f; } +.tgu-editor-pick__swatch--station { background: #d5e2e2; } +.tgu-editor-pick__swatch--swath { background: #6fb7ff; } + +.tgu-editor-pick__title { + font-size: 0.74rem; + font-weight: 600; +} + +.tgu-editor-pick__sub { + grid-column: 2; + color: var(--text-dim); + font-size: 0.62rem; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.tgu-editor-pick__chevron { + grid-row: 1 / span 2; + color: var(--text-dim); + font-size: 0.7rem; +} + +/* Вложенная карточка детали внутри раскрытого элемента — без двойной рамки/отступов. */ +.tgu-editor-pick__detail { + padding: 0.35rem 0 0.1rem; +} + +.tgu-editor-pick__detail .tgu-editor-panel { + border: 0; + padding: 0; +}