feat(tgu-ui): тепловая карта приоритетов и инспектор объектов на вкладке «Комплексный план»

Чекбокс «Тепловая карта» — фоновый слой-заливка ячеек сетки заданий по важности
(cool→hot), источник GET /api/pcp-request/v1/cells/priority-map (cellsApi + drawHeatmap).
Heatmap — полноценный слой MapPolygon (layer "heatmap", importance/cellNum), участвует
в spatial-индексе и chooser, рисуется под контурами плана/заявок.

Клик по объекту открывает правый инспектор ComplexInfoPanel (по аналогии с редактором):
карточки ячейки приоритета, включения комплексного плана и заявки; при наложении —
список объектов под точкой клика с раскрытием. При включённой тепловой карте ячейка
всегда идёт первой в списке и раскрыта по умолчанию.

Бэклог #44 отмечен сделанным.
This commit is contained in:
Дмитрий Соловьев
2026-06-10 15:47:18 +03:00
parent 11380fced5
commit bdbc874f41
15 changed files with 623 additions and 18 deletions
@@ -314,6 +314,8 @@ function layerLabel(layer: Tgu2DMapLayerKey): string {
return "Работы плана";
case "complexPlan":
return "Комплексный план";
case "heatmap":
return "Тепловая карта";
case "stations":
return "Станции";
case "spacecraftMarkers":
@@ -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
};
}
@@ -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),
];
}
@@ -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
@@ -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";
@@ -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;
};
@@ -52,6 +52,7 @@ describe("tgu2DMapInterval", () => {
swath: false,
planWorks: true,
complexPlan: false,
heatmap: false,
stations: true,
spacecraftMarkers: false,
requests: true