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:
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Фоновый слой тепловой карты приоритетов: заливка ячеек сетки заданий цветом по важности
|
||||
* (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
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user