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
@@ -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 <ModeCard selection={selection} modes={modes} />;
case "requests":
return <RequestCard selection={selection} requestItems={requestItems} />;
case "heatmap":
return <CellCard selection={selection} priorityCells={priorityCells} />;
default:
return <div className="tgu-editor-panel__hint">{selection.name || selection.id}</div>;
}
};
if (hits.length <= 1) {
const selection = hits[0];
return (
<div className="tgu-editor-panel">
<PanelHead title={selection ? selectionTitle(selection) : "Объект"} onClose={onClose} />
{selection ? renderCard(selection) : <div className="tgu-editor-panel__hint">Объект не выбран.</div>}
</div>
);
}
return (
<div className="tgu-editor-panel">
<PanelHead title={`Объекты под курсором · ${hits.length}`} onClose={onClose} />
<div className="tgu-editor-panel__hint">Кликните объект в списке, чтобы раскрыть его данные.</div>
<div className="tgu-editor-pick">
{hits.map((hit, index) => {
const open = hit.id === activeId;
return (
<div className={`tgu-editor-pick__item${open ? " is-open" : ""}`} key={`${hit.id}:${index}`}>
<button type="button" className="tgu-editor-pick__row" onClick={() => onToggle(hit)}>
<i className={`tgu-editor-pick__swatch tgu-editor-pick__swatch--${swatchKind(hit)}`} />
<span className="tgu-editor-pick__title">{hit.name || selectionTitle(hit)}</span>
<small className="tgu-editor-pick__sub">{selectionTitle(hit)}</small>
<span className="tgu-editor-pick__chevron" aria-hidden="true">{open ? "▾" : "▸"}</span>
</button>
{open && <div className="tgu-editor-pick__detail">{renderCard(hit)}</div>}
</div>
);
})}
</div>
</div>
);
}
function ModeCard({ selection, modes }: { selection: Tgu2DMapSelection; modes: ComplexPlanMode[] }) {
const mode = selection.type === "polygon" ? modes.find((m) => m.id === selection.modeId) : undefined;
if (!mode) return <div className="tgu-editor-panel__hint">{selection.name || selection.id}</div>;
return (
<section className="tgu-editor-panel__section">
<div className="tgu-editor-eyebrow">{selection.name}</div>
<dl className="tgu-map-request-details__grid">
<dt>КА</dt>
<dd className="mono">{mode.satelliteId}</dd>
<dt>Тип</dt>
<dd>{modeTypeLabel(mode.type)}</dd>
<dt>Виток</dt>
<dd className="mono">{mode.revolution}</dd>
<dt>Начало</dt>
<dd className="mono">{formatIso(mode.startTime)}</dd>
<dt>Конец</dt>
<dd className="mono">{formatIso(mode.endTime)}</dd>
<dt>Длительность, с</dt>
<dd className="mono">{mode.duration?.toFixed(1) ?? "—"}</dd>
<dt>Крен, °</dt>
<dd className="mono">{mode.roll != null ? mode.roll.toFixed(1) : "—"}</dd>
<dt>Объём</dt>
<dd className="mono">{mode.volumeMb != null ? formatVolume(mode.volumeMb) : "—"}</dd>
<dt> ячейки</dt>
<dd className="mono">{mode.cellNum || "—"}</dd>
<dt>Источник</dt>
<dd>{mode.source || "—"}</dd>
</dl>
</section>
);
}
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 <div className="tgu-editor-panel__hint">{selection.name || selection.id}</div>;
return (
<section className="tgu-editor-panel__section">
<dl className="tgu-map-request-details__grid">
<dt>ID</dt>
<dd className="mono">{item.id}</dd>
<dt>Название</dt>
<dd>{item.name || "—"}</dd>
<dt>Статус</dt>
<dd>{item.status}</dd>
<dt>Тип съёмки</dt>
<dd>{surveyTypeLabel(item.surveyType)}</dd>
<dt>Начало</dt>
<dd className="mono">{formatIso(item.beginDateTime)}</dd>
<dt>Конец</dt>
<dd className="mono">{formatIso(item.endDateTime)}</dd>
<dt>Важность</dt>
<dd className="mono">{item.importance}</dd>
<dt>Покрытие</dt>
<dd className="mono">{item.coveragePercent != null ? `${item.coveragePercent.toFixed(1)} %` : "—"}</dd>
</dl>
</section>
);
}
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 (
<section className="tgu-editor-panel__section">
<div className="tgu-editor-eyebrow">{selection.name}</div>
<dl className="tgu-map-request-details__grid">
<dt> ячейки</dt>
<dd className="mono">{cell?.cellNum ?? (selection.type === "polygon" ? selection.cellNum : undefined) ?? "—"}</dd>
<dt>Важность</dt>
<dd className="mono">{importance != null ? importance.toFixed(4) : "—"}</dd>
{cell && (
<>
<dt>Центр</dt>
<dd className="mono">{cell.latitude.toFixed(2)}°, {cell.longitude.toFixed(2)}°</dd>
<dt>Заявок</dt>
<dd className="mono">{cell.requests.length}</dd>
</>
)}
</dl>
{cell && cell.requests.length > 0 && (
<div className="tgu-editor-panel__section">
<div className="tgu-editor-eyebrow">Заявки в ячейке</div>
<dl className="tgu-map-request-details__grid">
{cell.requests.map((req) => (
<ReqFragmentRow key={req.requestId} requestId={req.requestId} coveragePercent={req.coveragePercent} importance={req.importance} />
))}
</dl>
</div>
)}
</section>
);
}
function ReqFragmentRow({ requestId, coveragePercent, importance }: { requestId: string; coveragePercent: number; importance: number }) {
return (
<>
<dt className="mono" title={requestId}>{requestId.slice(0, 8)}</dt>
<dd className="mono">покрытие {coveragePercent.toFixed(1)} % · важность {importance.toFixed(3)}</dd>
</>
);
}
function PanelHead({ title, onClose }: { title: string; onClose: () => void }) {
return (
<div className="tgu-editor-panel__head">
<h2>{title}</h2>
<button className="tgu-icon-button" type="button" onClick={onClose} title="Закрыть">×</button>
</div>
);
}
/** Заголовок/подпись типа объекта по слою выбора. */
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));
}
@@ -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<string>();
// Тепловая карта приоритетов ячеек сетки заданий (фоновый слой, тумблер-чекбокс).
const [showHeatmap, setShowHeatmap] = useState(false);
const [priorityCells, setPriorityCells] = useState<PriorityCell[]>([]);
const [heatmapLoading, setHeatmapLoading] = useState(false);
const [heatmapError, setHeatmapError] = useState<string>();
const [heatmapLoaded, setHeatmapLoaded] = useState(false);
// Инспектор объекта карты справа (по аналогии с редактором): выбранные под кликом объекты
// (1 — карточка сразу, ≥2 — список с раскрытием) и id раскрытого в списке.
const [mapHits, setMapHits] = useState<Tgu2DMapSelection[]>([]);
const [activeHitId, setActiveHitId] = useState<string>();
// 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<MapPolygon[]>(
() => (showHeatmap ? buildHeatmapPolygons(priorityCells) : []),
[showHeatmap, priorityCells]
);
const scene = useMemo<Tgu2DMapScene>(
() => ({ polygons: [...modePolygons, ...requestPolygons], lines: [], markers: [] }),
[modePolygons, requestPolygons]
() => ({ polygons: [...heatmapPolygons, ...modePolygons, ...requestPolygons], lines: [], markers: [] }),
[heatmapPolygons, modePolygons, requestPolygons]
);
const layers = useMemo<Tgu2DMapLayersState>(
@@ -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<ComplexPlanRunSummary[]> => {
@@ -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 ? "Скрыть заявки" : "Показать заявки"}
</button>
<label className="tgu-checkbox" title="Тепловая карта приоритетов ячеек сетки заданий">
<input
type="checkbox"
checked={showHeatmap}
onChange={(event) => setShowHeatmap(event.target.checked)}
/>
<span>Тепловая карта</span>
</label>
<div className="tgu-map-toolbar__spacer" style={{ flex: 1 }} />
{displayRun && (
<div className="tgu-map-toolbar__subtitle">
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}` : ""}
</div>
)}
</div>
<section className="tgu-map-stage">
{overlayMessage && <div className="tgu-map-overlay">{overlayMessage}</div>}
<Tgu2DMapView
scene={scene}
layers={layers}
redrawToken={modes.length + requestPolygons.length}
onObjectSelect={() => undefined}
/>
</section>
<div
className="tgu-complex-body"
style={{ gridTemplateColumns: mapHits.length > 0 ? "minmax(0, 1fr) 22rem" : "minmax(0, 1fr) 0" }}
>
<section className="tgu-map-stage">
{overlayMessage && <div className="tgu-map-overlay">{overlayMessage}</div>}
<Tgu2DMapView
scene={scene}
layers={layers}
redrawToken={modes.length + requestPolygons.length + heatmapPolygons.length}
enableObjectChooser
onObjectsAtPoint={handleObjectsAtPoint}
onObjectSelect={handleObjectSelect}
selectedObjectId={activeHitId}
/>
</section>
{mapHits.length > 0 && (
<aside className="tgu-editor-side-panel">
<ComplexInfoPanel
hits={mapHits}
activeId={activeHitId}
onToggle={toggleHit}
onClose={closeInfoPanel}
modes={modes}
requestItems={requestItems}
priorityCells={priorityCells}
/>
</aside>
)}
</div>
<div className="tgu-map-errors">
{satelliteIds.length === 0 && (
@@ -286,6 +391,14 @@ export function TguComplexPlanTab({ range, platforms }: TguComplexPlanTabProps)
)}
{requestsLoading && <div className="tgu-alert">Загрузка заявок</div>}
{requestsError && <div className="tgu-alert tgu-alert--danger">{requestsError}</div>}
{heatmapLoading && <div className="tgu-alert">Загрузка карты приоритетов</div>}
{heatmapError && <div className="tgu-alert tgu-alert--danger">{heatmapError}</div>}
{showHeatmap && heatmapLoaded && !heatmapError && priorityCells.length === 0 && (
<div className="tgu-alert tgu-alert--warning">
Карта приоритетов пуста на ячейки сетки заданий не спроецировано ни одной заявки
(нужна пересборка сетки в сервисе заданий).
</div>
)}
{runsError && <div className="tgu-alert tgu-alert--danger">{runsError}</div>}
{notice && <div className="tgu-alert">{notice}</div>}
{error && <div className="tgu-alert tgu-alert--danger">{error}</div>}
@@ -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[] = [];
@@ -89,6 +89,7 @@ const EDITOR_MAP_LAYERS: Tgu2DMapLayersState = {
swath: true,
planWorks: true,
complexPlan: false,
heatmap: false,
stations: true,
spacecraftMarkers: false,
requests: true
@@ -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;
}
/**
* Фоновый слой тепловой карты приоритетов: заливка ячеек сетки заданий цветом по важности
* (coolhot). Важность нормируется к максимуму набора, поэтому шкала подстраивается под данные.
* Рисуется ПОД контурами плана/заявок, без обводки и 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
@@ -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]
);