2D-карта (станции, заявки) и вкладка «Заявки»

Новая вкладка «Заявки»: показ заявок за интервал на 2D-карте,
создание заявки рисованием полигона на карте с формой параметров
(Оптика/РСА) и удаление выбранной заявки (createRequest/deleteRequest,
RequestCreatePanel/RequestDetailsPanel, polygonToWkt). В Tgu2DMapView
добавлен изолированный режим рисования полигона.

2D-карта: панели информации о станции и о заявках, слой станций
(stationsApi), поддержка выбора объектов через спатиальный индекс.
Список слоёв упрощён — трасса/полоса/маркеры КА убраны из переключателя,
по умолчанию включены работы плана, станции и заявки.
This commit is contained in:
Дмитрий Соловьев
2026-06-01 22:17:50 +03:00
parent 715b5108d6
commit 0128c5569c
26 changed files with 1851 additions and 96 deletions
+41 -1
View File
@@ -1,4 +1,4 @@
import type { RequestFilter, RequestMapItem } from "../model/requestTypes"; import type { CreateRequestPayload, CreateRequestResult, RequestFilter, RequestMapItem } from "../model/requestTypes";
const BASE = "/api/pcp-request/v1"; const BASE = "/api/pcp-request/v1";
const PAGE_SIZE = 500; const PAGE_SIZE = 500;
@@ -60,3 +60,43 @@ export async function fetchRequestsForMap(
truncated: first.totalPages > MAX_PAGES truncated: first.totalPages > MAX_PAGES
}; };
} }
type ErrorDetail = { field?: string; message?: string };
type ErrorBody = { code?: string; message?: string; details?: ErrorDetail[] };
async function readErrorMessage(response: Response, fallback: string): Promise<string> {
try {
const body = (await response.json()) as ErrorBody;
const details = body.details?.map((d) => (d.field ? `${d.field}: ${d.message}` : d.message)).filter(Boolean);
const detailText = details && details.length > 0 ? ` (${details.join("; ")})` : "";
if (body.message) return `${body.message}${detailText}`;
} catch {
// тело ответа не JSON — используем fallback
}
return `${fallback}: ${response.status}`;
}
export async function createRequest(payload: CreateRequestPayload): Promise<CreateRequestResult> {
const response = await fetch(`${BASE}/requests`, {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify(payload)
});
if (!response.ok) {
throw new Error(await readErrorMessage(response, "Не удалось создать заявку"));
}
return response.json() as Promise<CreateRequestResult>;
}
export async function deleteRequest(id: string): Promise<void> {
const response = await fetch(`${BASE}/requests/${encodeURIComponent(id)}`, {
method: "DELETE",
headers: { Accept: "application/json" }
});
if (!response.ok) {
throw new Error(await readErrorMessage(response, "Не удалось удалить заявку"));
}
}
@@ -0,0 +1,18 @@
export type StationDto = {
id: string | null;
number: number;
name: string;
position: { lat: number; long: number; height: number };
elevationMin: number;
elevationMax: number;
};
export async function fetchStations(): Promise<StationDto[]> {
const response = await fetch("/api/stations", {
headers: { Accept: "application/json" }
});
if (!response.ok) {
throw new Error(`Ошибка загрузки станций: ${response.status}`);
}
return response.json() as Promise<StationDto[]>;
}
@@ -1,8 +1,9 @@
import type { StationDto } from "../../api/stationsApi";
import type { RequestMapItem } from "../../model/requestTypes"; import type { RequestMapItem } from "../../model/requestTypes";
import type { TguPlanUi, TguPlatformUi, TimelineRange } from "../../model/timelineTypes"; import type { TguPlanUi, TguPlatformUi, TimelineRange } from "../../model/timelineTypes";
import { parseWktPolygon } from "./geometry/wktParser"; import { parseWktPolygon } from "./geometry/wktParser";
import type { Tgu2DMapLayersState } from "./model/mapLayerTypes"; import type { Tgu2DMapLayersState } from "./model/mapLayerTypes";
import type { MapPolygon, Tgu2DMapScene } from "./model/mapTypes"; import type { MapMarker, MapPolygon, Tgu2DMapScene } from "./model/mapTypes";
export type Tgu2DMapLayerAvailability = Partial<Record<keyof Tgu2DMapLayersState, string>>; export type Tgu2DMapLayerAvailability = Partial<Record<keyof Tgu2DMapLayersState, string>>;
@@ -12,17 +13,19 @@ export type Tgu2DMapSceneInput = {
platforms: TguPlatformUi[]; platforms: TguPlatformUi[];
hiddenSpacecraftIds?: ReadonlySet<string>; hiddenSpacecraftIds?: ReadonlySet<string>;
requestItems?: RequestMapItem[]; requestItems?: RequestMapItem[];
stationItems?: StationDto[];
}; };
const UNAVAILABLE = "Данные слоя недоступны в текущем API."; const UNAVAILABLE = "Данные слоя недоступны в текущем API.";
export function buildTgu2DMapScene(input: Tgu2DMapSceneInput): Tgu2DMapScene { export function buildTgu2DMapScene(input: Tgu2DMapSceneInput): Tgu2DMapScene {
const requestPolygons = buildRequestPolygons(input.requestItems ?? []); const requestPolygons = buildRequestPolygons(input.requestItems ?? []);
const stationMarkers = buildStationMarkers(input.stationItems ?? []);
return { return {
polygons: requestPolygons, polygons: requestPolygons,
lines: [], lines: [],
markers: [] markers: stationMarkers
}; };
} }
@@ -50,7 +53,25 @@ function buildRequestPolygons(items: RequestMapItem[]): MapPolygon[] {
return polygons; return polygons;
} }
export function getLayerAvailability(layers: Tgu2DMapLayersState, scene: Tgu2DMapScene): Tgu2DMapLayerAvailability { export function buildStationMarkers(items: StationDto[]): MapMarker[] {
return items.map((item, index) => ({
id: item.id ?? `station:${index}`,
layer: "stations" as const,
point: { lon: item.position.long, lat: item.position.lat },
label: item.name,
zIndex: index,
stationNumber: item.number,
altitude: item.position.height,
elevationMin: item.elevationMin,
elevationMax: item.elevationMax
}));
}
export function getLayerAvailability(
layers: Tgu2DMapLayersState,
scene: Tgu2DMapScene,
stationsFetchFailed?: boolean
): Tgu2DMapLayerAvailability {
const availability: Tgu2DMapLayerAvailability = {}; const availability: Tgu2DMapLayerAvailability = {};
if (layers.tracks && scene.lines.filter((line) => line.layer === "tracks").length === 0) { if (layers.tracks && scene.lines.filter((line) => line.layer === "tracks").length === 0) {
availability.tracks = UNAVAILABLE; availability.tracks = UNAVAILABLE;
@@ -61,7 +82,7 @@ export function getLayerAvailability(layers: Tgu2DMapLayersState, scene: Tgu2DMa
if (layers.planWorks && scene.polygons.filter((polygon) => polygon.layer === "planWorks").length === 0) { if (layers.planWorks && scene.polygons.filter((polygon) => polygon.layer === "planWorks").length === 0) {
availability.planWorks = UNAVAILABLE; availability.planWorks = UNAVAILABLE;
} }
if (layers.stations && scene.markers.filter((marker) => marker.layer === "stations").length === 0) { if (layers.stations && stationsFetchFailed) {
availability.stations = UNAVAILABLE; availability.stations = UNAVAILABLE;
} }
if (layers.spacecraftMarkers && scene.markers.filter((marker) => marker.layer === "spacecraftMarkers").length === 0) { if (layers.spacecraftMarkers && scene.markers.filter((marker) => marker.layer === "spacecraftMarkers").length === 0) {
@@ -0,0 +1,115 @@
import type { RequestMapItem } from "../../model/requestTypes";
import type { MapPolygon } from "./model/mapTypes";
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 new Date(value).toLocaleString("ru-RU");
}
function formatDateRange(begin: string, end: string): string {
const b = new Date(begin);
const e = new Date(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}`;
}
@@ -70,7 +70,7 @@ export function Tgu2DMapSidebar({
return ( return (
<aside className="tgu-map-panel"> <aside className="tgu-map-panel">
<div className="tgu-panel-heading"> <div className="tgu-panel-heading">
<span>Карта 2D</span> <span>Карта</span>
{hiddenCount > 0 && ( {hiddenCount > 0 && (
<button className="tgu-link-button" type="button" onClick={onShowAll}> <button className="tgu-link-button" type="button" onClick={onShowAll}>
Показать все ({hiddenCount} скрыто) Показать все ({hiddenCount} скрыто)
@@ -202,19 +202,7 @@ export function Tgu2DMapSidebar({
{filteredPlatforms.length === 0 && <div className="tgu-empty tgu-empty--compact">Нет КА по текущему поиску.</div>} {filteredPlatforms.length === 0 && <div className="tgu-empty tgu-empty--compact">Нет КА по текущему поиску.</div>}
</div> </div>
</div> </div>
<div className="tgu-map-panel__section">
<div className="tgu-map-panel__label">Статусы планов</div>
<div className="tgu-map-status-legend">
<span><i className="status-accepted" />ACCEPTED</span>
<span><i className="status-rejected" />REJECTED</span>
<span><i className="status-waiting" />WAITING_DECISION</span>
<span><i className="status-planned" />PLANNED</span>
<span><i className="status-issuing" />ISSUING</span>
<span><i className="status-expired" />EXPIRED</span>
<span><i className="status-superseded" />SUPERSEDED</span>
<span><i className="status-ambiguous" />START_AMBIGUOUS</span>
</div>
</div>
</aside> </aside>
); );
} }
@@ -0,0 +1,43 @@
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>
);
}
@@ -1,13 +1,17 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { fetchRequestsForMap } from "../../api/requestApi"; import { fetchRequestsForMap } from "../../api/requestApi";
import { fetchStations, type StationDto } from "../../api/stationsApi";
import type { RequestMapItem } from "../../model/requestTypes"; import type { RequestMapItem } from "../../model/requestTypes";
import type { TguPlanUi, TguPlatformUi, TimelineRange } from "../../model/timelineTypes"; import type { TguPlanUi, TguPlatformUi, TimelineRange } from "../../model/timelineTypes";
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 { 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";
@@ -56,6 +60,18 @@ export function Tgu2DMapTab({
const [selectedObject, setSelectedObject] = useState<Tgu2DMapSelection>(); 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 [stationsFetchFailed, setStationsFetchFailed] = useState(false);
const [requestsAtPoint, setRequestsAtPoint] = useState<RequestAtPoint[]>([]);
const [selectedRequestPolygonId, setSelectedRequestPolygonId] = useState<string | undefined>();
const [hoveredRequestPolygonId, setHoveredRequestPolygonId] = useState<string | undefined>();
const [stationPanel, setStationPanel] = useState<(Tgu2DMapSelection & { type: "marker"; layer: "stations" }) | undefined>();
useEffect(() => {
fetchStations()
.then((items) => { setStations(items); setStationsFetchFailed(false); })
.catch(() => setStationsFetchFailed(true));
}, []);
const mapInterval = getTgu2DMapIntervalState(range, selectedPlan); const mapInterval = getTgu2DMapIntervalState(range, selectedPlan);
const mapInvalidRange = invalidRange || mapInterval.tooLarge; const mapInvalidRange = invalidRange || mapInterval.tooLarge;
@@ -146,11 +162,15 @@ export function Tgu2DMapTab({
selectedPlan, selectedPlan,
platforms, platforms,
hiddenSpacecraftIds: hiddenIds, hiddenSpacecraftIds: hiddenIds,
requestItems: layers.requests ? requestsState.items : [] requestItems: layers.requests ? requestsState.items : [],
stationItems: layers.stations ? stations : []
}), }),
[mapInterval.range, platforms, selectedPlan, hiddenIds, requestsState.items, layers.requests] [mapInterval.range, platforms, selectedPlan, hiddenIds, requestsState.items, layers.requests, stations, layers.stations]
);
const layerAvailability = useMemo(
() => getLayerAvailability(layers, scene, stationsFetchFailed),
[layers, scene, stationsFetchFailed]
); );
const layerAvailability = useMemo(() => getLayerAvailability(layers, scene), [layers, scene]);
const overlayMessage = invalidRange const overlayMessage = invalidRange
? "Некорректный интервал: from должен быть меньше to." ? "Некорректный интервал: from должен быть меньше to."
@@ -162,6 +182,49 @@ export function Tgu2DMapTab({
setLayers((current) => ({ ...current, [key]: !current[key] })); setLayers((current) => ({ ...current, [key]: !current[key] }));
}; };
const handleObjectSelect = useCallback((selection?: Tgu2DMapSelection) => {
if (selection?.type === "marker" && selection.layer === "stations") {
setStationPanel(selection as Tgu2DMapSelection & { type: "marker"; layer: "stations" });
setRequestsAtPoint([]);
setSelectedRequestPolygonId(undefined);
setHoveredRequestPolygonId(undefined);
setSelectedObject(undefined);
} else {
setStationPanel(undefined);
setSelectedObject(selection);
}
}, []);
const handleRequestsAtPoint = useCallback(
(polygons: MapPolygon[]) => {
if (polygons.length === 0) {
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(() => {
setStationPanel(undefined);
}, []);
return ( return (
<main className="tgu-map-workspace"> <main className="tgu-map-workspace">
<Tgu2DMapToolbar <Tgu2DMapToolbar
@@ -172,7 +235,7 @@ export function Tgu2DMapTab({
onToggleLayer={toggleLayer} onToggleLayer={toggleLayer}
/> />
<div className="tgu-map-content"> <div className={`tgu-map-content${(requestsAtPoint.length > 0 || stationPanel != null) ? " has-requests-panel" : ""}`}>
<Tgu2DMapSidebar <Tgu2DMapSidebar
platforms={platforms} platforms={platforms}
selectedPlanId={selectedPlan?.planId} selectedPlanId={selectedPlan?.planId}
@@ -196,9 +259,24 @@ export function Tgu2DMapTab({
scene={mapInvalidRange ? { polygons: [], lines: [], markers: [] } : scene} scene={mapInvalidRange ? { polygons: [], lines: [], markers: [] } : scene}
layers={layers} layers={layers}
redrawToken={redrawToken} redrawToken={redrawToken}
onObjectSelect={setSelectedObject} onObjectSelect={handleObjectSelect}
onRequestsAtPoint={handleRequestsAtPoint}
hoveredRequestPolygonId={hoveredRequestPolygonId}
selectedRequestPolygonId={selectedRequestPolygonId}
/> />
</section> </section>
{requestsAtPoint.length > 0 && (
<Tgu2DMapRequestsPanel
requests={requestsAtPoint}
selectedPolygonId={selectedRequestPolygonId}
onHover={setHoveredRequestPolygonId}
onSelect={setSelectedRequestPolygonId}
onClose={closeRequestsPanel}
/>
)}
{stationPanel != null && (
<Tgu2DMapStationPanel selection={stationPanel} onClose={closeStationPanel} />
)}
</div> </div>
<div className="tgu-map-errors"> <div className="tgu-map-errors">
@@ -18,7 +18,7 @@ export function Tgu2DMapToolbar({
return ( return (
<div className="tgu-map-toolbar"> <div className="tgu-map-toolbar">
<div> <div>
<div className="tgu-map-toolbar__title">Карта 2D</div> <div className="tgu-map-toolbar__title">Карта</div>
<div className="tgu-map-toolbar__subtitle"> <div className="tgu-map-toolbar__subtitle">
{satelliteLabel} · {intervalLabel} {satelliteLabel} · {intervalLabel}
</div> </div>
@@ -7,9 +7,9 @@ import { drawSwath } from "./canvas/drawSwath";
import { drawTiles } from "./canvas/drawTiles"; import { drawTiles } from "./canvas/drawTiles";
import { drawTracks } from "./canvas/drawTracks"; import { drawTracks } from "./canvas/drawTracks";
import { createMapProjection } from "./geometry/mapProjection"; import { createMapProjection } from "./geometry/mapProjection";
import { createPolygonSpatialIndex, hitTestPolygons, queryPolygonsByBBox } from "./geometry/spatialIndex"; import { createPolygonSpatialIndex, hitTestAllPolygons, hitTestPolygons, queryPolygonsByBBox } from "./geometry/spatialIndex";
import type { Tgu2DMapLayersState } from "./model/mapLayerTypes"; import type { Tgu2DMapLayersState } from "./model/mapLayerTypes";
import type { MapPolygon, MapSize, MapViewState, ScreenPoint, Tgu2DMapScene } from "./model/mapTypes"; import type { GeoPoint, MapMarker, MapPolygon, MapSize, MapViewState, ScreenPoint, Tgu2DMapScene } from "./model/mapTypes";
import type { Tgu2DMapSelection } from "./model/mapSelectionTypes"; import type { Tgu2DMapSelection } from "./model/mapSelectionTypes";
type Tgu2DMapViewProps = { type Tgu2DMapViewProps = {
@@ -17,12 +17,18 @@ type Tgu2DMapViewProps = {
layers: Tgu2DMapLayersState; layers: Tgu2DMapLayersState;
redrawToken: number; redrawToken: number;
onObjectSelect: (selection?: Tgu2DMapSelection) => void; onObjectSelect: (selection?: Tgu2DMapSelection) => void;
onRequestsAtPoint?: (polygons: MapPolygon[]) => void;
hoveredRequestPolygonId?: string;
selectedRequestPolygonId?: string;
pickMode?: boolean; pickMode?: boolean;
onPickPoint?: (point: { lat: number; lon: number }) => void; onPickPoint?: (point: { lat: number; lon: number }) => void;
targets?: Tgu2DMapTarget[]; targets?: Tgu2DMapTarget[];
pickTarget?: Tgu2DMapTarget; pickTarget?: Tgu2DMapTarget;
selectedTargetId?: string; selectedTargetId?: string;
onTargetSelect?: (id: string) => void; onTargetSelect?: (id: string) => void;
drawMode?: boolean;
draftPolygon?: GeoPoint[];
onDrawAddPoint?: (point: GeoPoint) => void;
}; };
export type Tgu2DMapTarget = { export type Tgu2DMapTarget = {
@@ -59,17 +65,26 @@ export function Tgu2DMapView({
layers, layers,
redrawToken, redrawToken,
onObjectSelect, onObjectSelect,
onRequestsAtPoint,
hoveredRequestPolygonId,
selectedRequestPolygonId,
pickMode = false, pickMode = false,
onPickPoint, onPickPoint,
targets = [], targets = [],
pickTarget, pickTarget,
selectedTargetId, selectedTargetId,
onTargetSelect onTargetSelect,
drawMode = false,
draftPolygon = [],
onDrawAddPoint
}: Tgu2DMapViewProps) { }: Tgu2DMapViewProps) {
const wrapperRef = useRef<HTMLDivElement | null>(null); const wrapperRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null); const canvasRef = useRef<HTMLCanvasElement | null>(null);
const dragRef = useRef<DragState | undefined>(undefined); const dragRef = useRef<DragState | undefined>(undefined);
const hasDraggedRef = useRef(false);
const hoverRef = useRef<MapPolygon | undefined>(undefined); const hoverRef = useRef<MapPolygon | undefined>(undefined);
const hoverMarkerRef = useRef<MapMarker | undefined>(undefined);
const selectedMarkerRef = useRef<MapMarker | undefined>(undefined);
const [size, setSize] = useState<MapSize>({ width: 1000, height: 560 }); const [size, setSize] = useState<MapSize>({ width: 1000, height: 560 });
const [view, setView] = useState<MapViewState>(INITIAL_VIEW); const [view, setView] = useState<MapViewState>(INITIAL_VIEW);
const [tileVersion, setTileVersion] = useState(0); const [tileVersion, setTileVersion] = useState(0);
@@ -81,6 +96,10 @@ export function Tgu2DMapView({
[scene.polygons, layers] [scene.polygons, layers]
); );
const polygonIndex = useMemo(() => createPolygonSpatialIndex(activePolygons), [activePolygons]); const polygonIndex = useMemo(() => createPolygonSpatialIndex(activePolygons), [activePolygons]);
const activeMarkers = useMemo(
() => scene.markers.filter((marker) => layers[marker.layer]),
[scene.markers, layers]
);
const projection = useMemo(() => createMapProjection(view, size), [view, size]); const projection = useMemo(() => createMapProjection(view, size), [view, size]);
const visiblePolygons = useMemo(() => { const visiblePolygons = useMemo(() => {
// When the canvas is wider than the world tile, the entire globe is visible — // When the canvas is wider than the world tile, the entire globe is visible —
@@ -170,11 +189,15 @@ export function Tgu2DMapView({
const updateHover = useCallback( const updateHover = useCallback(
(event: React.PointerEvent) => { (event: React.PointerEvent) => {
const screenPoint = screenPointFromEvent(event); const screenPoint = screenPointFromEvent(event);
const polygon = hitTestPolygons(polygonIndex, projection.unproject(screenPoint)); const marker = hitTestMarkers(activeMarkers, screenPoint, projection);
if (polygon?.id === hoverRef.current?.id) return; const polygon = marker ? undefined : hitTestPolygons(polygonIndex, projection.unproject(screenPoint));
if (marker?.id === hoverMarkerRef.current?.id && polygon?.id === hoverRef.current?.id) return;
hoverMarkerRef.current = marker;
hoverRef.current = polygon; hoverRef.current = polygon;
if (!polygon) {
if (!marker && !polygon) {
setTooltip(undefined); setTooltip(undefined);
return; return;
} }
@@ -182,15 +205,16 @@ export function Tgu2DMapView({
setTooltip({ setTooltip({
x: screenPoint.x + 12, x: screenPoint.x + 12,
y: screenPoint.y + 12, y: screenPoint.y + 12,
title: polygon.name, title: marker ? marker.label : polygon!.name,
subtitle: layerTitle(polygon.layer) subtitle: marker ? "Станция" : layerTitle(polygon!.layer)
}); });
}, },
[polygonIndex, projection, screenPointFromEvent] [activeMarkers, polygonIndex, projection, screenPointFromEvent]
); );
const onPointerDown = (event: React.PointerEvent) => { const onPointerDown = (event: React.PointerEvent) => {
const screenPoint = screenPointFromEvent(event); const screenPoint = screenPointFromEvent(event);
hasDraggedRef.current = false;
dragRef.current = { dragRef.current = {
startX: screenPoint.x, startX: screenPoint.x,
startY: screenPoint.y, startY: screenPoint.y,
@@ -199,6 +223,8 @@ export function Tgu2DMapView({
wrapperRef.current?.setPointerCapture(event.pointerId); wrapperRef.current?.setPointerCapture(event.pointerId);
}; };
const DRAG_THRESHOLD = 4;
const onPointerMove = (event: React.PointerEvent) => { const onPointerMove = (event: React.PointerEvent) => {
const drag = dragRef.current; const drag = dragRef.current;
if (!drag) { if (!drag) {
@@ -209,6 +235,9 @@ export function Tgu2DMapView({
const screenPoint = screenPointFromEvent(event); const screenPoint = screenPointFromEvent(event);
const dx = screenPoint.x - drag.startX; const dx = screenPoint.x - drag.startX;
const dy = screenPoint.y - drag.startY; const dy = screenPoint.y - drag.startY;
if (!hasDraggedRef.current && Math.sqrt(dx * dx + dy * dy) > DRAG_THRESHOLD) {
hasDraggedRef.current = true;
}
const dragProjection = createMapProjection( const dragProjection = createMapProjection(
{ {
centerLon: drag.centerAtStart.lon, centerLon: drag.centerAtStart.lon,
@@ -231,15 +260,53 @@ export function Tgu2DMapView({
}; };
const onClick = (event: React.MouseEvent) => { const onClick = (event: React.MouseEvent) => {
if (hasDraggedRef.current) return;
const screenPoint = screenPointFromEvent(event); const screenPoint = screenPointFromEvent(event);
if (drawMode) {
onDrawAddPoint?.(projection.unproject(screenPoint));
return;
}
if (pickMode) { if (pickMode) {
onPickPoint?.(projection.unproject(screenPoint)); onPickPoint?.(projection.unproject(screenPoint));
onObjectSelect(undefined); onObjectSelect(undefined);
return; return;
} }
const polygon = hitTestPolygons(polygonIndex, projection.unproject(screenPoint)); const marker = hitTestMarkers(activeMarkers, screenPoint, projection);
if (marker) {
selectedMarkerRef.current = marker;
selectedPolygonRef.current = undefined;
onRequestsAtPoint?.([]);
onObjectSelect({
type: "marker",
id: marker.id,
name: marker.label,
layer: marker.layer,
stationNumber: marker.stationNumber,
lat: marker.point.lat,
lon: marker.point.lon,
altitude: marker.altitude,
elevationMin: marker.elevationMin,
elevationMax: marker.elevationMax
});
return;
}
const geoPoint = projection.unproject(screenPoint);
const polygon = hitTestPolygons(polygonIndex, geoPoint);
if (polygon?.layer === "requests" && onRequestsAtPoint) {
const allRequests = hitTestAllPolygons(polygonIndex, geoPoint).filter((p) => p.layer === "requests");
onRequestsAtPoint(allRequests);
selectedPolygonRef.current = undefined;
selectedMarkerRef.current = undefined;
onObjectSelect(undefined);
return;
}
onRequestsAtPoint?.([]);
selectedPolygonRef.current = polygon; selectedPolygonRef.current = polygon;
selectedMarkerRef.current = undefined;
if (!polygon) { if (!polygon) {
onObjectSelect(undefined); onObjectSelect(undefined);
@@ -276,11 +343,21 @@ export function Tgu2DMapView({
}; };
const selectedPolygon = selectedPolygonRef.current; const selectedPolygon = selectedPolygonRef.current;
const overlayPolygons = uniquePolygons([selectedPolygon, hoverRef.current]); const selectedMarker = selectedMarkerRef.current;
const externalSelectedPolygon = useMemo(
() => selectedRequestPolygonId ? activePolygons.find((p) => p.id === selectedRequestPolygonId) : undefined,
[selectedRequestPolygonId, activePolygons]
);
const externalHoveredPolygon = useMemo(
() => hoveredRequestPolygonId ? activePolygons.find((p) => p.id === hoveredRequestPolygonId) : undefined,
[hoveredRequestPolygonId, activePolygons]
);
const overlayPolygons = uniquePolygons([externalSelectedPolygon, selectedPolygon, externalHoveredPolygon, hoverRef.current]);
const overlayMarkers = uniqueMarkers([selectedMarker, hoverMarkerRef.current]);
return ( return (
<div <div
className={`tgu-2d-map ${pickMode ? "is-picking" : ""}`} className={`tgu-2d-map ${pickMode || drawMode ? "is-picking" : ""}`}
ref={wrapperRef} ref={wrapperRef}
onClick={onClick} onClick={onClick}
onPointerDown={onPointerDown} onPointerDown={onPointerDown}
@@ -290,6 +367,7 @@ export function Tgu2DMapView({
onPointerLeave={() => { onPointerLeave={() => {
dragRef.current = undefined; dragRef.current = undefined;
hoverRef.current = undefined; hoverRef.current = undefined;
hoverMarkerRef.current = undefined;
setTooltip(undefined); setTooltip(undefined);
}} }}
onWheel={onWheel} onWheel={onWheel}
@@ -299,7 +377,10 @@ export function Tgu2DMapView({
{overlayPolygons.flatMap((polygon) => { {overlayPolygons.flatMap((polygon) => {
const raw = polygon.points.map((point) => projection.project(point)); const raw = polygon.points.map((point) => projection.project(point));
const pts = unwrapX(raw, projection.worldSize); const pts = unwrapX(raw, projection.worldSize);
const cls = polygon.id === selectedPolygon?.id ? "is-selected" : "is-hovered"; const cls =
polygon.id === selectedPolygon?.id || polygon.id === externalSelectedPolygon?.id
? "is-selected"
: "is-hovered";
return ([0, projection.worldSize, -projection.worldSize] as const).flatMap((dx) => { return ([0, projection.worldSize, -projection.worldSize] as const).flatMap((dx) => {
const visible = pts.some((p) => { const visible = pts.some((p) => {
const x = p.x + dx; const x = p.x + dx;
@@ -315,6 +396,11 @@ export function Tgu2DMapView({
); );
}); });
})} })}
{overlayMarkers.map((marker) => {
const pt = projection.project(marker.point);
const cls = marker.id === selectedMarker?.id ? "is-selected" : "is-hovered";
return <circle key={marker.id} cx={pt.x} cy={pt.y} r={10} className={cls} />;
})}
{targets.map((t) => { {targets.map((t) => {
const screen = projection.project(t); const screen = projection.project(t);
const isSelected = t.id === selectedTargetId; const isSelected = t.id === selectedTargetId;
@@ -344,6 +430,22 @@ export function Tgu2DMapView({
</g> </g>
); );
})()} })()}
{draftPolygon.length > 0 && (() => {
const pts = unwrapX(draftPolygon.map((point) => projection.project(point)), projection.worldSize);
const path = pts.map((p) => `${p.x},${p.y}`).join(" ");
return (
<g className="tgu-2d-map__draft">
{pts.length >= 2 && (
pts.length >= 3
? <polygon className="tgu-2d-map__draft-fill" points={path} />
: <polyline className="tgu-2d-map__draft-line" points={path} />
)}
{pts.map((p, i) => (
<circle key={i} cx={p.x} cy={p.y} r={4} className="tgu-2d-map__draft-vertex" />
))}
</g>
);
})()}
</svg> </svg>
{tooltip && ( {tooltip && (
<div className="tgu-2d-map__tooltip" style={{ left: tooltip.x, top: tooltip.y }}> <div className="tgu-2d-map__tooltip" style={{ left: tooltip.x, top: tooltip.y }}>
@@ -388,6 +490,36 @@ function unwrapX(points: { x: number; y: number }[], worldSize: number): { x: nu
return out; return out;
} }
function hitTestMarkers(
markers: MapMarker[],
screenPoint: ScreenPoint,
projection: ReturnType<typeof createMapProjection>,
hitRadius = 10
): MapMarker | undefined {
let best: MapMarker | undefined;
let bestDist = hitRadius;
for (const marker of markers) {
const pt = projection.project(marker.point);
const dist = Math.sqrt((pt.x - screenPoint.x) ** 2 + (pt.y - screenPoint.y) ** 2);
if (dist <= bestDist) {
bestDist = dist;
best = marker;
}
}
return best;
}
function uniqueMarkers(markers: Array<MapMarker | undefined>): MapMarker[] {
const result: MapMarker[] = [];
const ids = new Set<string>();
for (const marker of markers) {
if (!marker || ids.has(marker.id)) continue;
ids.add(marker.id);
result.push(marker);
}
return result;
}
function uniquePolygons(polygons: Array<MapPolygon | undefined>): MapPolygon[] { function uniquePolygons(polygons: Array<MapPolygon | undefined>): MapPolygon[] {
const result: MapPolygon[] = []; const result: MapPolygon[] = [];
const ids = new Set<string>(); const ids = new Set<string>();
@@ -20,6 +20,28 @@ export function createPolygonSpatialIndex(polygons: MapPolygon[]): PolygonSpatia
}; };
} }
export function hitTestAllPolygons(index: PolygonSpatialIndex, point: GeoPoint): MapPolygon[] {
const hits: MapPolygon[] = [];
for (const entry of index.entries) {
const inBbox =
bboxContainsPoint(entry.bbox, point) ||
bboxContainsPoint(entry.bbox, { lon: point.lon + 360, lat: point.lat }) ||
bboxContainsPoint(entry.bbox, { lon: point.lon - 360, lat: point.lat });
if (!inBbox) continue;
const normalized = unwrapLon(entry.polygon.points);
const hit =
pointInPolygon(point, normalized) ||
pointInPolygon({ lon: point.lon + 360, lat: point.lat }, normalized) ||
pointInPolygon({ lon: point.lon - 360, lat: point.lat }, normalized);
if (hit) hits.push(entry.polygon);
}
return hits.sort((a, b) => b.zIndex - a.zIndex);
}
export function hitTestPolygons(index: PolygonSpatialIndex, point: GeoPoint): MapPolygon | undefined { export function hitTestPolygons(index: PolygonSpatialIndex, point: GeoPoint): MapPolygon | undefined {
let selected: MapPolygon | undefined; let selected: MapPolygon | undefined;
@@ -9,31 +9,16 @@ export type Tgu2DMapLayerDefinition = {
}; };
export const TGU_2D_MAP_LAYERS: Tgu2DMapLayerDefinition[] = [ export const TGU_2D_MAP_LAYERS: Tgu2DMapLayerDefinition[] = [
{
key: "tracks",
label: "Трасса КА",
description: "Проекция движения КА на поверхность."
},
{
key: "swath",
label: "Полоса обзора",
description: "Массовые полосы обзора через canvas."
},
{ {
key: "planWorks", key: "planWorks",
label: "Работы плана", label: "Работы плана",
description: "Полигоны работ выбранного плана." description: "Маршруты съёмки из выбранного плана."
}, },
{ {
key: "stations", key: "stations",
label: "Станции", label: "Станции",
description: "Наземные станции приёма." description: "Наземные станции приёма."
}, },
{
key: "spacecraftMarkers",
label: "Маркеры КА",
description: "Текущие или расчётные позиции КА."
},
{ {
key: "requests", key: "requests",
label: "Заявки на съёмку", label: "Заявки на съёмку",
@@ -42,11 +27,11 @@ export const TGU_2D_MAP_LAYERS: Tgu2DMapLayerDefinition[] = [
]; ];
export const DEFAULT_TGU_2D_MAP_LAYERS: Tgu2DMapLayersState = { export const DEFAULT_TGU_2D_MAP_LAYERS: Tgu2DMapLayersState = {
tracks: true, tracks: false,
swath: true, swath: false,
planWorks: true, planWorks: true,
stations: true, stations: true,
spacecraftMarkers: true, spacecraftMarkers: false,
requests: true requests: true
}; };
@@ -18,5 +18,11 @@ export type Tgu2DMapSelection =
name: string; name: string;
layer: "stations" | "spacecraftMarkers"; layer: "stations" | "spacecraftMarkers";
spacecraftId?: string; spacecraftId?: string;
stationNumber?: number;
lat?: number;
lon?: number;
altitude?: number;
elevationMin?: number;
elevationMax?: number;
}; };
@@ -50,6 +50,10 @@ export type MapMarker = {
point: GeoPoint; point: GeoPoint;
label: string; label: string;
zIndex: number; zIndex: number;
stationNumber?: number;
altitude?: number;
elevationMin?: number;
elevationMax?: number;
}; };
export type Tgu2DMapScene = { export type Tgu2DMapScene = {
@@ -45,13 +45,13 @@ describe("tgu2DMapInterval", () => {
}); });
}); });
it("enables all requested 2D map layers by default", () => { it("enables plan, stations and requests layers by default", () => {
expect(DEFAULT_TGU_2D_MAP_LAYERS).toEqual({ expect(DEFAULT_TGU_2D_MAP_LAYERS).toEqual({
tracks: true, tracks: false,
swath: true, swath: false,
planWorks: true, planWorks: true,
stations: true, stations: true,
spacecraftMarkers: true, spacecraftMarkers: false,
requests: true requests: true
}); });
}); });
@@ -4,6 +4,7 @@ import type { TguPlanDecision, TguPlatform } from "../../model/tguTypes";
import type { TguActiveTab, TguPlanUi, TimelineRange } from "../../model/timelineTypes"; import type { TguActiveTab, TguPlanUi, TimelineRange } from "../../model/timelineTypes";
import { TguEditorTab } from "../tgu-editor/TguEditorTab"; import { TguEditorTab } from "../tgu-editor/TguEditorTab";
import { Tgu2DMapTab } from "../tgu-map-2d/Tgu2DMapTab"; import { Tgu2DMapTab } from "../tgu-map-2d/Tgu2DMapTab";
import { TguRequestsTab } from "../tgu-requests/TguRequestsTab";
import { TguPlanDetails } from "./TguPlanDetails"; import { TguPlanDetails } from "./TguPlanDetails";
import { TguPlanningLayout } from "./TguPlanningLayout"; import { TguPlanningLayout } from "./TguPlanningLayout";
import { TguSidebar } from "./TguSidebar"; import { TguSidebar } from "./TguSidebar";
@@ -114,14 +115,6 @@ export function TguPlanningPage() {
setDecisionNotice(undefined); setDecisionNotice(undefined);
}; };
const refresh = () => {
if (invalidRange) {
setPageError("Некорректный интервал: from должен быть меньше to.");
return;
}
void loadData(appliedRange);
};
const handleDecision = async (planId: string, decision: TguPlanDecision, reason?: string) => { const handleDecision = async (planId: string, decision: TguPlanDecision, reason?: string) => {
setDecisionInFlight(true); setDecisionInFlight(true);
setDecisionError(undefined); setDecisionError(undefined);
@@ -156,7 +149,6 @@ export function TguPlanningPage() {
onToChange={setToValue} onToChange={setToValue}
onApply={applyRange} onApply={applyRange}
onQuickRange={quickRange} onQuickRange={quickRange}
onRefresh={refresh}
/> />
} }
> >
@@ -205,6 +197,12 @@ export function TguPlanningPage() {
selectedPlan={selectedPlan} selectedPlan={selectedPlan}
platforms={platforms} platforms={platforms}
/> />
) : activeTab === "requests" ? (
<TguRequestsTab
range={appliedRange}
invalidRange={invalidRange}
platforms={platforms}
/>
) : ( ) : (
<TguEditorTab <TguEditorTab
selectedSpacecraftId={selectedPlan?.spacecraftId ?? selectedSpacecraftId} selectedSpacecraftId={selectedPlan?.spacecraftId ?? selectedSpacecraftId}
@@ -1,5 +1,6 @@
import type { TguPlatformUi } from "../../model/timelineTypes"; import type { TguPlatformUi } from "../../model/timelineTypes";
import { filterPlatforms, platformLabel } from "./tguTimelineMapper"; import { filterPlatforms, platformLabel } from "./tguTimelineMapper";
import { TguLegend } from "./TguLegend";
type TguSidebarProps = { type TguSidebarProps = {
platforms: TguPlatformUi[]; platforms: TguPlatformUi[];
@@ -69,6 +70,10 @@ export function TguSidebar({
})} })}
{filtered.length === 0 && <div className="tgu-empty tgu-empty--compact">Нет КА по текущему поиску.</div>} {filtered.length === 0 && <div className="tgu-empty tgu-empty--compact">Нет КА по текущему поиску.</div>}
</div> </div>
<div className="tgu-sidebar__legend">
<TguLegend />
</div>
</aside> </aside>
); );
} }
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useLayoutEffect, useMemo, useRef, useState } from "react";
import type { TimelinePlanSegment, TimelineRange, TimelineRow } from "../../model/timelineTypes"; import type { TimelinePlanSegment, TimelineRange, TimelineRow } from "../../model/timelineTypes";
import { import {
buildSequentialLinks, buildSequentialLinks,
@@ -30,9 +30,11 @@ export function TguTimeline({ rows, range, selectedPlanId, invalidRange, onSelec
const height = Math.max(260, timelineHeight(rows.length)); const height = Math.max(260, timelineHeight(rows.length));
const nowMs = Date.now(); const nowMs = Date.now();
useEffect(() => { useLayoutEffect(() => {
if (!containerRef.current) return; if (!containerRef.current) return;
setContainerWidth(Math.floor(containerRef.current.getBoundingClientRect().width));
const observer = new ResizeObserver(([entry]) => { const observer = new ResizeObserver(([entry]) => {
setContainerWidth(Math.floor(entry.contentRect.width)); setContainerWidth(Math.floor(entry.contentRect.width));
}); });
@@ -55,17 +57,14 @@ export function TguTimeline({ rows, range, selectedPlanId, invalidRange, onSelec
? TIMELINE_LABEL_WIDTH + timeToX(nowMs, range, timelineWidth) ? TIMELINE_LABEL_WIDTH + timeToX(nowMs, range, timelineWidth)
: undefined; : undefined;
if (invalidRange) {
return <div className="tgu-empty">Некорректный интервал: from должен быть меньше to.</div>;
}
if (rows.length === 0 || segments.length === 0) {
return <div className="tgu-empty">Нет планов в выбранном интервале.</div>;
}
return ( return (
<div className="tgu-timeline" ref={containerRef}> <div className="tgu-timeline" ref={containerRef}>
<svg className="tgu-timeline__svg" viewBox={`0 0 ${fullWidth} ${height}`} role="img" aria-label="Timeline планов ТГУ"> {invalidRange ? (
<div className="tgu-empty">Некорректный интервал: from должен быть меньше to.</div>
) : rows.length === 0 || segments.length === 0 ? (
<div className="tgu-empty">Нет планов в выбранном интервале.</div>
) : (
<svg className="tgu-timeline__svg" viewBox={`0 0 ${fullWidth} ${height}`} style={{ height }} role="img" aria-label="Timeline планов ТГУ">
<defs> <defs>
<marker id="tgu-arrow-head" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"> <marker id="tgu-arrow-head" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">
<path d="M0 0 L8 4 L0 8 z" fill="#8aa2a6" /> <path d="M0 0 L8 4 L0 8 z" fill="#8aa2a6" />
@@ -175,6 +174,7 @@ export function TguTimeline({ rows, range, selectedPlanId, invalidRange, onSelec
); );
})} })}
</svg> </svg>
)}
</div> </div>
); );
} }
@@ -1,4 +1,3 @@
import { TguLegend } from "./TguLegend";
import type { TguActiveTab } from "../../model/timelineTypes"; import type { TguActiveTab } from "../../model/timelineTypes";
type TguToolbarProps = { type TguToolbarProps = {
@@ -12,7 +11,6 @@ type TguToolbarProps = {
onToChange: (value: string) => void; onToChange: (value: string) => void;
onApply: () => void; onApply: () => void;
onQuickRange: (hours: number) => void; onQuickRange: (hours: number) => void;
onRefresh: () => void;
}; };
export function TguToolbar({ export function TguToolbar({
@@ -25,8 +23,7 @@ export function TguToolbar({
onFromChange, onFromChange,
onToChange, onToChange,
onApply, onApply,
onQuickRange, onQuickRange
onRefresh
}: TguToolbarProps) { }: TguToolbarProps) {
return ( return (
<header className="tgu-toolbar"> <header className="tgu-toolbar">
@@ -56,7 +53,7 @@ export function TguToolbar({
aria-selected={activeTab === "map"} aria-selected={activeTab === "map"}
onClick={() => onTabChange("map")} onClick={() => onTabChange("map")}
> >
Карта 2D Карта
</button> </button>
<button <button
className={activeTab === "editor" ? "is-active" : ""} className={activeTab === "editor" ? "is-active" : ""}
@@ -67,6 +64,15 @@ export function TguToolbar({
> >
Редактор Редактор
</button> </button>
<button
className={activeTab === "requests" ? "is-active" : ""}
type="button"
role="tab"
aria-selected={activeTab === "requests"}
onClick={() => onTabChange("requests")}
>
Заявки
</button>
</div> </div>
<label className="tgu-field"> <label className="tgu-field">
@@ -90,11 +96,7 @@ export function TguToolbar({
<button className="tgu-button" type="button" onClick={() => onQuickRange(168)} disabled={loading}> <button className="tgu-button" type="button" onClick={() => onQuickRange(168)} disabled={loading}>
7 суток 7 суток
</button> </button>
<button className="tgu-button" type="button" onClick={onRefresh} disabled={loading}>
{loading ? "Обновление..." : "Обновить"}
</button>
</div> </div>
<TguLegend />
{error && <div className="tgu-alert tgu-alert--danger">{error}</div>} {error && <div className="tgu-alert tgu-alert--danger">{error}</div>}
</header> </header>
); );
@@ -0,0 +1,355 @@
import { useState } from "react";
import type {
OpticsParams,
OpticsResultType,
Polarisation,
RsaParams,
RsaResultType
} from "../../model/requestTypes";
export type RequestFormSubmit = {
name: string;
importance: number;
beginDateTime: string;
endDateTime: string;
kpp: number[];
highPriorityTransmit: boolean;
optics?: OpticsParams;
rsa?: RsaParams;
};
type Props = {
vertexCount: number;
drawing: boolean;
defaultBegin: string;
defaultEnd: string;
submitting: boolean;
error?: string;
onUndoPoint: () => void;
onClear: () => void;
onFinishDrawing: () => void;
onRestartDrawing: () => void;
onSubmit: (form: RequestFormSubmit) => void;
onCancel: () => void;
};
const OPTICS_RESULT_TYPES: { value: OpticsResultType; label: string }[] = [
{ value: "PANCHROMATIC", label: "Панхроматическая" },
{ value: "MULTISPECTRAL", label: "Мультиспектральная" },
{ value: "PANSHARPENING", label: "Паншарпенинг" }
];
const RSA_RESULT_TYPES: { value: RsaResultType; label: string }[] = [
{ value: "SPOTLIGHT", label: "Spotlight" },
{ value: "STRIPMAP", label: "Stripmap" },
{ value: "SCANSAR", label: "ScanSAR" }
];
const POLARISATIONS: { value: Polarisation; label: string }[] = [
{ value: "HH", label: "HH" },
{ value: "VV", label: "VV" }
];
export function RequestCreatePanel({
vertexCount,
drawing,
defaultBegin,
defaultEnd,
submitting,
error,
onUndoPoint,
onClear,
onFinishDrawing,
onRestartDrawing,
onSubmit,
onCancel
}: Props) {
const [name, setName] = useState("");
const [importance, setImportance] = useState("1");
const [begin, setBegin] = useState(defaultBegin);
const [end, setEnd] = useState(defaultEnd);
const [kpp, setKpp] = useState("");
const [highPriority, setHighPriority] = useState(false);
const [opticsEnabled, setOpticsEnabled] = useState(true);
const [opticsResultType, setOpticsResultType] = useState<OpticsResultType>("PANCHROMATIC");
const [opticsResolution, setOpticsResolution] = useState("1");
const [sunAngleMin, setSunAngleMin] = useState("10");
const [sunAngleMax, setSunAngleMax] = useState("90");
const [clouds, setClouds] = useState("100");
const [rsaEnabled, setRsaEnabled] = useState(false);
const [rsaResultType, setRsaResultType] = useState<RsaResultType>("STRIPMAP");
const [interferometry, setInterferometry] = useState(false);
const [polarisation, setPolarisation] = useState<Polarisation>("HH");
const [rsaResolution, setRsaResolution] = useState("1");
const importanceNum = Number(importance);
const beginMs = new Date(begin).getTime();
const endMs = new Date(end).getTime();
const kppList = parseKpp(kpp);
const kppValid = kppList !== null;
const geometryReady = vertexCount >= 3 && !drawing;
const validationError = buildValidationError({
geometryReady,
drawing,
vertexCount,
name,
importance,
importanceNum,
beginMs,
endMs,
kppValid,
opticsEnabled,
opticsResolution,
rsaEnabled,
rsaResolution
});
const handleSubmit = () => {
if (validationError || !kppList) return;
const optics: OpticsParams | undefined = opticsEnabled
? {
resultType: opticsResultType,
resolution: Number(opticsResolution),
sunAngleMin: Number(sunAngleMin),
sunAngleMax: Number(sunAngleMax),
clouds: Number(clouds)
}
: undefined;
const rsa: RsaParams | undefined = rsaEnabled
? {
resultType: rsaResultType,
interferometry,
polarisation,
resolution: Number(rsaResolution)
}
: undefined;
onSubmit({
name: name.trim(),
importance: importanceNum,
beginDateTime: begin,
endDateTime: end,
kpp: kppList,
highPriorityTransmit: highPriority,
optics,
rsa
});
};
return (
<aside className="tgu-map-requests-panel tgu-requests-form">
<div className="tgu-panel-heading">
<span>Новая заявка</span>
<button className="tgu-link-button" type="button" onClick={onCancel}>
Отмена
</button>
</div>
<div className="tgu-requests-form__body">
<section className="tgu-requests-form__section">
<div className="tgu-map-panel__label">Геометрия (полигон)</div>
<div className="tgu-requests-form__hint">
{drawing
? "Кликайте по карте, чтобы добавить вершины. Нажмите «Завершить» (≥3 вершин)."
: geometryReady
? `Полигон задан: ${vertexCount} вершин.`
: "Полигон не задан."}
</div>
<div className="tgu-requests-form__geo-status">Вершин: {vertexCount}</div>
<div className="tgu-requests-form__actions">
{drawing ? (
<>
<button className="tgu-button tgu-button--sm" type="button" onClick={onUndoPoint} disabled={vertexCount === 0}>
Отменить точку
</button>
<button className="tgu-button tgu-button--sm" type="button" onClick={onClear} disabled={vertexCount === 0}>
Очистить
</button>
<button
className="tgu-button tgu-button--primary tgu-button--sm"
type="button"
onClick={onFinishDrawing}
disabled={vertexCount < 3}
>
Завершить
</button>
</>
) : (
<button className="tgu-button tgu-button--sm" type="button" onClick={onRestartDrawing}>
Перерисовать
</button>
)}
</div>
</section>
<section className="tgu-requests-form__section">
<div className="tgu-map-panel__label">Параметры заявки</div>
<label className="tgu-requests-field">
<span>Название</span>
<input type="text" value={name} onChange={(e) => setName(e.target.value)} placeholder="Название задания" />
</label>
<label className="tgu-requests-field">
<span>Важность</span>
<input type="number" min="0" step="0.1" value={importance} onChange={(e) => setImportance(e.target.value)} />
</label>
<label className="tgu-requests-field">
<span>Начало</span>
<input type="datetime-local" value={begin} onChange={(e) => setBegin(e.target.value)} />
</label>
<label className="tgu-requests-field">
<span>Конец</span>
<input type="datetime-local" value={end} onChange={(e) => setEnd(e.target.value)} />
</label>
<label className="tgu-requests-field">
<span>КПП</span>
<input type="text" value={kpp} onChange={(e) => setKpp(e.target.value)} placeholder="напр. 1, 2, 5" />
</label>
<label className="tgu-requests-checkbox">
<input type="checkbox" checked={highPriority} onChange={(e) => setHighPriority(e.target.checked)} />
<span>Высокоприоритетная передача</span>
</label>
</section>
<section className="tgu-requests-form__section">
<label className="tgu-requests-checkbox tgu-requests-form__block-toggle">
<input type="checkbox" checked={opticsEnabled} onChange={(e) => setOpticsEnabled(e.target.checked)} />
<span>Оптическая съёмка</span>
</label>
{opticsEnabled && (
<div className="tgu-requests-form__block">
<label className="tgu-requests-field">
<span>Результат</span>
<select value={opticsResultType} onChange={(e) => setOpticsResultType(e.target.value as OpticsResultType)}>
{OPTICS_RESULT_TYPES.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</label>
<label className="tgu-requests-field">
<span>Разрешение, м</span>
<input type="number" min="0.1" step="0.1" value={opticsResolution} onChange={(e) => setOpticsResolution(e.target.value)} />
</label>
<label className="tgu-requests-field">
<span>Высота Солнца мин, °</span>
<input type="number" min="0" max="90" step="1" value={sunAngleMin} onChange={(e) => setSunAngleMin(e.target.value)} />
</label>
<label className="tgu-requests-field">
<span>Высота Солнца макс, °</span>
<input type="number" min="0" max="90" step="1" value={sunAngleMax} onChange={(e) => setSunAngleMax(e.target.value)} />
</label>
<label className="tgu-requests-field">
<span>Облачность макс, %</span>
<input type="number" min="0" max="100" step="1" value={clouds} onChange={(e) => setClouds(e.target.value)} />
</label>
</div>
)}
</section>
<section className="tgu-requests-form__section">
<label className="tgu-requests-checkbox tgu-requests-form__block-toggle">
<input type="checkbox" checked={rsaEnabled} onChange={(e) => setRsaEnabled(e.target.checked)} />
<span>Радиолокационная съёмка (РСА)</span>
</label>
{rsaEnabled && (
<div className="tgu-requests-form__block">
<label className="tgu-requests-field">
<span>Тип аппаратуры</span>
<select value={rsaResultType} onChange={(e) => setRsaResultType(e.target.value as RsaResultType)}>
{RSA_RESULT_TYPES.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</label>
<label className="tgu-requests-field">
<span>Поляризация</span>
<select value={polarisation} onChange={(e) => setPolarisation(e.target.value as Polarisation)}>
{POLARISATIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</label>
<label className="tgu-requests-field">
<span>Разрешение, м</span>
<input type="number" min="0.1" step="0.1" value={rsaResolution} onChange={(e) => setRsaResolution(e.target.value)} />
</label>
<label className="tgu-requests-checkbox">
<input type="checkbox" checked={interferometry} onChange={(e) => setInterferometry(e.target.checked)} />
<span>Интерферометрия</span>
</label>
</div>
)}
</section>
{(validationError || error) && (
<div className="tgu-alert tgu-alert--danger tgu-alert--compact">{error ?? validationError}</div>
)}
</div>
<div className="tgu-requests-form__footer">
<button
className="tgu-button tgu-button--primary"
type="button"
disabled={submitting || validationError != null}
onClick={handleSubmit}
>
{submitting ? "Создание..." : "Создать заявку"}
</button>
</div>
</aside>
);
}
function parseKpp(raw: string): number[] | null {
const trimmed = raw.trim();
if (trimmed === "") return [];
const parts = trimmed.split(/[,\s]+/).filter(Boolean);
const result: number[] = [];
for (const part of parts) {
const n = Number(part);
if (!Number.isInteger(n) || n < 0) return null;
result.push(n);
}
return result;
}
function buildValidationError(input: {
geometryReady: boolean;
drawing: boolean;
vertexCount: number;
name: string;
importance: string;
importanceNum: number;
beginMs: number;
endMs: number;
kppValid: boolean;
opticsEnabled: boolean;
opticsResolution: string;
rsaEnabled: boolean;
rsaResolution: string;
}): string | null {
if (input.drawing) return "Завершите рисование полигона (≥3 вершин).";
if (!input.geometryReady) return "Задайте полигон минимум из 3 вершин.";
if (input.name.trim() === "") return "Укажите название заявки.";
if (input.importance.trim() === "" || !Number.isFinite(input.importanceNum) || input.importanceNum < 0) {
return "Важность должна быть числом ≥ 0.";
}
if (!Number.isFinite(input.beginMs) || !Number.isFinite(input.endMs) || input.beginMs >= input.endMs) {
return "Начало должно быть раньше конца.";
}
if (!input.kppValid) return "КПП — список целых чисел через запятую.";
if (!input.opticsEnabled && !input.rsaEnabled) {
return "Включите хотя бы один блок съёмки: Оптика или РСА.";
}
if (input.opticsEnabled && !(Number(input.opticsResolution) >= 0.1)) {
return "Разрешение оптики должно быть ≥ 0.1 м.";
}
if (input.rsaEnabled && !(Number(input.rsaResolution) >= 0.1)) {
return "Разрешение РСА должно быть ≥ 0.1 м.";
}
return null;
}
@@ -0,0 +1,142 @@
import type { RequestAtPoint } from "../tgu-map-2d/Tgu2DMapRequestsPanel";
type Props = {
requests: RequestAtPoint[];
selectedPolygonId?: string;
deleting: boolean;
deleteError?: string;
onHover: (polygonId: string | undefined) => void;
onSelect: (polygonId: string) => void;
onDelete: (requestId: string) => void;
onClose: () => void;
};
export function RequestDetailsPanel({
requests,
selectedPolygonId,
deleting,
deleteError,
onHover,
onSelect,
onDelete,
onClose
}: Props) {
const selectedEntry = selectedPolygonId
? requests.find((r) => r.polygon.id === selectedPolygonId)
: requests.length === 1
? requests[0]
: 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>
{requests.length > 1 && (
<div className="tgu-map-requests-list">
{requests.map(({ polygon, item }) => (
<button
key={polygon.id}
className={`tgu-map-request-row ${polygon.id === selectedEntry?.polygon.id ? "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">
{formatDateTime(item.beginDateTime)} · {item.surveyType} · {item.status}
</span>
</span>
</button>
))}
</div>
)}
{selectedEntry && (
<div className="tgu-map-request-details" style={{ flex: 1 }}>
<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>{surveyTypeLabel(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>
{deleteError && <div className="tgu-alert tgu-alert--danger tgu-alert--compact">{deleteError}</div>}
<div className="tgu-requests-form__actions">
<button
className="tgu-button tgu-button--reject"
type="button"
disabled={deleting}
onClick={() => {
if (window.confirm(`Удалить заявку «${selectedEntry.item.name || selectedEntry.item.id}»?`)) {
onDelete(selectedEntry.item.id);
}
}}
>
{deleting ? "Удаление..." : "Удалить заявку"}
</button>
</div>
</div>
)}
</aside>
);
}
function formatDateTime(value: string): string {
return new Date(value).toLocaleString("ru-RU");
}
function surveyTypeLabel(surveyType: string): string {
switch (surveyType) {
case "OPTICS":
return "Оптическая";
case "RSA":
return "Радиолокационная (РСА)";
case "COMBINED":
return "Комбинированная";
default:
return surveyType;
}
}
@@ -0,0 +1,420 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { createRequest, deleteRequest, fetchRequestsForMap } from "../../api/requestApi";
import { fetchStations, type StationDto } from "../../api/stationsApi";
import type { RequestMapItem } from "../../model/requestTypes";
import type { TguPlatformUi, TimelineRange } from "../../model/timelineTypes";
import { buildTgu2DMapScene } from "../tgu-map-2d/Tgu2DMapLayers";
import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView";
import type { RequestAtPoint } from "../tgu-map-2d/Tgu2DMapRequestsPanel";
import type { Tgu2DMapSelection } from "../tgu-map-2d/model/mapSelectionTypes";
import type { GeoPoint, MapPolygon } from "../tgu-map-2d/model/mapTypes";
import { RequestCreatePanel, type RequestFormSubmit } from "./RequestCreatePanel";
import { RequestDetailsPanel } from "./RequestDetailsPanel";
import { polygonToWkt } from "./requestGeometry";
type TguRequestsTabProps = {
range: TimelineRange;
invalidRange: boolean;
platforms: TguPlatformUi[];
};
type RequestsLoadState = {
fromValue: string;
toValue: string;
items: RequestMapItem[];
loading: boolean;
loadedCount: number;
totalItems: number;
truncated: boolean;
error?: string;
loaded: boolean;
};
type PanelMode = "none" | "details" | "create";
function buildInitialLoadState(range: TimelineRange): RequestsLoadState {
return {
fromValue: formatDateTimeLocal(range.fromMs),
toValue: formatDateTimeLocal(range.toMs),
items: [],
loading: false,
loadedCount: 0,
totalItems: 0,
truncated: false,
loaded: false
};
}
export function TguRequestsTab({ range, invalidRange, platforms }: TguRequestsTabProps) {
const [requestsState, setRequestsState] = useState<RequestsLoadState>(() => buildInitialLoadState(range));
const [stations, setStations] = useState<StationDto[]>([]);
const [showStations, setShowStations] = useState(true);
const [redrawToken, setRedrawToken] = useState(0);
const [panelMode, setPanelMode] = useState<PanelMode>("none");
const [requestsAtPoint, setRequestsAtPoint] = useState<RequestAtPoint[]>([]);
const [selectedRequestPolygonId, setSelectedRequestPolygonId] = useState<string | undefined>();
const [hoveredRequestPolygonId, setHoveredRequestPolygonId] = useState<string | undefined>();
const [draftPolygon, setDraftPolygon] = useState<GeoPoint[]>([]);
const [drawing, setDrawing] = useState(false);
const [creating, setCreating] = useState(false);
const [createError, setCreateError] = useState<string>();
const [deleting, setDeleting] = useState(false);
const [deleteError, setDeleteError] = useState<string>();
const [notice, setNotice] = useState<string>();
useEffect(() => {
fetchStations()
.then((items) => setStations(items))
.catch(() => setStations([]));
}, []);
const loadRequests = useCallback(async (fromValue: string, toValue: string) => {
const fromMs = new Date(fromValue).getTime();
const toMs = new Date(toValue).getTime();
if (!Number.isFinite(fromMs) || !Number.isFinite(toMs) || fromMs >= toMs) return;
setRequestsState((current) => ({ ...current, loading: true, error: undefined, loadedCount: 0 }));
try {
const result = await fetchRequestsForMap(
{ endFrom: new Date(fromMs).toISOString(), beginTo: new Date(toMs).toISOString() },
(loaded, total) => setRequestsState((current) => ({ ...current, loadedCount: loaded, totalItems: total }))
);
setRequestsState((current) => ({
...current,
items: result.items,
loadedCount: result.items.length,
totalItems: result.totalItems,
truncated: result.truncated,
loading: false,
loaded: true
}));
} catch (error) {
setRequestsState((current) => ({
...current,
loading: false,
error: error instanceof Error ? error.message : "Не удалось загрузить заявки."
}));
}
}, []);
useEffect(() => {
setRequestsState(buildInitialLoadState(range));
}, [range]);
const applyRange = useCallback(() => {
void loadRequests(requestsState.fromValue, requestsState.toValue);
}, [loadRequests, requestsState.fromValue, requestsState.toValue]);
const scene = useMemo(
() =>
buildTgu2DMapScene({
range,
platforms,
requestItems: requestsState.items,
stationItems: showStations ? stations : []
}),
[range, platforms, requestsState.items, showStations, stations]
);
const layers = useMemo(
() => ({ tracks: false, swath: false, planWorks: false, stations: showStations, spacecraftMarkers: false, requests: true }),
[showStations]
);
const closeAll = useCallback(() => {
setRequestsAtPoint([]);
setSelectedRequestPolygonId(undefined);
setHoveredRequestPolygonId(undefined);
setDeleteError(undefined);
}, []);
const handleRequestsAtPoint = useCallback(
(polygons: MapPolygon[]) => {
if (panelMode === "create") return;
if (polygons.length === 0) {
closeAll();
setPanelMode("none");
return;
}
const joined = polygons.flatMap((polygon) => {
const item = requestsState.items.find((r) => r.id === polygon.requestId);
return item ? [{ polygon, item }] : [];
});
setRequestsAtPoint(joined);
setSelectedRequestPolygonId(joined.length === 1 ? joined[0].polygon.id : undefined);
setHoveredRequestPolygonId(undefined);
setDeleteError(undefined);
setPanelMode(joined.length > 0 ? "details" : "none");
},
[panelMode, requestsState.items, closeAll]
);
// Детали заявок открываются через onRequestsAtPoint. onObjectSelect карта
// вызывает с undefined одновременно с onRequestsAtPoint, поэтому здесь no-op,
// чтобы не затирать только что открытую панель (станции/пустой клик уже
// закрывают панель через onRequestsAtPoint([])).
const handleObjectSelect = useCallback((_selection?: Tgu2DMapSelection) => {}, []);
const startCreate = useCallback(() => {
closeAll();
setDraftPolygon([]);
setDrawing(true);
setCreateError(undefined);
setNotice(undefined);
setPanelMode("create");
}, [closeAll]);
const cancelCreate = useCallback(() => {
setDraftPolygon([]);
setDrawing(false);
setCreateError(undefined);
setPanelMode("none");
}, []);
const handleDrawAddPoint = useCallback(
(point: GeoPoint) => {
if (panelMode !== "create" || !drawing) return;
setDraftPolygon((current) => [...current, point]);
},
[panelMode, drawing]
);
const submitCreate = useCallback(
async (form: RequestFormSubmit) => {
let geometry: string;
try {
geometry = polygonToWkt(draftPolygon);
} catch (error) {
setCreateError(error instanceof Error ? error.message : "Некорректная геометрия.");
return;
}
setCreating(true);
setCreateError(undefined);
try {
const id = crypto.randomUUID();
await createRequest({
id,
name: form.name,
geometry,
importance: form.importance,
beginDateTime: new Date(form.beginDateTime).toISOString(),
endDateTime: new Date(form.endDateTime).toISOString(),
kpp: form.kpp,
highPriorityTransmit: form.highPriorityTransmit,
optics: form.optics,
rsa: form.rsa
});
setDraftPolygon([]);
setDrawing(false);
setPanelMode("none");
setNotice(`Заявка «${form.name}» создана.`);
void loadRequests(requestsState.fromValue, requestsState.toValue);
} catch (error) {
setCreateError(error instanceof Error ? error.message : "Не удалось создать заявку.");
} finally {
setCreating(false);
}
},
[draftPolygon, loadRequests, requestsState.fromValue, requestsState.toValue]
);
const handleDelete = useCallback(
async (requestId: string) => {
setDeleting(true);
setDeleteError(undefined);
try {
await deleteRequest(requestId);
closeAll();
setPanelMode("none");
setNotice("Заявка удалена.");
void loadRequests(requestsState.fromValue, requestsState.toValue);
} catch (error) {
setDeleteError(error instanceof Error ? error.message : "Не удалось удалить заявку.");
} finally {
setDeleting(false);
}
},
[closeAll, loadRequests, requestsState.fromValue, requestsState.toValue]
);
const fromMs = new Date(requestsState.fromValue).getTime();
const toMs = new Date(requestsState.toValue).getTime();
const rangeInvalid = !Number.isFinite(fromMs) || !Number.isFinite(toMs) || fromMs >= toMs;
const hasPanel = panelMode !== "none";
return (
<main className="tgu-map-workspace">
<div className="tgu-map-toolbar">
<div>
<div className="tgu-map-toolbar__title">Заявки</div>
<div className="tgu-map-toolbar__subtitle">
{requestsState.loaded ? `Загружено: ${requestsState.items.length}` : "Заявки не загружены"}
</div>
</div>
<div className="tgu-map-toolbar__toggles">
<button className="is-active" type="button" disabled>
Заявки на съёмку
</button>
<button className={showStations ? "is-active" : ""} type="button" onClick={() => setShowStations((v) => !v)}>
Станции
</button>
</div>
<button
className="tgu-button tgu-button--primary"
type="button"
onClick={startCreate}
disabled={panelMode === "create"}
>
+ Создать заявку
</button>
<button className="tgu-button" type="button" onClick={() => setRedrawToken((v) => v + 1)}>
Перерисовать
</button>
</div>
<div className={`tgu-map-content${hasPanel ? " has-side-panel" : ""}`}>
<aside className="tgu-map-panel">
<div className="tgu-panel-heading">
<span>Заявки за интервал</span>
</div>
<div className="tgu-map-panel__section">
<div className="tgu-map-requests-filter">
<label className="tgu-field">
<span>С</span>
<input
type="datetime-local"
value={requestsState.fromValue}
onChange={(e) => setRequestsState((cur) => ({ ...cur, fromValue: e.target.value }))}
/>
</label>
<label className="tgu-field">
<span>По</span>
<input
type="datetime-local"
value={requestsState.toValue}
onChange={(e) => setRequestsState((cur) => ({ ...cur, toValue: e.target.value }))}
/>
</label>
<div className="tgu-map-requests-filter__actions">
<button
className="tgu-button tgu-button--primary tgu-button--sm"
type="button"
onClick={applyRange}
disabled={requestsState.loading || rangeInvalid}
>
{requestsState.loading ? "Загрузка..." : "Загрузить"}
</button>
</div>
{requestsState.error && (
<div className="tgu-alert tgu-alert--danger tgu-alert--compact">{requestsState.error}</div>
)}
{requestsState.loading && requestsState.totalItems > 0 && (
<div className="tgu-map-requests-filter__status">
Загрузка: {requestsState.loadedCount} / {requestsState.totalItems}...
</div>
)}
{requestsState.loaded && !requestsState.error && (
<div className="tgu-map-requests-filter__status">
{requestsState.items.length === 0
? "Заявок не найдено."
: `Загружено: ${requestsState.items.length} из ${requestsState.totalItems}`}
{requestsState.truncated && (
<span className="tgu-map-requests-filter__truncated"> (показаны первые 5000 уточните интервал)</span>
)}
</div>
)}
<div className="tgu-map-requests-legend">
<span><i className="req-optics" />OPTICS</span>
<span><i className="req-rsa" />RSA</span>
<span><i className="req-combined" />COMBINED</span>
</div>
</div>
</div>
{notice && (
<div className="tgu-map-panel__section">
<div className="tgu-alert tgu-alert--success tgu-alert--compact">{notice}</div>
</div>
)}
<div className="tgu-map-panel__section">
<div className="tgu-map-panel__label">Создание заявки</div>
<div className="tgu-map-panel__value">
Нажмите «+ Создать заявку», затем нарисуйте полигон на карте кликами и заполните параметры справа.
</div>
</div>
</aside>
<section className="tgu-map-stage">
{invalidRange && <div className="tgu-map-overlay">Некорректный интервал: from должен быть меньше to.</div>}
{panelMode === "create" && drawing && (
<div className="tgu-map-overlay tgu-map-overlay--hint">
Кликайте по карте, чтобы добавить вершины полигона. Нажмите «Завершить» (3 вершин).
</div>
)}
<Tgu2DMapView
scene={scene}
layers={layers}
redrawToken={redrawToken}
onObjectSelect={handleObjectSelect}
onRequestsAtPoint={handleRequestsAtPoint}
hoveredRequestPolygonId={hoveredRequestPolygonId}
selectedRequestPolygonId={selectedRequestPolygonId}
drawMode={panelMode === "create"}
draftPolygon={draftPolygon}
onDrawAddPoint={handleDrawAddPoint}
/>
</section>
{panelMode === "details" && (
<RequestDetailsPanel
requests={requestsAtPoint}
selectedPolygonId={selectedRequestPolygonId}
deleting={deleting}
deleteError={deleteError}
onHover={setHoveredRequestPolygonId}
onSelect={setSelectedRequestPolygonId}
onDelete={handleDelete}
onClose={() => {
closeAll();
setPanelMode("none");
}}
/>
)}
{panelMode === "create" && (
<RequestCreatePanel
vertexCount={draftPolygon.length}
drawing={drawing}
defaultBegin={requestsState.fromValue}
defaultEnd={requestsState.toValue}
submitting={creating}
error={createError}
onUndoPoint={() => setDraftPolygon((cur) => cur.slice(0, -1))}
onClear={() => setDraftPolygon([])}
onFinishDrawing={() => setDrawing(false)}
onRestartDrawing={() => {
setDraftPolygon([]);
setDrawing(true);
}}
onSubmit={submitCreate}
onCancel={cancelCreate}
/>
)}
</div>
</main>
);
}
function formatDateTimeLocal(ms: number): string {
const date = new Date(ms);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
return `${year}-${month}-${day}T${hours}:${minutes}`;
}
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { polygonToWkt } from "./requestGeometry";
describe("polygonToWkt", () => {
it("формирует WKT POLYGON с замыканием кольца", () => {
const wkt = polygonToWkt([
{ lon: 10, lat: 20 },
{ lon: 30, lat: 20 },
{ lon: 30, lat: 40 }
]);
expect(wkt).toBe("POLYGON((10 20, 30 20, 30 40, 10 20))");
});
it("не дублирует замыкающую точку, если кольцо уже замкнуто", () => {
const wkt = polygonToWkt([
{ lon: 0, lat: 0 },
{ lon: 5, lat: 0 },
{ lon: 5, lat: 5 },
{ lon: 0, lat: 0 }
]);
expect(wkt).toBe("POLYGON((0 0, 5 0, 5 5, 0 0))");
});
it("бросает ошибку при менее чем 3 вершинах", () => {
expect(() => polygonToWkt([{ lon: 0, lat: 0 }, { lon: 1, lat: 1 }])).toThrow();
});
});
@@ -0,0 +1,27 @@
import type { GeoPoint } from "../tgu-map-2d/model/mapTypes";
/**
* Преобразует набор вершин полигона в WKT POLYGON.
* Порядок координат WKT — "lon lat". Кольцо замыкается первой вершиной,
* если оно ещё не замкнуто. Требуется минимум 3 вершины.
*/
export function polygonToWkt(points: GeoPoint[]): string {
if (points.length < 3) {
throw new Error("Полигон должен содержать минимум 3 вершины.");
}
const ring = [...points];
const first = ring[0];
const last = ring[ring.length - 1];
if (first.lon !== last.lon || first.lat !== last.lat) {
ring.push(first);
}
const coords = ring.map((p) => `${formatCoord(p.lon)} ${formatCoord(p.lat)}`).join(", ");
return `POLYGON((${coords}))`;
}
function formatCoord(value: number): string {
// ограничиваем разрядность, убираем хвостовые нули
return Number(value.toFixed(6)).toString();
}
@@ -63,3 +63,44 @@ export type RequestMapItem = {
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
}; };
export type OpticsResultType = "PANCHROMATIC" | "MULTISPECTRAL" | "PANSHARPENING";
export type RsaResultType = "SPOTLIGHT" | "STRIPMAP" | "SCANSAR";
export type Polarisation = "HH" | "VV";
export type OpticsParams = {
resultType: OpticsResultType;
resolution: number;
sunAngleMin: number;
sunAngleMax: number;
clouds: number;
};
export type RsaParams = {
resultType: RsaResultType;
interferometry: boolean;
polarisation: Polarisation;
resolution: number;
};
export type CreateRequestPayload = {
id: string;
name: string;
geometry: string;
importance: number;
beginDateTime: string;
endDateTime: string;
kpp: number[];
highPriorityTransmit: boolean;
optics?: OpticsParams;
rsa?: RsaParams;
};
export type CreateRequestResult = {
id: string;
name: string;
status: RequestStatus;
surveyType: SurveyType;
importance: number;
message?: string;
};
@@ -22,7 +22,7 @@ export type TimelineRange = {
toMs: number; toMs: number;
}; };
export type TguActiveTab = "timeline" | "map" | "editor"; export type TguActiveTab = "timeline" | "map" | "editor" | "requests";
export type TimelineRow = { export type TimelineRow = {
spacecraftId: string; spacecraftId: string;
@@ -16,7 +16,7 @@
.tgu-toolbar__row { .tgu-toolbar__row {
display: flex; display: flex;
align-items: end; align-items: center;
gap: 0.75rem; gap: 0.75rem;
min-height: 4.4rem; min-height: 4.4rem;
padding: 0.75rem 1rem 0.65rem; padding: 0.75rem 1rem 0.65rem;
@@ -53,8 +53,9 @@
} }
.tgu-field { .tgu-field {
display: grid; display: flex;
gap: 0.25rem; align-items: center;
gap: 0.45rem;
color: var(--text-muted); color: var(--text-muted);
font-size: 0.72rem; font-size: 0.72rem;
} }
@@ -114,7 +115,6 @@
.tgu-tabs { .tgu-tabs {
display: inline-flex; display: inline-flex;
gap: 0.15rem; gap: 0.15rem;
align-self: center;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 7px; border-radius: 7px;
padding: 0.18rem; padding: 0.18rem;
@@ -219,6 +219,17 @@
border-right: 1px solid var(--line); border-right: 1px solid var(--line);
} }
.tgu-sidebar__legend {
border-top: 1px solid var(--line);
margin-top: auto;
}
.tgu-sidebar__legend .tgu-legend {
flex-direction: column;
align-items: flex-start;
padding: 0.75rem 1rem;
}
.tgu-panel-heading { .tgu-panel-heading {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -344,7 +355,6 @@
.tgu-timeline__svg { .tgu-timeline__svg {
display: block; display: block;
width: 100%; width: 100%;
min-height: 26rem;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 8px; border-radius: 8px;
background: #151819; background: #151819;
@@ -832,6 +842,18 @@
stroke-width: 2; stroke-width: 2;
} }
.tgu-2d-map__overlay circle {
fill: rgba(104, 195, 189, 0.12);
stroke: rgba(130, 222, 216, 0.9);
stroke-width: 2;
}
.tgu-2d-map__overlay circle.is-selected {
fill: rgba(240, 173, 46, 0.15);
stroke: #f0ad2e;
stroke-width: 2.5;
}
.tgu-2d-map__tooltip { .tgu-2d-map__tooltip {
position: absolute; position: absolute;
z-index: 8; z-index: 8;
@@ -998,6 +1020,7 @@
} }
.tgu-map-requests-filter .tgu-field { .tgu-map-requests-filter .tgu-field {
display: grid;
grid-template-columns: 2rem minmax(0, 1fr); grid-template-columns: 2rem minmax(0, 1fr);
align-items: center; align-items: center;
} }
@@ -1059,3 +1082,266 @@
background: rgba(192, 128, 255, 0.25); background: rgba(192, 128, 255, 0.25);
border-color: rgba(192, 128, 255, 0.85); border-color: rgba(192, 128, 255, 0.85);
} }
/* ── Requests-at-point panel ───────────────────────────── */
.tgu-map-content.has-requests-panel {
grid-template-columns: 19rem minmax(0, 1fr) 20rem;
}
.tgu-map-requests-panel {
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
border-left: 1px solid var(--line);
background: var(--bg-panel);
}
.tgu-map-requests-list {
display: flex;
flex: 1;
flex-direction: column;
gap: 1px;
overflow-y: auto;
background: var(--line-soft);
}
.tgu-map-request-row {
display: flex;
gap: 0.65rem;
align-items: flex-start;
width: 100%;
padding: 0.6rem 0.9rem;
border: none;
background: var(--bg-panel);
color: var(--text);
cursor: pointer;
text-align: left;
}
.tgu-map-request-row:hover {
background: var(--bg-hover);
}
.tgu-map-request-row.is-selected {
background: rgba(240, 173, 46, 0.1);
outline: 1px solid rgba(240, 173, 46, 0.4);
outline-offset: -1px;
}
.tgu-map-request-row__type {
flex-shrink: 0;
align-self: center;
width: 1rem;
height: 0.4rem;
border-radius: 3px;
border: 1.5px dashed;
}
.tgu-map-request-row__body {
display: flex;
flex-direction: column;
gap: 0.15rem;
min-width: 0;
}
.tgu-map-request-row__name {
overflow: hidden;
color: var(--text);
font-size: 0.82rem;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.tgu-map-request-row__meta {
color: var(--text-dim);
font-size: 0.72rem;
line-height: 1.4;
}
.tgu-map-request-details {
display: flex;
flex-direction: column;
gap: 0.5rem;
overflow-y: auto;
border-top: 1px solid var(--line);
padding: 0.75rem 0.9rem;
}
.tgu-map-request-details .tgu-map-panel__label {
margin-bottom: 0.1rem;
}
.tgu-map-request-details__grid {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.3rem 0.8rem;
margin: 0;
}
.tgu-map-request-details__grid dt {
color: var(--text-dim);
font-size: 0.72rem;
font-weight: 600;
white-space: nowrap;
line-height: 1.5;
}
.tgu-map-request-details__grid dd {
overflow-wrap: anywhere;
margin: 0;
color: var(--text);
font-size: 0.8rem;
line-height: 1.5;
}
@media (max-width: 1100px) {
.tgu-map-content.has-requests-panel {
grid-template-columns: 1fr;
}
.tgu-map-requests-panel {
max-height: 18rem;
border-left: 0;
border-top: 1px solid var(--line);
}
}
/* ── Вкладка «Заявки» ───────────────────────────────────── */
.tgu-map-content.has-side-panel {
grid-template-columns: 19rem minmax(0, 1fr) 24rem;
}
.tgu-map-overlay--hint {
border-color: rgba(104, 195, 189, 0.5);
color: #bdeee9;
}
/* Черновой полигон при рисовании заявки */
.tgu-2d-map__overlay .tgu-2d-map__draft-fill {
fill: rgba(240, 173, 46, 0.16);
stroke: #f0ad2e;
stroke-width: 2;
stroke-dasharray: 6 4;
}
.tgu-2d-map__overlay .tgu-2d-map__draft-line {
fill: none;
stroke: #f0ad2e;
stroke-width: 2;
stroke-dasharray: 6 4;
}
.tgu-2d-map__overlay .tgu-2d-map__draft-vertex {
fill: #f0ad2e;
stroke: #1a1c1e;
stroke-width: 1.5;
}
/* Панель-форма создания заявки */
.tgu-requests-form__body {
display: flex;
flex: 1;
flex-direction: column;
gap: 0.2rem;
overflow-y: auto;
}
.tgu-requests-form__section {
display: grid;
gap: 0.5rem;
padding: 0.75rem 0.9rem;
border-bottom: 1px solid var(--line-soft);
}
.tgu-requests-field {
display: grid;
grid-template-columns: 9rem minmax(0, 1fr);
gap: 0.5rem;
align-items: center;
font-size: 0.8rem;
}
.tgu-requests-field > span {
color: var(--text-dim);
font-size: 0.74rem;
}
.tgu-requests-field input,
.tgu-requests-field select {
width: 100%;
min-height: 1.9rem;
border: 1px solid var(--line);
border-radius: 6px;
padding: 0.25rem 0.45rem;
background: #101214;
color: var(--text);
font-size: 0.8rem;
}
.tgu-requests-field input:focus,
.tgu-requests-field select:focus {
outline: none;
border-color: rgba(104, 195, 189, 0.6);
}
.tgu-requests-checkbox {
display: flex;
gap: 0.5rem;
align-items: center;
color: var(--text);
font-size: 0.8rem;
}
.tgu-requests-checkbox input {
accent-color: var(--accent);
}
.tgu-requests-form__block-toggle {
font-weight: 650;
}
.tgu-requests-form__block {
display: grid;
gap: 0.5rem;
padding: 0.55rem 0.6rem;
border: 1px solid var(--line-soft);
border-radius: 7px;
background: #111416;
}
.tgu-requests-form__actions {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.tgu-requests-form__hint {
color: var(--text-dim);
font-size: 0.75rem;
line-height: 1.4;
}
.tgu-requests-form__geo-status {
color: var(--text);
font-size: 0.78rem;
font-weight: 650;
}
.tgu-requests-form__footer {
padding: 0.75rem 0.9rem;
border-top: 1px solid var(--line);
}
.tgu-requests-form__footer .tgu-button {
width: 100%;
}
@media (max-width: 1100px) {
.tgu-map-content.has-side-panel {
grid-template-columns: 1fr;
}
}