From bdbc874f419f4254be320d433055abd0f0ba6f04 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: Wed, 10 Jun 2026 15:47:18 +0300 Subject: [PATCH] =?UTF-8?q?feat(tgu-ui):=20=D1=82=D0=B5=D0=BF=D0=BB=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D1=8F=20=D0=BA=D0=B0=D1=80=D1=82=D0=B0=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B8=D0=BE=D1=80=D0=B8=D1=82=D0=B5=D1=82=D0=BE=D0=B2=20?= =?UTF-8?q?=D0=B8=20=D0=B8=D0=BD=D1=81=D0=BF=D0=B5=D0=BA=D1=82=D0=BE=D1=80?= =?UTF-8?q?=20=D0=BE=D0=B1=D1=8A=D0=B5=D0=BA=D1=82=D0=BE=D0=B2=20=D0=BD?= =?UTF-8?q?=D0=B0=20=D0=B2=D0=BA=D0=BB=D0=B0=D0=B4=D0=BA=D0=B5=20=C2=AB?= =?UTF-8?q?=D0=9A=D0=BE=D0=BC=D0=BF=D0=BB=D0=B5=D0=BA=D1=81=D0=BD=D1=8B?= =?UTF-8?q?=D0=B9=20=D0=BF=D0=BB=D0=B0=D0=BD=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Чекбокс «Тепловая карта» — фоновый слой-заливка ячеек сетки заданий по важности (cool→hot), источник GET /api/pcp-request/v1/cells/priority-map (cellsApi + drawHeatmap). Heatmap — полноценный слой MapPolygon (layer "heatmap", importance/cellNum), участвует в spatial-индексе и chooser, рисуется под контурами плана/заявок. Клик по объекту открывает правый инспектор ComplexInfoPanel (по аналогии с редактором): карточки ячейки приоритета, включения комплексного плана и заявки; при наложении — список объектов под точкой клика с раскрытием. При включённой тепловой карте ячейка всегда идёт первой в списке и раскрыта по умолчанию. Бэклог #44 отмечен сделанным. --- docs/бэклог-pcp-tgu-ops-ui.md | 1 + .../pcp-tgu-ui-service/src/api/cellsApi.ts | 85 ++++++ .../tgu-complex-plan/ComplexInfoPanel.tsx | 242 ++++++++++++++++++ .../tgu-complex-plan/TguComplexPlanTab.tsx | 161 +++++++++++- .../src/features/tgu-editor/TguEditorTab.tsx | 1 + .../src/features/tgu-map-2d/Tgu2DMapTab.tsx | 2 + .../src/features/tgu-map-2d/Tgu2DMapView.tsx | 19 +- .../features/tgu-map-2d/canvas/drawHeatmap.ts | 86 +++++++ .../tgu-map-2d/model/mapLayerTypes.ts | 2 + .../tgu-map-2d/model/mapSelectionTypes.ts | 6 +- .../src/features/tgu-map-2d/model/mapTypes.ts | 8 +- .../tgu-map-2d/tgu2DMapInterval.test.ts | 1 + .../features/tgu-requests/TguRequestsTab.tsx | 2 +- .../src/styles/tgu-editor.css | 2 + .../src/styles/tgu-planning.css | 23 ++ 15 files changed, 623 insertions(+), 18 deletions(-) create mode 100644 services/pcp-tgu-ui-service/src/api/cellsApi.ts create mode 100644 services/pcp-tgu-ui-service/src/features/tgu-complex-plan/ComplexInfoPanel.tsx create mode 100644 services/pcp-tgu-ui-service/src/features/tgu-map-2d/canvas/drawHeatmap.ts diff --git a/docs/бэклог-pcp-tgu-ops-ui.md b/docs/бэклог-pcp-tgu-ops-ui.md index 9fcaf4f..f256e83 100644 --- a/docs/бэклог-pcp-tgu-ops-ui.md +++ b/docs/бэклог-pcp-tgu-ops-ui.md @@ -34,6 +34,7 @@ _Обновлено: 2026-06-10_ ### Комплексный план - [ ] **#6** — Пересчитывать комплексный план при бронировании новых слотов или при поступлении новой заявки. +- [x] **#44** — Добавить на вкладку «Комплексный план» просмотр тепловой карты (приоритеты/покрытие ячеек). Источник — `priority-map` сервиса заданий (importance ячеек) и/или статистика покрытия прогона; отрисовать как heatmap-слой поверх карты. _Сделано: чекбокс «Тепловая карта» в тулбаре; фоновый слой-заливка ячеек по важности (cool→hot) под контурами плана/заявок; источник `GET /api/pcp-request/v1/cells/priority-map`, не интерактивен (в spatial-индекс не попадает). `cellsApi.ts` + `drawHeatmap.ts` + слой `heatmap` в `Tgu2DMapView`. Клик по объекту (ячейка/включение/заявка) показывает его данные в правом инспекторе `ComplexInfoPanel` (по аналогии с редактором; при наложении — список под точкой клика с раскрытием)._ ### Бэкенд / инфраструктура - [ ] **#7** — Перевести сервис станций на ordinis (по примеру получения аппаратов в сервисе tgu). diff --git a/services/pcp-tgu-ui-service/src/api/cellsApi.ts b/services/pcp-tgu-ui-service/src/api/cellsApi.ts new file mode 100644 index 0000000..e6818f7 --- /dev/null +++ b/services/pcp-tgu-ui-service/src/api/cellsApi.ts @@ -0,0 +1,85 @@ +import { TguApiError } from '../model/tguTypes' +import { apiFetch } from './apiFetch' + +/** + * Карта приоритетов ячеек сетки заданий (pcp-request-service, GET /v1/cells/priority-map). + * Это тот же источник, по которому комплексный план считает покрытие заявок — отдаёт только + * ячейки, на которые спроецированы заявки (с их важностью и контуром), а не весь каталог сетки. + * Используется на вкладке «Комплексный план» для тепловой карты приоритетов. + */ +const BASE = '/api/pcp-request/v1' + +/** + * Минимальная важность ячейки, ниже которой ячейку не возвращаем — тот же порог, что и в + * комплексном сервисе (REQUEST_GRID_MIN_IMPORTANCE), чтобы тепловая карта совпадала с тем, + * что реально участвует в расчёте. + */ +export const HEATMAP_MIN_IMPORTANCE = 0.001 + +/** Фрагмент заявки внутри ячейки сетки приоритетов. */ +export type PriorityCellRequest = { + requestId: string + /** Контур фрагмента заявки внутри ячейки, WKT POLYGON. */ + contour: string + /** Процент покрытия ячейки фрагментом заявки. */ + coveragePercent: number + /** Snapshot важности заявки из request_cells. */ + importance: number +} + +/** Ячейка сетки приоритетов: центр, важность, контур (WKT POLYGON) и фрагменты заявок. */ +export type PriorityCell = { + cellNum: number + latitude: number + longitude: number + importance: number + /** Контур ячейки, WKT POLYGON (lon lat, ...). */ + contour: string + /** Фрагменты заявок, пересекающие ячейку. */ + requests: PriorityCellRequest[] +} + +/** + * Загрузить карту приоритетов ячеек (ячейки с важностью и контуром) для тепловой карты. + * @param minImportance Порог важности (по умолчанию {@link HEATMAP_MIN_IMPORTANCE}). + * + * @returns Список ячеек сетки с важностью и контурами. + */ +export async function fetchPriorityCells( + minImportance: number = HEATMAP_MIN_IMPORTANCE, +): Promise { + const params = new URLSearchParams({ minImportance: String(minImportance) }) + const url = `${BASE}/cells/priority-map?${params}` + let response: Response + try { + response = await apiFetch(url, { method: 'GET', headers: { Accept: 'application/json' } }) + } catch { + throw new TguApiError('Не удалось подключиться к сервису заданий.', 'network') + } + const text = await response.text() + if (!response.ok) { + throw new TguApiError( + extractErrorText(text) || 'Не удалось загрузить карту приоритетов.', + 'http', + response.status, + ) + } + if (!text.trim()) return [] + const parsed = JSON.parse(text) as { items?: PriorityCell[] } + return parsed.items ?? [] +} + +function extractErrorText(body: string): string { + const trimmed = body.trim() + if (!trimmed) return '' + try { + const parsed = JSON.parse(trimmed) as Record + for (const field of ['message', 'error', 'reason', 'detail']) { + const value = parsed[field] + if (typeof value === 'string' && value.trim()) return value + } + } catch { + return trimmed + } + return trimmed +} diff --git a/services/pcp-tgu-ui-service/src/features/tgu-complex-plan/ComplexInfoPanel.tsx b/services/pcp-tgu-ui-service/src/features/tgu-complex-plan/ComplexInfoPanel.tsx new file mode 100644 index 0000000..ca36cb0 --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-complex-plan/ComplexInfoPanel.tsx @@ -0,0 +1,242 @@ +import type { ReactNode } from "react"; +import type { Tgu2DMapSelection } from "../tgu-map-2d/model/mapSelectionTypes"; +import type { ComplexPlanMode } from "../../api/complexPlanApi"; +import type { PriorityCell } from "../../api/cellsApi"; +import type { RequestMapItem } from "../../model/requestTypes"; +import { formatLocalDateTime, parseLocal } from "../../utils/localTime"; +import { formatVolume } from "../../utils/dataVolume"; + +type ComplexInfoPanelProps = { + /** Объекты под точкой клика (1 — карточка сразу, ≥2 — список с раскрытием). */ + hits: Tgu2DMapSelection[]; + /** id раскрытого объекта в списке. */ + activeId?: string; + onToggle: (selection: Tgu2DMapSelection) => void; + onClose: () => void; + /** Режимы текущего прогона — для карточки включения комплексного плана. */ + modes: ComplexPlanMode[]; + /** Заявки активного интервала — для карточки заявки (если включён показ заявок). */ + requestItems: RequestMapItem[]; + /** Ячейки карты приоритетов — для карточки ячейки тепловой карты. */ + priorityCells: PriorityCell[]; +}; + +/** + * Правый инспектор вкладки «Комплексный план» (по аналогии с редактором): показывает данные + * объекта, выбранного на карте — включения комплексного плана, заявки или ячейки тепловой карты. + * При наложении объектов даёт список под точкой клика с раскрытием карточки нужного. + */ +export function ComplexInfoPanel({ + hits, + activeId, + onToggle, + onClose, + modes, + requestItems, + priorityCells, +}: ComplexInfoPanelProps) { + const renderCard = (selection: Tgu2DMapSelection): ReactNode => { + switch (selection.layer) { + case "complexPlan": + return ; + case "requests": + return ; + case "heatmap": + return ; + default: + return
{selection.name || selection.id}
; + } + }; + + if (hits.length <= 1) { + const selection = hits[0]; + return ( +
+ + {selection ? renderCard(selection) :
Объект не выбран.
} +
+ ); + } + + return ( +
+ +
Кликните объект в списке, чтобы раскрыть его данные.
+
+ {hits.map((hit, index) => { + const open = hit.id === activeId; + return ( +
+ + {open &&
{renderCard(hit)}
} +
+ ); + })} +
+
+ ); +} + +function ModeCard({ selection, modes }: { selection: Tgu2DMapSelection; modes: ComplexPlanMode[] }) { + const mode = selection.type === "polygon" ? modes.find((m) => m.id === selection.modeId) : undefined; + if (!mode) return
{selection.name || selection.id}
; + return ( +
+
{selection.name}
+
+
КА
+
{mode.satelliteId}
+
Тип
+
{modeTypeLabel(mode.type)}
+
Виток
+
{mode.revolution}
+
Начало
+
{formatIso(mode.startTime)}
+
Конец
+
{formatIso(mode.endTime)}
+
Длительность, с
+
{mode.duration?.toFixed(1) ?? "—"}
+
Крен, °
+
{mode.roll != null ? mode.roll.toFixed(1) : "—"}
+
Объём
+
{mode.volumeMb != null ? formatVolume(mode.volumeMb) : "—"}
+
№ ячейки
+
{mode.cellNum || "—"}
+
Источник
+
{mode.source || "—"}
+
+
+ ); +} + +function RequestCard({ selection, requestItems }: { selection: Tgu2DMapSelection; requestItems: RequestMapItem[] }) { + const item = + selection.type === "polygon" && selection.requestId + ? requestItems.find((r) => r.id === selection.requestId) + : undefined; + if (!item) return
{selection.name || selection.id}
; + return ( +
+
+
ID
+
{item.id}
+
Название
+
{item.name || "—"}
+
Статус
+
{item.status}
+
Тип съёмки
+
{surveyTypeLabel(item.surveyType)}
+
Начало
+
{formatIso(item.beginDateTime)}
+
Конец
+
{formatIso(item.endDateTime)}
+
Важность
+
{item.importance}
+
Покрытие
+
{item.coveragePercent != null ? `${item.coveragePercent.toFixed(1)} %` : "—"}
+
+
+ ); +} + +function CellCard({ selection, priorityCells }: { selection: Tgu2DMapSelection; priorityCells: PriorityCell[] }) { + const cell = + selection.type === "polygon" && selection.cellNum != null + ? priorityCells.find((c) => c.cellNum === selection.cellNum) + : undefined; + const importance = cell?.importance ?? (selection.type === "polygon" ? selection.importance : undefined); + return ( +
+
{selection.name}
+
+
№ ячейки
+
{cell?.cellNum ?? (selection.type === "polygon" ? selection.cellNum : undefined) ?? "—"}
+
Важность
+
{importance != null ? importance.toFixed(4) : "—"}
+ {cell && ( + <> +
Центр
+
{cell.latitude.toFixed(2)}°, {cell.longitude.toFixed(2)}°
+
Заявок
+
{cell.requests.length}
+ + )} +
+ {cell && cell.requests.length > 0 && ( +
+
Заявки в ячейке
+
+ {cell.requests.map((req) => ( + + ))} +
+
+ )} +
+ ); +} + +function ReqFragmentRow({ requestId, coveragePercent, importance }: { requestId: string; coveragePercent: number; importance: number }) { + return ( + <> +
{requestId.slice(0, 8)}…
+
покрытие {coveragePercent.toFixed(1)} % · важность {importance.toFixed(3)}
+ + ); +} + +function PanelHead({ title, onClose }: { title: string; onClose: () => void }) { + return ( +
+

{title}

+ +
+ ); +} + +/** Заголовок/подпись типа объекта по слою выбора. */ +function selectionTitle(selection: Tgu2DMapSelection): string { + if (selection.type === "marker") return selection.layer === "stations" ? "Станция" : "Маркер КА"; + if (selection.type === "line") return selection.kind === "drop" ? "Сброс на НКПОР" : "Трасса КА"; + switch (selection.layer) { + case "complexPlan": return "Включение комплексного плана"; + case "heatmap": return "Ячейка приоритета"; + case "requests": return "Заявка на съёмку"; + case "planWorks": return "Маршрут съёмки"; + case "swath": return "Полоса обзора"; + default: return "Объект карты"; + } +} + +/** Класс цветовой метки в списке выбора (есть стили для requests/planWorks/swath/...). */ +function swatchKind(selection: Tgu2DMapSelection): string { + if (selection.type === "marker") return "station"; + if (selection.type === "line") return "drop"; + return selection.layer; +} + +function modeTypeLabel(type: string): string { + switch (type) { + case "SURVEY": return "Съёмка"; + case "DROP": return "Сброс"; + default: return type; + } +} + +function surveyTypeLabel(surveyType: string): string { + switch (surveyType) { + case "OPTICS": return "Оптическая"; + case "RSA": return "Радиолокационная (РСА)"; + case "COMBINED": return "Комбинированная"; + default: return surveyType; + } +} + +function formatIso(iso: string): string { + return formatLocalDateTime(parseLocal(iso)); +} diff --git a/services/pcp-tgu-ui-service/src/features/tgu-complex-plan/TguComplexPlanTab.tsx b/services/pcp-tgu-ui-service/src/features/tgu-complex-plan/TguComplexPlanTab.tsx index 0183bba..157a4bb 100644 --- a/services/pcp-tgu-ui-service/src/features/tgu-complex-plan/TguComplexPlanTab.tsx +++ b/services/pcp-tgu-ui-service/src/features/tgu-complex-plan/TguComplexPlanTab.tsx @@ -9,6 +9,7 @@ import { type ComplexPlanRunSummary } from "../../api/complexPlanApi"; import { fetchRequestsForMap } from "../../api/requestApi"; +import { fetchPriorityCells, type PriorityCell } from "../../api/cellsApi"; import type { RequestMapItem } from "../../model/requestTypes"; import type { TguPlatformUi, TimelineRange } from "../../model/timelineTypes"; import { DateTime24Field } from "../../components/timeFields"; @@ -16,7 +17,9 @@ import { parseWktPolygon } from "../tgu-map-2d/geometry/wktParser"; import { buildTgu2DMapScene } from "../tgu-map-2d/Tgu2DMapLayers"; import type { Tgu2DMapLayersState } from "../tgu-map-2d/model/mapLayerTypes"; import type { MapPolygon, Tgu2DMapScene } from "../tgu-map-2d/model/mapTypes"; +import type { Tgu2DMapSelection } from "../tgu-map-2d/model/mapSelectionTypes"; import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView"; +import { ComplexInfoPanel } from "./ComplexInfoPanel"; import { formatLocalDateTime, parseLocal, toLocalInputValue, toLocalIso } from "../../utils/localTime"; import { formatVolume } from "../../utils/dataVolume"; @@ -46,6 +49,18 @@ export function TguComplexPlanTab({ range, platforms }: TguComplexPlanTabProps) const [requestsLoading, setRequestsLoading] = useState(false); const [requestsError, setRequestsError] = useState(); + // Тепловая карта приоритетов ячеек сетки заданий (фоновый слой, тумблер-чекбокс). + const [showHeatmap, setShowHeatmap] = useState(false); + const [priorityCells, setPriorityCells] = useState([]); + const [heatmapLoading, setHeatmapLoading] = useState(false); + const [heatmapError, setHeatmapError] = useState(); + const [heatmapLoaded, setHeatmapLoaded] = useState(false); + + // Инспектор объекта карты справа (по аналогии с редактором): выбранные под кликом объекты + // (1 — карточка сразу, ≥2 — список с раскрытием) и id раскрытого в списке. + const [mapHits, setMapHits] = useState([]); + const [activeHitId, setActiveHitId] = useState(); + // NORAD-id всех эксплуатируемых КА «Планов» (spacecraftId == norad-id, см. tguTimelineMapper). const satelliteIds = useMemo( () => @@ -89,9 +104,14 @@ export function TguComplexPlanTab({ range, platforms }: TguComplexPlanTabProps) [showRequests, requestItems, range, platforms] ); + const heatmapPolygons = useMemo( + () => (showHeatmap ? buildHeatmapPolygons(priorityCells) : []), + [showHeatmap, priorityCells] + ); + const scene = useMemo( - () => ({ polygons: [...modePolygons, ...requestPolygons], lines: [], markers: [] }), - [modePolygons, requestPolygons] + () => ({ polygons: [...heatmapPolygons, ...modePolygons, ...requestPolygons], lines: [], markers: [] }), + [heatmapPolygons, modePolygons, requestPolygons] ); const layers = useMemo( @@ -100,11 +120,12 @@ export function TguComplexPlanTab({ range, platforms }: TguComplexPlanTabProps) swath: false, planWorks: false, complexPlan: true, + heatmap: showHeatmap, stations: false, spacecraftMarkers: false, requests: showRequests }), - [showRequests] + [showHeatmap, showRequests] ); const loadRuns = useCallback(async (): Promise => { @@ -121,6 +142,8 @@ export function TguComplexPlanTab({ range, platforms }: TguComplexPlanTabProps) const loadRunModes = useCallback(async (runId: number) => { setSelectedRunId(runId); + setMapHits([]); + setActiveHitId(undefined); setLoadingRun(true); setError(undefined); try { @@ -165,6 +188,57 @@ export function TguComplexPlanTab({ range, platforms }: TguComplexPlanTabProps) }; }, [showRequests, activeInterval]); + // Карта приоритетов не зависит от интервала — грузим один раз при первом включении чекбокса. + // heatmapLoading намеренно НЕ в зависимостях: иначе setHeatmapLoading(true) перезапустил бы + // эффект, а его cleanup отменил бы собственный незавершённый fetch (cancelled=true). + useEffect(() => { + if (!showHeatmap || heatmapLoaded) return; + let cancelled = false; + setHeatmapLoading(true); + setHeatmapError(undefined); + fetchPriorityCells() + .then((cells) => { + if (!cancelled) { + setPriorityCells(cells); + setHeatmapLoaded(true); + } + }) + .catch((caught) => { + if (!cancelled) setHeatmapError(caught instanceof Error ? caught.message : "Не удалось загрузить карту приоритетов."); + }) + .finally(() => { + if (!cancelled) setHeatmapLoading(false); + }); + return () => { + cancelled = true; + }; + }, [showHeatmap, heatmapLoaded]); + + // Клик по карте: 0/1 объект — карточка сразу; ≥2 (наложение) — список под точкой клика. + const handleObjectSelect = useCallback((selection?: Tgu2DMapSelection) => { + setMapHits(selection ? [selection] : []); + setActiveHitId(selection?.id); + }, []); + + const handleObjectsAtPoint = useCallback((selections: Tgu2DMapSelection[]) => { + // При включённой тепловой карте ячейка приоритета всегда идёт первой в списке и раскрыта + // по умолчанию — оператор сразу видит приоритет точки, а контуры/заявки ниже. + const ordered = showHeatmap + ? [...selections].sort((a, b) => Number(b.layer === "heatmap") - Number(a.layer === "heatmap")) + : selections; + setMapHits(ordered); + setActiveHitId(ordered[0]?.id); + }, [showHeatmap]); + + const toggleHit = useCallback((selection: Tgu2DMapSelection) => { + setActiveHitId((current) => (current === selection.id ? undefined : selection.id)); + }, []); + + const closeInfoPanel = useCallback(() => { + setMapHits([]); + setActiveHitId(undefined); + }, []); + const runCalculation = async () => { if (running || rangeInvalid || rangeTooLong || satelliteIds.length === 0) return; setRunning(true); @@ -253,25 +327,56 @@ export function TguComplexPlanTab({ range, platforms }: TguComplexPlanTabProps) > {showRequests ? "Скрыть заявки" : "Показать заявки"} +
{displayRun && (
run #{displayRun.runId} · режимов {displayRun.calculatedModesCount} · охват {formatPercent(displayRun.coveredAreaPercent)} · объём {formatVolume(totalVolumeMb)} {displayRun.snapshotIds.length > 0 ? ` · snapshot ${displayRun.snapshotIds.join(", ")}` : ""} · на карте {contoursCount} контуров {showRequests ? ` · заявок ${requestItems.length}` : ""} + {showHeatmap ? ` · ячеек ${heatmapPolygons.length}` : ""}
)}
-
- {overlayMessage &&
{overlayMessage}
} - undefined} - /> -
+
0 ? "minmax(0, 1fr) 22rem" : "minmax(0, 1fr) 0" }} + > +
+ {overlayMessage &&
{overlayMessage}
} + +
+ + {mapHits.length > 0 && ( + + )} +
{satelliteIds.length === 0 && ( @@ -286,6 +391,14 @@ export function TguComplexPlanTab({ range, platforms }: TguComplexPlanTabProps) )} {requestsLoading &&
Загрузка заявок…
} {requestsError &&
{requestsError}
} + {heatmapLoading &&
Загрузка карты приоритетов…
} + {heatmapError &&
{heatmapError}
} + {showHeatmap && heatmapLoaded && !heatmapError && priorityCells.length === 0 && ( +
+ Карта приоритетов пуста — на ячейки сетки заданий не спроецировано ни одной заявки + (нужна пересборка сетки в сервисе заданий). +
+ )} {runsError &&
{runsError}
} {notice &&
{notice}
} {error &&
{error}
} @@ -294,6 +407,30 @@ export function TguComplexPlanTab({ range, platforms }: TguComplexPlanTabProps) ); } +/** Ячейки карты приоритетов → полигоны слоя тепловой карты (контур из WKT + важность + № ячейки). */ +function buildHeatmapPolygons(cells: PriorityCell[]): MapPolygon[] { + const result: MapPolygon[] = []; + let zIndex = 0; + for (const cell of cells) { + if (!cell.contour) continue; + const rings = parseWktPolygon(cell.contour); + for (const ring of rings) { + if (ring.length < 3) continue; + result.push({ + id: `heatmap-cell:${cell.cellNum}`, + name: `Ячейка ${cell.cellNum}`, + kind: "heatmap", + layer: "heatmap", + points: ring, + importance: cell.importance, + cellNum: cell.cellNum, + zIndex: zIndex++ + }); + } + } + return result; +} + /** Контуры режимов (survey) → полигоны слоя комплексного плана. Сбросы без контура пропускаем. */ function buildModePolygons(modes: ComplexPlanMode[]): MapPolygon[] { const polygons: MapPolygon[] = []; diff --git a/services/pcp-tgu-ui-service/src/features/tgu-editor/TguEditorTab.tsx b/services/pcp-tgu-ui-service/src/features/tgu-editor/TguEditorTab.tsx index 3b6dc34..b54a82d 100644 --- a/services/pcp-tgu-ui-service/src/features/tgu-editor/TguEditorTab.tsx +++ b/services/pcp-tgu-ui-service/src/features/tgu-editor/TguEditorTab.tsx @@ -89,6 +89,7 @@ const EDITOR_MAP_LAYERS: Tgu2DMapLayersState = { swath: true, planWorks: true, complexPlan: false, + heatmap: false, stations: true, spacecraftMarkers: false, requests: true diff --git a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/Tgu2DMapTab.tsx b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/Tgu2DMapTab.tsx index b02477a..345c1a5 100644 --- a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/Tgu2DMapTab.tsx +++ b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/Tgu2DMapTab.tsx @@ -314,6 +314,8 @@ function layerLabel(layer: Tgu2DMapLayerKey): string { return "Работы плана"; case "complexPlan": return "Комплексный план"; + case "heatmap": + return "Тепловая карта"; case "stations": return "Станции"; case "spacecraftMarkers": diff --git a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/Tgu2DMapView.tsx b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/Tgu2DMapView.tsx index ac200bb..67f3b11 100644 --- a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/Tgu2DMapView.tsx +++ b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/Tgu2DMapView.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { drawBaseMap } from "./canvas/drawBaseMap"; import { drawComplexPlan } from "./canvas/drawComplexPlan"; +import { drawHeatmap } from "./canvas/drawHeatmap"; import { drawPlanWorks } from "./canvas/drawPlanWorks"; import { drawRequests } from "./canvas/drawRequests"; import { drawStations } from "./canvas/drawStations"; @@ -193,6 +194,9 @@ export function Tgu2DMapView({ drawTiles(context, view, projection, size, handleTileLoad); drawBaseMap(context, projection); + if (layers.heatmap) { + drawHeatmap(context, visiblePolygons.filter((polygon) => polygon.layer === "heatmap"), projection, size); + } if (layers.requests) { drawRequests(context, visiblePolygons.filter((polygon) => polygon.layer === "requests"), projection, size); } @@ -319,7 +323,14 @@ export function Tgu2DMapView({ 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; + if ( + polygon.layer !== "requests" && + polygon.layer !== "planWorks" && + polygon.layer !== "complexPlan" && + polygon.layer !== "heatmap" + ) { + continue; + } hits.push({ kind: "polygon", polygon, selection: polygonToSelection(polygon) }); } @@ -603,6 +614,8 @@ function layerTitle(layer: MapPolygon["layer"]): string { return "Полоса обзора"; case "complexPlan": return "Комплексный план"; + case "heatmap": + return "Ячейка приоритета"; case "tracks": return "Трасса КА"; case "requests": @@ -708,7 +721,9 @@ function polygonToSelection(polygon: MapPolygon): Tgu2DMapSelection { requestId: polygon.requestId, surveyType: polygon.surveyType, status: polygon.status, - modeId: polygon.modeId + modeId: polygon.modeId, + importance: polygon.importance, + cellNum: polygon.cellNum }; } diff --git a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/canvas/drawHeatmap.ts b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/canvas/drawHeatmap.ts new file mode 100644 index 0000000..3caaf4a --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/canvas/drawHeatmap.ts @@ -0,0 +1,86 @@ +import type { MapProjection } from "../geometry/mapProjection"; +import type { MapPolygon } from "../model/mapTypes"; + +type Pt = { x: number; y: number }; + +function unwrapX(points: Pt[], worldSize: number): Pt[] { + if (points.length === 0) return []; + const out: Pt[] = [points[0]]; + let prevX = points[0].x; + for (let i = 1; i < points.length; i++) { + let x = points[i].x; + while (x - prevX > worldSize / 2) x -= worldSize; + while (prevX - x > worldSize / 2) x += worldSize; + out.push({ x, y: points[i].y }); + prevX = x; + } + return out; +} + +/** + * Фоновый слой тепловой карты приоритетов: заливка ячеек сетки заданий цветом по важности + * (cool→hot). Важность нормируется к максимуму набора, поэтому шкала подстраивается под данные. + * Рисуется ПОД контурами плана/заявок, без обводки и hit-теста — это фон, а не объекты. + */ +export function drawHeatmap( + ctx: CanvasRenderingContext2D, + cells: MapPolygon[], + projection: MapProjection, + viewport: { width: number; height: number } +) { + if (cells.length === 0) return; + const maxImportance = cells.reduce((max, cell) => Math.max(max, cell.importance ?? 0), 0); + if (maxImportance <= 0) return; + + ctx.save(); + for (const cell of cells) { + if (cell.points.length < 3) continue; + const raw = cell.points.map((p) => projection.project(p)); + const pts = unwrapX(raw, projection.worldSize); + // sqrt-нормировка слегка поднимает слабые ячейки, чтобы они не сливались с фоном. + const t = Math.sqrt(Math.min(1, Math.max(0, (cell.importance ?? 0) / maxImportance))); + ctx.fillStyle = heatColor(t); + for (const dx of [0, projection.worldSize, -projection.worldSize]) { + const visible = pts.some((p) => { + const x = p.x + dx; + return x >= -64 && x <= viewport.width + 64 && p.y >= -64 && p.y <= viewport.height + 64; + }); + if (!visible) continue; + ctx.beginPath(); + for (let i = 0; i < pts.length; i++) { + if (i === 0) ctx.moveTo(pts[i].x + dx, pts[i].y); + else ctx.lineTo(pts[i].x + dx, pts[i].y); + } + ctx.closePath(); + ctx.fill(); + } + } + ctx.restore(); +} + +/** + * Цвет тепловой карты по нормированной важности t∈[0,1]: бирюзовый (низкая) → + * жёлтый → красный (высокая). Альфа растёт с важностью, чтобы «холодные» ячейки были легче. + */ +function heatColor(t: number): string { + // Кусочно-линейная палитра: 0 → #2BD0C4 (cool), 0.5 → #F5D020 (warm), 1 → #F03B20 (hot). + const cool = [43, 208, 196]; + const warm = [245, 208, 32]; + const hot = [240, 59, 32]; + let rgb: number[]; + if (t < 0.5) { + rgb = mix(cool, warm, t / 0.5); + } else { + rgb = mix(warm, hot, (t - 0.5) / 0.5); + } + const alpha = 0.35 + 0.4 * t; + return `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, ${alpha.toFixed(3)})`; +} + +function mix(a: number[], b: number[], k: number): number[] { + return [ + Math.round(a[0] + (b[0] - a[0]) * k), + Math.round(a[1] + (b[1] - a[1]) * k), + Math.round(a[2] + (b[2] - a[2]) * k), + ]; +} diff --git a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/model/mapLayerTypes.ts b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/model/mapLayerTypes.ts index 6dff8ab..18e7f69 100644 --- a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/model/mapLayerTypes.ts +++ b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/model/mapLayerTypes.ts @@ -3,6 +3,7 @@ export type Tgu2DMapLayerKey = | "swath" | "planWorks" | "complexPlan" + | "heatmap" | "stations" | "spacecraftMarkers" | "requests"; @@ -38,6 +39,7 @@ export const DEFAULT_TGU_2D_MAP_LAYERS: Tgu2DMapLayersState = { swath: false, planWorks: true, complexPlan: false, + heatmap: false, stations: true, spacecraftMarkers: false, requests: true diff --git a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/model/mapSelectionTypes.ts b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/model/mapSelectionTypes.ts index e2af403..d774a65 100644 --- a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/model/mapSelectionTypes.ts +++ b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/model/mapSelectionTypes.ts @@ -5,7 +5,7 @@ export type Tgu2DMapSelection = type: "polygon"; id: string; name: string; - layer: "planWorks" | "swath" | "tracks" | "requests" | "complexPlan"; + layer: "planWorks" | "swath" | "tracks" | "requests" | "complexPlan" | "heatmap"; planId?: string; spacecraftId?: string; requestId?: string; @@ -13,6 +13,10 @@ export type Tgu2DMapSelection = status?: TguPlanStatus; /** id режима плана (включения) для polygon-ов слоя planWorks — связь с работой таймлайна. */ modeId?: number; + /** Важность ячейки тепловой карты (layer "heatmap"). */ + importance?: number; + /** Номер ячейки сетки заданий (layer "heatmap"). */ + cellNum?: number; } | { type: "line"; diff --git a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/model/mapTypes.ts b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/model/mapTypes.ts index 227bf17..6c139d8 100644 --- a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/model/mapTypes.ts +++ b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/model/mapTypes.ts @@ -24,8 +24,8 @@ export type MapViewState = { export type MapPolygon = { id: string; name: string; - kind: "planWork" | "swath" | "trackZone" | "request" | "complexPlan"; - layer: "planWorks" | "swath" | "tracks" | "requests" | "complexPlan"; + kind: "planWork" | "swath" | "trackZone" | "request" | "complexPlan" | "heatmap"; + layer: "planWorks" | "swath" | "tracks" | "requests" | "complexPlan" | "heatmap"; points: GeoPoint[]; status?: TguPlanStatus; planId?: string; @@ -34,6 +34,10 @@ export type MapPolygon = { surveyType?: string; /** id режима плана (survey), к которому относится контур — для связи «включение ↔ объект на карте». */ modeId?: number; + /** Важность ячейки тепловой карты (layer "heatmap"): задаёт цвет заливки и показывается в инспекторе. */ + importance?: number; + /** Номер ячейки сетки заданий (layer "heatmap") — ключ для поиска полной карточки ячейки. */ + cellNum?: number; zIndex: number; }; diff --git a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/tgu2DMapInterval.test.ts b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/tgu2DMapInterval.test.ts index 1d5f154..42129d1 100644 --- a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/tgu2DMapInterval.test.ts +++ b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/tgu2DMapInterval.test.ts @@ -52,6 +52,7 @@ describe("tgu2DMapInterval", () => { swath: false, planWorks: true, complexPlan: false, + heatmap: false, stations: true, spacecraftMarkers: false, requests: true diff --git a/services/pcp-tgu-ui-service/src/features/tgu-requests/TguRequestsTab.tsx b/services/pcp-tgu-ui-service/src/features/tgu-requests/TguRequestsTab.tsx index 4e7b3ab..0dc7dbe 100644 --- a/services/pcp-tgu-ui-service/src/features/tgu-requests/TguRequestsTab.tsx +++ b/services/pcp-tgu-ui-service/src/features/tgu-requests/TguRequestsTab.tsx @@ -123,7 +123,7 @@ export function TguRequestsTab({ range, invalidRange, platforms }: TguRequestsTa ); const layers = useMemo( - () => ({ tracks: false, swath: false, planWorks: false, complexPlan: false, stations: showStations, spacecraftMarkers: false, requests: true }), + () => ({ tracks: false, swath: false, planWorks: false, complexPlan: false, heatmap: false, stations: showStations, spacecraftMarkers: false, requests: true }), [showStations] ); diff --git a/services/pcp-tgu-ui-service/src/styles/tgu-editor.css b/services/pcp-tgu-ui-service/src/styles/tgu-editor.css index 7f86e9f..aea13cc 100644 --- a/services/pcp-tgu-ui-service/src/styles/tgu-editor.css +++ b/services/pcp-tgu-ui-service/src/styles/tgu-editor.css @@ -1159,6 +1159,8 @@ .tgu-editor-pick__swatch--drop { background: #ffcf3f; } .tgu-editor-pick__swatch--station { background: #d5e2e2; } .tgu-editor-pick__swatch--swath { background: #6fb7ff; } +.tgu-editor-pick__swatch--complexPlan { background: #c080ff; } +.tgu-editor-pick__swatch--heatmap { background: #f5803e; } .tgu-editor-pick__title { font-size: 0.74rem; diff --git a/services/pcp-tgu-ui-service/src/styles/tgu-planning.css b/services/pcp-tgu-ui-service/src/styles/tgu-planning.css index cd56a02..0d9a2b0 100644 --- a/services/pcp-tgu-ui-service/src/styles/tgu-planning.css +++ b/services/pcp-tgu-ui-service/src/styles/tgu-planning.css @@ -733,6 +733,22 @@ color: var(--accent-strong); } +/* Чекбокс-тумблер в тулбаре карты (напр. тепловая карта на вкладке «Комплексный план»). */ +.tgu-checkbox { + display: inline-flex; + gap: 0.4rem; + align-items: center; + color: var(--text-muted); + font-size: 0.8rem; + white-space: nowrap; + cursor: pointer; +} + +.tgu-checkbox input { + accent-color: var(--accent); + cursor: pointer; +} + .tgu-map-content { display: grid; grid-template-columns: 19rem minmax(0, 1fr); @@ -883,6 +899,13 @@ border-radius: 999px; } +/* Тело вкладки «Комплексный план»: карта + правый инспектор объекта (как в редакторе). */ +.tgu-complex-body { + display: grid; + min-width: 0; + min-height: 0; +} + .tgu-map-stage { position: relative; min-width: 0;