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

Обобщил ComplexInfoPanel в общий MapObjectInspector (tgu-map-2d): карточки для
всех типов объектов — заявка, включение комплексного плана, ячейка тепловой карты,
маршрут planWorks, станция, маркер КА, линия; список с раскрытием при наложении.

Вкладка «Карта» переведена на enableObjectChooser + onObjectsAtPoint + правый
инспектор — заменила разрозненные Tgu2DMapRequestsPanel/Tgu2DMapStationPanel (удалены)
и объектную часть нижней плашки (Tgu2DMapDetails ужат до контекста плана). Вкладка
«Комплексный план» использует тот же инспектор.

RequestAtPoint переехал в tgu-requests/RequestDetailsPanel (нужен вкладке «Заявки»).
This commit is contained in:
Дмитрий Соловьев
2026-06-10 15:58:55 +03:00
parent bdbc874f41
commit 7202f7cb02
9 changed files with 164 additions and 270 deletions
@@ -19,7 +19,7 @@ import type { Tgu2DMapLayersState } from "../tgu-map-2d/model/mapLayerTypes";
import type { MapPolygon, Tgu2DMapScene } from "../tgu-map-2d/model/mapTypes"; import type { MapPolygon, Tgu2DMapScene } from "../tgu-map-2d/model/mapTypes";
import type { Tgu2DMapSelection } from "../tgu-map-2d/model/mapSelectionTypes"; import type { Tgu2DMapSelection } from "../tgu-map-2d/model/mapSelectionTypes";
import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView"; import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView";
import { ComplexInfoPanel } from "./ComplexInfoPanel"; import { MapObjectInspector } from "../tgu-map-2d/MapObjectInspector";
import { formatLocalDateTime, parseLocal, toLocalInputValue, toLocalIso } from "../../utils/localTime"; import { formatLocalDateTime, parseLocal, toLocalInputValue, toLocalIso } from "../../utils/localTime";
import { formatVolume } from "../../utils/dataVolume"; import { formatVolume } from "../../utils/dataVolume";
@@ -365,7 +365,7 @@ export function TguComplexPlanTab({ range, platforms }: TguComplexPlanTabProps)
{mapHits.length > 0 && ( {mapHits.length > 0 && (
<aside className="tgu-editor-side-panel"> <aside className="tgu-editor-side-panel">
<ComplexInfoPanel <MapObjectInspector
hits={mapHits} hits={mapHits}
activeId={activeHitId} activeId={activeHitId}
onToggle={toggleHit} onToggle={toggleHit}
@@ -1,41 +1,50 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import type { Tgu2DMapSelection } from "../tgu-map-2d/model/mapSelectionTypes"; import type { Tgu2DMapSelection } from "./model/mapSelectionTypes";
import type { ComplexPlanMode } from "../../api/complexPlanApi"; import type { ComplexPlanMode } from "../../api/complexPlanApi";
import type { PriorityCell } from "../../api/cellsApi"; import type { PriorityCell } from "../../api/cellsApi";
import type { RequestMapItem } from "../../model/requestTypes"; import type { RequestMapItem } from "../../model/requestTypes";
import { statusStyle } from "../tgu-planning/tguStatus";
import { formatLocalDateTime, parseLocal } from "../../utils/localTime"; import { formatLocalDateTime, parseLocal } from "../../utils/localTime";
import { formatVolume } from "../../utils/dataVolume"; import { formatVolume } from "../../utils/dataVolume";
type ComplexInfoPanelProps = { /**
* Единый инспектор объектов карты для всех вкладок (Карта, Комплексный план, Редактор):
* показывает данные объекта, выбранного на карте заявки, маршрута/включения, ячейки тепловой
* карты, станции, маркера КА. При наложении объектов (2 под точкой клика) даёт список с
* раскрытием карточки нужного. Данные-обогащение (modes/requestItems/priorityCells) опциональны:
* чего нет, то берётся из самого выбора (имя, статус).
*/
type MapObjectInspectorProps = {
/** Объекты под точкой клика (1 — карточка сразу, ≥2 — список с раскрытием). */ /** Объекты под точкой клика (1 — карточка сразу, ≥2 — список с раскрытием). */
hits: Tgu2DMapSelection[]; hits: Tgu2DMapSelection[];
/** id раскрытого объекта в списке. */ /** id раскрытого объекта в списке. */
activeId?: string; activeId?: string;
onToggle: (selection: Tgu2DMapSelection) => void; onToggle: (selection: Tgu2DMapSelection) => void;
onClose: () => void; onClose: () => void;
/** Режимы текущего прогона — для карточки включения комплексного плана. */ /** Режимы прогона комплексного плана — для карточки включения (layer "complexPlan"). */
modes: ComplexPlanMode[]; modes?: ComplexPlanMode[];
/** Заявки активного интервала — для карточки заявки (если включён показ заявок). */ /** Заявки активного интервала — для карточки заявки (layer "requests"). */
requestItems: RequestMapItem[]; requestItems?: RequestMapItem[];
/** Ячейки карты приоритетов — для карточки ячейки тепловой карты. */ /** Ячейки карты приоритетов — для карточки ячейки тепловой карты (layer "heatmap"). */
priorityCells: PriorityCell[]; priorityCells?: PriorityCell[];
}; };
/** export function MapObjectInspector({
* Правый инспектор вкладки «Комплексный план» (по аналогии с редактором): показывает данные
* объекта, выбранного на карте включения комплексного плана, заявки или ячейки тепловой карты.
* При наложении объектов даёт список под точкой клика с раскрытием карточки нужного.
*/
export function ComplexInfoPanel({
hits, hits,
activeId, activeId,
onToggle, onToggle,
onClose, onClose,
modes, modes = [],
requestItems, requestItems = [],
priorityCells, priorityCells = [],
}: ComplexInfoPanelProps) { }: MapObjectInspectorProps) {
const renderCard = (selection: Tgu2DMapSelection): ReactNode => { const renderCard = (selection: Tgu2DMapSelection): ReactNode => {
if (selection.type === "marker") {
return selection.layer === "stations"
? <StationCard selection={selection} />
: <MarkerCard selection={selection} />;
}
if (selection.type === "line") return <LineCard selection={selection} />;
switch (selection.layer) { switch (selection.layer) {
case "complexPlan": case "complexPlan":
return <ModeCard selection={selection} modes={modes} />; return <ModeCard selection={selection} modes={modes} />;
@@ -43,6 +52,8 @@ export function ComplexInfoPanel({
return <RequestCard selection={selection} requestItems={requestItems} />; return <RequestCard selection={selection} requestItems={requestItems} />;
case "heatmap": case "heatmap":
return <CellCard selection={selection} priorityCells={priorityCells} />; return <CellCard selection={selection} priorityCells={priorityCells} />;
case "planWorks":
return <PlanWorkCard selection={selection} />;
default: default:
return <div className="tgu-editor-panel__hint">{selection.name || selection.id}</div>; return <div className="tgu-editor-panel__hint">{selection.name || selection.id}</div>;
} }
@@ -82,8 +93,12 @@ export function ComplexInfoPanel({
); );
} }
function ModeCard({ selection, modes }: { selection: Tgu2DMapSelection; modes: ComplexPlanMode[] }) { type PolygonSelection = Extract<Tgu2DMapSelection, { type: "polygon" }>;
const mode = selection.type === "polygon" ? modes.find((m) => m.id === selection.modeId) : undefined; type MarkerSelection = Extract<Tgu2DMapSelection, { type: "marker" }>;
type LineSelection = Extract<Tgu2DMapSelection, { type: "line" }>;
function ModeCard({ selection, modes }: { selection: PolygonSelection; modes: ComplexPlanMode[] }) {
const mode = modes.find((m) => m.id === selection.modeId);
if (!mode) return <div className="tgu-editor-panel__hint">{selection.name || selection.id}</div>; if (!mode) return <div className="tgu-editor-panel__hint">{selection.name || selection.id}</div>;
return ( return (
<section className="tgu-editor-panel__section"> <section className="tgu-editor-panel__section">
@@ -114,11 +129,8 @@ function ModeCard({ selection, modes }: { selection: Tgu2DMapSelection; modes: C
); );
} }
function RequestCard({ selection, requestItems }: { selection: Tgu2DMapSelection; requestItems: RequestMapItem[] }) { function RequestCard({ selection, requestItems }: { selection: PolygonSelection; requestItems: RequestMapItem[] }) {
const item = const item = selection.requestId ? requestItems.find((r) => r.id === selection.requestId) : undefined;
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>; if (!item) return <div className="tgu-editor-panel__hint">{selection.name || selection.id}</div>;
return ( return (
<section className="tgu-editor-panel__section"> <section className="tgu-editor-panel__section">
@@ -139,23 +151,24 @@ function RequestCard({ selection, requestItems }: { selection: Tgu2DMapSelection
<dd className="mono">{item.importance}</dd> <dd className="mono">{item.importance}</dd>
<dt>Покрытие</dt> <dt>Покрытие</dt>
<dd className="mono">{item.coveragePercent != null ? `${item.coveragePercent.toFixed(1)} %` : "—"}</dd> <dd className="mono">{item.coveragePercent != null ? `${item.coveragePercent.toFixed(1)} %` : "—"}</dd>
<dt>КПП</dt>
<dd className="mono">{item.kpp?.join(", ") || "—"}</dd>
<dt>Приоритет передачи</dt>
<dd>{item.highPriorityTransmit ? "Да" : "Нет"}</dd>
</dl> </dl>
</section> </section>
); );
} }
function CellCard({ selection, priorityCells }: { selection: Tgu2DMapSelection; priorityCells: PriorityCell[] }) { function CellCard({ selection, priorityCells }: { selection: PolygonSelection; priorityCells: PriorityCell[] }) {
const cell = const cell = selection.cellNum != null ? priorityCells.find((c) => c.cellNum === selection.cellNum) : undefined;
selection.type === "polygon" && selection.cellNum != null const importance = cell?.importance ?? selection.importance;
? priorityCells.find((c) => c.cellNum === selection.cellNum)
: undefined;
const importance = cell?.importance ?? (selection.type === "polygon" ? selection.importance : undefined);
return ( return (
<section className="tgu-editor-panel__section"> <section className="tgu-editor-panel__section">
<div className="tgu-editor-eyebrow">{selection.name}</div> <div className="tgu-editor-eyebrow">{selection.name}</div>
<dl className="tgu-map-request-details__grid"> <dl className="tgu-map-request-details__grid">
<dt> ячейки</dt> <dt> ячейки</dt>
<dd className="mono">{cell?.cellNum ?? (selection.type === "polygon" ? selection.cellNum : undefined) ?? "—"}</dd> <dd className="mono">{cell?.cellNum ?? selection.cellNum ?? "—"}</dd>
<dt>Важность</dt> <dt>Важность</dt>
<dd className="mono">{importance != null ? importance.toFixed(4) : "—"}</dd> <dd className="mono">{importance != null ? importance.toFixed(4) : "—"}</dd>
{cell && ( {cell && (
@@ -181,6 +194,75 @@ function CellCard({ selection, priorityCells }: { selection: Tgu2DMapSelection;
); );
} }
function PlanWorkCard({ selection }: { selection: PolygonSelection }) {
const style = selection.status ? statusStyle(selection.status) : undefined;
return (
<section className="tgu-editor-panel__section">
<div className="tgu-editor-eyebrow">{selection.name}</div>
<dl className="tgu-map-request-details__grid">
{selection.spacecraftId && (<><dt>КА</dt><dd className="mono">{selection.spacecraftId}</dd></>)}
{selection.planId && (<><dt>План</dt><dd className="mono">{selection.planId}</dd></>)}
{selection.surveyType && (<><dt>Тип съёмки</dt><dd>{surveyTypeLabel(selection.surveyType)}</dd></>)}
</dl>
{style && (
<span className="tgu-map-status-pill">
<i style={{ backgroundColor: style.color }} />
{style.label}
</span>
)}
</section>
);
}
function StationCard({ selection }: { selection: MarkerSelection }) {
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">{selection.stationNumber ?? "—"}</dd>
<dt>Широта</dt>
<dd className="mono">{selection.lat != null ? `${selection.lat.toFixed(4)}°` : "—"}</dd>
<dt>Долгота</dt>
<dd className="mono">{selection.lon != null ? `${selection.lon.toFixed(4)}°` : "—"}</dd>
<dt>Высота, м</dt>
<dd className="mono">{selection.altitude != null ? selection.altitude.toFixed(1) : "—"}</dd>
<dt>Угол места мин, °</dt>
<dd className="mono">{selection.elevationMin != null ? selection.elevationMin.toFixed(1) : "—"}</dd>
<dt>Угол места макс, °</dt>
<dd className="mono">{selection.elevationMax != null ? selection.elevationMax.toFixed(1) : "—"}</dd>
</dl>
</section>
);
}
function MarkerCard({ selection }: { selection: MarkerSelection }) {
return (
<section className="tgu-editor-panel__section">
<div className="tgu-editor-eyebrow">{selection.name}</div>
<dl className="tgu-map-request-details__grid">
{selection.spacecraftId && (<><dt>КА</dt><dd className="mono">{selection.spacecraftId}</dd></>)}
<dt>Широта</dt>
<dd className="mono">{selection.lat != null ? `${selection.lat.toFixed(4)}°` : "—"}</dd>
<dt>Долгота</dt>
<dd className="mono">{selection.lon != null ? `${selection.lon.toFixed(4)}°` : "—"}</dd>
</dl>
</section>
);
}
function LineCard({ selection }: { selection: LineSelection }) {
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>{selection.kind === "drop" ? "Сброс на НКПОР" : "Трасса КА"}</dd>
</dl>
</section>
);
}
function ReqFragmentRow({ requestId, coveragePercent, importance }: { requestId: string; coveragePercent: number; importance: number }) { function ReqFragmentRow({ requestId, coveragePercent, importance }: { requestId: string; coveragePercent: number; importance: number }) {
return ( return (
<> <>
@@ -213,10 +295,10 @@ function selectionTitle(selection: Tgu2DMapSelection): string {
} }
} }
/** Класс цветовой метки в списке выбора (есть стили для requests/planWorks/swath/...). */ /** Класс цветовой метки в списке выбора (стили в tgu-editor.css). */
function swatchKind(selection: Tgu2DMapSelection): string { function swatchKind(selection: Tgu2DMapSelection): string {
if (selection.type === "marker") return "station"; if (selection.type === "marker") return selection.layer === "stations" ? "station" : "spacecraftMarkers";
if (selection.type === "line") return "drop"; if (selection.type === "line") return selection.kind === "drop" ? "drop" : "tracks";
return selection.layer; return selection.layer;
} }
@@ -1,14 +1,16 @@
import type { TguPlanUi } from "../../model/timelineTypes"; import type { TguPlanUi } from "../../model/timelineTypes";
import { platformLabel } from "../tgu-planning/tguTimelineMapper"; import { platformLabel } from "../tgu-planning/tguTimelineMapper";
import { statusStyle } from "../tgu-planning/tguStatus"; import { statusStyle } from "../tgu-planning/tguStatus";
import type { Tgu2DMapSelection } from "./model/mapSelectionTypes";
type Tgu2DMapDetailsProps = { type Tgu2DMapDetailsProps = {
selectedPlan?: TguPlanUi; selectedPlan?: TguPlanUi;
selectedObject?: Tgu2DMapSelection;
}; };
export function Tgu2DMapDetails({ selectedPlan, selectedObject }: Tgu2DMapDetailsProps) { /**
* Нижняя плашка контекста вкладки «Карта»: какой план выбран на «Планах» (КА/статус).
* Информация по объектам карты живёт в правом инспекторе (MapObjectInspector), не здесь.
*/
export function Tgu2DMapDetails({ selectedPlan }: Tgu2DMapDetailsProps) {
const status = selectedPlan ? statusStyle(selectedPlan.status) : undefined; const status = selectedPlan ? statusStyle(selectedPlan.status) : undefined;
return ( return (
@@ -34,15 +36,6 @@ export function Tgu2DMapDetails({ selectedPlan, selectedObject }: Tgu2DMapDetail
) : "-"} ) : "-"}
</div> </div>
</div> </div>
<div>
<div className="tgu-map-details__label">Объект карты</div>
<div className="tgu-map-details__value mono">{selectedObject?.name || selectedObject?.id || "-"}</div>
</div>
<div>
<div className="tgu-map-details__label">Работы</div>
<div className="tgu-map-details__value">Работы плана</div>
</div>
</section> </section>
); );
} }
@@ -1,116 +0,0 @@
import type { RequestMapItem } from "../../model/requestTypes";
import type { MapPolygon } from "./model/mapTypes";
import { formatLocalDateTime, parseLocal } from "../../utils/localTime";
export type RequestAtPoint = { polygon: MapPolygon; item: RequestMapItem };
type Props = {
requests: RequestAtPoint[];
selectedPolygonId?: string;
onHover: (polygonId: string | undefined) => void;
onSelect: (polygonId: string) => void;
onClose: () => void;
};
export function Tgu2DMapRequestsPanel({ requests, selectedPolygonId, onHover, onSelect, onClose }: Props) {
const selectedEntry = selectedPolygonId ? requests.find((r) => r.polygon.id === selectedPolygonId) : undefined;
return (
<aside className="tgu-map-requests-panel">
<div className="tgu-panel-heading">
<span>Заявки в точке ({requests.length})</span>
<button className="tgu-link-button" type="button" onClick={onClose}>
Закрыть
</button>
</div>
<div className="tgu-map-requests-list">
{requests.map(({ polygon, item }) => (
<button
key={polygon.id}
className={`tgu-map-request-row ${polygon.id === selectedPolygonId ? "is-selected" : ""}`}
type="button"
onMouseEnter={() => onHover(polygon.id)}
onMouseLeave={() => onHover(undefined)}
onClick={() => onSelect(polygon.id)}
>
<i className={`tgu-map-request-row__type req-${item.surveyType.toLowerCase()}`} />
<span className="tgu-map-request-row__body">
<span className="tgu-map-request-row__name">{item.name || item.id}</span>
<span className="tgu-map-request-row__meta">
{formatDateRange(item.beginDateTime, item.endDateTime)}
{" · "}
{item.surveyType}
{" · "}
{item.status}
</span>
</span>
</button>
))}
</div>
{selectedEntry && (
<div className="tgu-map-request-details">
<div className="tgu-map-panel__label">Детали заявки</div>
<dl className="tgu-map-request-details__grid">
<dt>ID</dt>
<dd className="mono">{selectedEntry.item.id}</dd>
<dt>Название</dt>
<dd>{selectedEntry.item.name || "—"}</dd>
<dt>Статус</dt>
<dd>{selectedEntry.item.status}</dd>
<dt>Тип съёмки</dt>
<dd>{selectedEntry.item.surveyType}</dd>
<dt>Начало</dt>
<dd className="mono">{formatDateTime(selectedEntry.item.beginDateTime)}</dd>
<dt>Конец</dt>
<dd className="mono">{formatDateTime(selectedEntry.item.endDateTime)}</dd>
<dt>Важность</dt>
<dd className="mono">{selectedEntry.item.importance}</dd>
<dt>Покрытие</dt>
<dd className="mono">{selectedEntry.item.coveragePercent != null ? `${selectedEntry.item.coveragePercent.toFixed(1)} %` : "—"}</dd>
<dt>КПП</dt>
<dd className="mono">{selectedEntry.item.kpp?.join(", ") || "—"}</dd>
<dt>Приоритет передачи</dt>
<dd>{selectedEntry.item.highPriorityTransmit ? "Да" : "Нет"}</dd>
<dt>Создано</dt>
<dd className="mono">{formatDateTime(selectedEntry.item.createdAt)}</dd>
<dt>Изменено</dt>
<dd className="mono">{formatDateTime(selectedEntry.item.updatedAt)}</dd>
</dl>
</div>
)}
</aside>
);
}
function formatDateTime(value: string): string {
return formatLocalDateTime(parseLocal(value));
}
function formatDateRange(begin: string, end: string): string {
const b = new Date(parseLocal(begin));
const e = new Date(parseLocal(end));
const sameDay =
b.getFullYear() === e.getFullYear() &&
b.getMonth() === e.getMonth() &&
b.getDate() === e.getDate();
const dateOpts: Intl.DateTimeFormatOptions = { day: "2-digit", month: "2-digit" };
const timeOpts: Intl.DateTimeFormatOptions = { hour: "2-digit", minute: "2-digit" };
const beginStr = `${b.toLocaleDateString("ru-RU", dateOpts)} ${b.toLocaleTimeString("ru-RU", timeOpts)}`;
const endStr = sameDay
? b.toLocaleTimeString("ru-RU", timeOpts)
: `${e.toLocaleDateString("ru-RU", dateOpts)} ${e.toLocaleTimeString("ru-RU", timeOpts)}`;
return `${beginStr} ${endStr}`;
}
@@ -1,43 +0,0 @@
import type { Tgu2DMapSelection } from "./model/mapSelectionTypes";
type StationSelection = Extract<Tgu2DMapSelection, { type: "marker" }> & { layer: "stations" };
type Props = {
selection: StationSelection;
onClose: () => void;
};
export function Tgu2DMapStationPanel({ selection, onClose }: Props) {
return (
<aside className="tgu-map-requests-panel">
<div className="tgu-panel-heading">
<span>Станция</span>
<button className="tgu-link-button" type="button" onClick={onClose}>
Закрыть
</button>
</div>
<div className="tgu-map-request-details" style={{ flex: 1 }}>
<div className="tgu-map-panel__label">{selection.name}</div>
<dl className="tgu-map-request-details__grid">
<dt>Номер</dt>
<dd className="mono">{selection.stationNumber ?? "—"}</dd>
<dt>Широта</dt>
<dd className="mono">{selection.lat != null ? `${selection.lat.toFixed(4)}°` : "—"}</dd>
<dt>Долгота</dt>
<dd className="mono">{selection.lon != null ? `${selection.lon.toFixed(4)}°` : "—"}</dd>
<dt>Высота, м</dt>
<dd className="mono">{selection.altitude != null ? selection.altitude.toFixed(1) : "—"}</dd>
<dt>Угол места мин, °</dt>
<dd className="mono">{selection.elevationMin != null ? selection.elevationMin.toFixed(1) : "—"}</dd>
<dt>Угол места макс, °</dt>
<dd className="mono">{selection.elevationMax != null ? selection.elevationMax.toFixed(1) : "—"}</dd>
</dl>
</div>
</aside>
);
}
@@ -6,12 +6,10 @@ import type { TguPlanUi, TguPlatformUi, TimelineRange } from "../../model/timeli
import { platformLabel } from "../tgu-planning/tguTimelineMapper"; import { platformLabel } from "../tgu-planning/tguTimelineMapper";
import { DEFAULT_TGU_2D_MAP_LAYERS, type Tgu2DMapLayerKey, type Tgu2DMapLayersState } from "./model/mapLayerTypes"; import { DEFAULT_TGU_2D_MAP_LAYERS, type Tgu2DMapLayerKey, type Tgu2DMapLayersState } from "./model/mapLayerTypes";
import type { Tgu2DMapSelection } from "./model/mapSelectionTypes"; import type { Tgu2DMapSelection } from "./model/mapSelectionTypes";
import type { MapPolygon } from "./model/mapTypes";
import { buildTgu2DMapScene, getLayerAvailability } from "./Tgu2DMapLayers"; import { buildTgu2DMapScene, getLayerAvailability } from "./Tgu2DMapLayers";
import { Tgu2DMapDetails } from "./Tgu2DMapDetails"; import { Tgu2DMapDetails } from "./Tgu2DMapDetails";
import { Tgu2DMapRequestsPanel, type RequestAtPoint } from "./Tgu2DMapRequestsPanel"; import { MapObjectInspector } from "./MapObjectInspector";
import { Tgu2DMapSidebar } from "./Tgu2DMapSidebar"; import { Tgu2DMapSidebar } from "./Tgu2DMapSidebar";
import { Tgu2DMapStationPanel } from "./Tgu2DMapStationPanel";
import { Tgu2DMapToolbar } from "./Tgu2DMapToolbar"; import { Tgu2DMapToolbar } from "./Tgu2DMapToolbar";
import { Tgu2DMapView } from "./Tgu2DMapView"; import { Tgu2DMapView } from "./Tgu2DMapView";
import { getTgu2DMapIntervalState, TGU_2D_MAP_INTERVAL_WARNING } from "./tgu2DMapInterval"; import { getTgu2DMapIntervalState, TGU_2D_MAP_INTERVAL_WARNING } from "./tgu2DMapInterval";
@@ -58,15 +56,13 @@ export function Tgu2DMapTab({
const [layers, setLayers] = useState<Tgu2DMapLayersState>(DEFAULT_TGU_2D_MAP_LAYERS); const [layers, setLayers] = useState<Tgu2DMapLayersState>(DEFAULT_TGU_2D_MAP_LAYERS);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [redrawToken, setRedrawToken] = useState(0); const [redrawToken, setRedrawToken] = useState(0);
const [selectedObject, setSelectedObject] = useState<Tgu2DMapSelection>();
const [hiddenIds, setHiddenIds] = useState<ReadonlySet<string>>(new Set()); const [hiddenIds, setHiddenIds] = useState<ReadonlySet<string>>(new Set());
const [requestsState, setRequestsState] = useState<RequestsState>(() => buildInitialRequestsState(range)); const [requestsState, setRequestsState] = useState<RequestsState>(() => buildInitialRequestsState(range));
const [stations, setStations] = useState<StationDto[]>([]); const [stations, setStations] = useState<StationDto[]>([]);
const [stationsFetchFailed, setStationsFetchFailed] = useState(false); const [stationsFetchFailed, setStationsFetchFailed] = useState(false);
const [requestsAtPoint, setRequestsAtPoint] = useState<RequestAtPoint[]>([]); // Инспектор объекта карты справа (единый для всех вкладок): объекты под кликом и раскрытый.
const [selectedRequestPolygonId, setSelectedRequestPolygonId] = useState<string | undefined>(); const [mapHits, setMapHits] = useState<Tgu2DMapSelection[]>([]);
const [hoveredRequestPolygonId, setHoveredRequestPolygonId] = useState<string | undefined>(); const [activeHitId, setActiveHitId] = useState<string>();
const [stationPanel, setStationPanel] = useState<(Tgu2DMapSelection & { type: "marker"; layer: "stations" }) | undefined>();
useEffect(() => { useEffect(() => {
fetchStations() fetchStations()
@@ -183,47 +179,24 @@ export function Tgu2DMapTab({
setLayers((current) => ({ ...current, [key]: !current[key] })); setLayers((current) => ({ ...current, [key]: !current[key] }));
}; };
// Клик по карте: 0/1 объект — карточка сразу; ≥2 (наложение) — список под точкой клика.
const handleObjectSelect = useCallback((selection?: Tgu2DMapSelection) => { const handleObjectSelect = useCallback((selection?: Tgu2DMapSelection) => {
if (selection?.type === "marker" && selection.layer === "stations") { setMapHits(selection ? [selection] : []);
setStationPanel(selection as Tgu2DMapSelection & { type: "marker"; layer: "stations" }); setActiveHitId(selection?.id);
setRequestsAtPoint([]);
setSelectedRequestPolygonId(undefined);
setHoveredRequestPolygonId(undefined);
setSelectedObject(undefined);
} else {
setStationPanel(undefined);
setSelectedObject(selection);
}
}, []); }, []);
const handleRequestsAtPoint = useCallback( const handleObjectsAtPoint = useCallback((selections: Tgu2DMapSelection[]) => {
(polygons: MapPolygon[]) => { setMapHits(selections);
if (polygons.length === 0) { setActiveHitId(selections[0]?.id);
setRequestsAtPoint([]);
setSelectedRequestPolygonId(undefined);
setHoveredRequestPolygonId(undefined);
return;
}
const joined = polygons.flatMap((polygon) => {
const item = requestsState.items.find((r) => r.id === polygon.requestId);
return item ? [{ polygon, item }] : [];
});
setRequestsAtPoint(joined);
setSelectedRequestPolygonId(undefined);
setHoveredRequestPolygonId(undefined);
setStationPanel(undefined);
},
[requestsState.items]
);
const closeRequestsPanel = useCallback(() => {
setRequestsAtPoint([]);
setSelectedRequestPolygonId(undefined);
setHoveredRequestPolygonId(undefined);
}, []); }, []);
const closeStationPanel = useCallback(() => { const toggleHit = useCallback((selection: Tgu2DMapSelection) => {
setStationPanel(undefined); setActiveHitId((current) => (current === selection.id ? undefined : selection.id));
}, []);
const closeInfoPanel = useCallback(() => {
setMapHits([]);
setActiveHitId(undefined);
}, []); }, []);
return ( return (
@@ -236,7 +209,7 @@ export function Tgu2DMapTab({
onToggleLayer={toggleLayer} onToggleLayer={toggleLayer}
/> />
<div className={`tgu-map-content${(requestsAtPoint.length > 0 || stationPanel != null) ? " has-requests-panel" : ""}`}> <div className={`tgu-map-content${mapHits.length > 0 ? " has-side-panel" : ""}`}>
<Tgu2DMapSidebar <Tgu2DMapSidebar
platforms={platforms} platforms={platforms}
selectedPlanId={selectedPlan?.planId} selectedPlanId={selectedPlan?.planId}
@@ -260,23 +233,22 @@ export function Tgu2DMapTab({
scene={mapInvalidRange ? { polygons: [], lines: [], markers: [] } : scene} scene={mapInvalidRange ? { polygons: [], lines: [], markers: [] } : scene}
layers={layers} layers={layers}
redrawToken={redrawToken} redrawToken={redrawToken}
enableObjectChooser
onObjectsAtPoint={handleObjectsAtPoint}
onObjectSelect={handleObjectSelect} onObjectSelect={handleObjectSelect}
onRequestsAtPoint={handleRequestsAtPoint} selectedObjectId={activeHitId}
hoveredRequestPolygonId={hoveredRequestPolygonId}
selectedRequestPolygonId={selectedRequestPolygonId}
/> />
</section> </section>
{requestsAtPoint.length > 0 && ( {mapHits.length > 0 && (
<Tgu2DMapRequestsPanel <aside className="tgu-editor-side-panel">
requests={requestsAtPoint} <MapObjectInspector
selectedPolygonId={selectedRequestPolygonId} hits={mapHits}
onHover={setHoveredRequestPolygonId} activeId={activeHitId}
onSelect={setSelectedRequestPolygonId} onToggle={toggleHit}
onClose={closeRequestsPanel} onClose={closeInfoPanel}
/> requestItems={requestsState.items}
)} />
{stationPanel != null && ( </aside>
<Tgu2DMapStationPanel selection={stationPanel} onClose={closeStationPanel} />
)} )}
</div> </div>
@@ -290,7 +262,7 @@ export function Tgu2DMapTab({
)} )}
</div> </div>
<Tgu2DMapDetails selectedPlan={selectedPlan} selectedObject={selectedObject} /> <Tgu2DMapDetails selectedPlan={selectedPlan} />
</main> </main>
); );
} }
@@ -1,7 +1,11 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import type { RequestAtPoint } from "../tgu-map-2d/Tgu2DMapRequestsPanel"; import type { RequestMapItem } from "../../model/requestTypes";
import type { MapPolygon } from "../tgu-map-2d/model/mapTypes";
import { formatLocalDateTime, parseLocal } from "../../utils/localTime"; import { formatLocalDateTime, parseLocal } from "../../utils/localTime";
/** Заявка под точкой клика на карте: полигон-проекция заявки и сама заявка. */
export type RequestAtPoint = { polygon: MapPolygon; item: RequestMapItem };
type Props = { type Props = {
requests: RequestAtPoint[]; requests: RequestAtPoint[];
selectedPolygonId?: string; selectedPolygonId?: string;
@@ -5,7 +5,7 @@ import type { RequestMapItem } from "../../model/requestTypes";
import type { TguPlatformUi, TimelineRange } from "../../model/timelineTypes"; import type { TguPlatformUi, TimelineRange } from "../../model/timelineTypes";
import { buildTgu2DMapScene } from "../tgu-map-2d/Tgu2DMapLayers"; import { buildTgu2DMapScene } from "../tgu-map-2d/Tgu2DMapLayers";
import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView"; import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView";
import type { RequestAtPoint } from "../tgu-map-2d/Tgu2DMapRequestsPanel"; import type { RequestAtPoint } from "./RequestDetailsPanel";
import type { Tgu2DMapSelection } from "../tgu-map-2d/model/mapSelectionTypes"; import type { Tgu2DMapSelection } from "../tgu-map-2d/model/mapSelectionTypes";
import type { GeoPoint, MapPolygon } from "../tgu-map-2d/model/mapTypes"; import type { GeoPoint, MapPolygon } from "../tgu-map-2d/model/mapTypes";
import { generateUuid } from "../../utils/uuid"; import { generateUuid } from "../../utils/uuid";
@@ -1161,6 +1161,8 @@
.tgu-editor-pick__swatch--swath { background: #6fb7ff; } .tgu-editor-pick__swatch--swath { background: #6fb7ff; }
.tgu-editor-pick__swatch--complexPlan { background: #c080ff; } .tgu-editor-pick__swatch--complexPlan { background: #c080ff; }
.tgu-editor-pick__swatch--heatmap { background: #f5803e; } .tgu-editor-pick__swatch--heatmap { background: #f5803e; }
.tgu-editor-pick__swatch--tracks { background: #8d99a6; }
.tgu-editor-pick__swatch--spacecraftMarkers { background: #d5e2e2; }
.tgu-editor-pick__title { .tgu-editor-pick__title {
font-size: 0.74rem; font-size: 0.74rem;