feat(tgu-ops-ui): display request polygons on 2D map

Добавлен слой заявок (КНС) на 2D карту ТГУ:
- requestApi.ts — загрузка заявок постранично через GET /v1/requests/map
  с прогресс-коллбэком; автоматически собирает все страницы
- wktParser.ts — парсинг WKT POLYGON/MULTIPOLYGON в массив координат
- drawRequests.ts — Canvas-рендеринг полигонов заявок
- Tgu2DMapLayers.ts — buildTgu2DMapScene строит MapPolygon[] из RequestMapItem[]
- Tgu2DMapTab.tsx — состояние requestsState, кнопка «Загрузить», фильтры по дате
- Tgu2DMapSidebar.tsx — панель слоя заявок: счётчик, ошибки, truncated-предупреждение
- requestTypes.ts — RequestMapItem, RequestMapPage модели
- Поддержка выбора заявки на карте (mapSelectionTypes, mapTypes)
- nginx.conf, vite.config.ts — проксирование /v1 на pcp-request-service в dev

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Дмитрий Соловьев
2026-05-30 23:28:58 +03:00
parent bb84844674
commit 42708450bb
16 changed files with 647 additions and 22 deletions
@@ -1,4 +1,6 @@
import { useCallback, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { fetchRequestsForMap } from "../../api/requestApi";
import type { RequestMapItem } from "../../model/requestTypes";
import type { TguPlanUi, TguPlatformUi, TimelineRange } from "../../model/timelineTypes";
import { platformLabel } from "../tgu-planning/tguTimelineMapper";
import { DEFAULT_TGU_2D_MAP_LAYERS, type Tgu2DMapLayerKey, type Tgu2DMapLayersState } from "./model/mapLayerTypes";
@@ -17,6 +19,31 @@ type Tgu2DMapTabProps = {
platforms: TguPlatformUi[];
};
export type RequestsState = {
fromValue: string;
toValue: string;
items: RequestMapItem[];
loading: boolean;
loadedCount: number;
error?: string;
totalItems: number;
truncated: boolean;
loaded: boolean;
};
function buildInitialRequestsState(range: TimelineRange): RequestsState {
return {
fromValue: formatDateTimeLocal(range.fromMs),
toValue: formatDateTimeLocal(range.toMs),
items: [],
loading: false,
loadedCount: 0,
totalItems: 0,
truncated: false,
loaded: false
};
}
export function Tgu2DMapTab({
range,
invalidRange,
@@ -28,10 +55,68 @@ export function Tgu2DMapTab({
const [redrawToken, setRedrawToken] = useState(0);
const [selectedObject, setSelectedObject] = useState<Tgu2DMapSelection>();
const [hiddenIds, setHiddenIds] = useState<ReadonlySet<string>>(new Set());
const [requestsState, setRequestsState] = useState<RequestsState>(() => buildInitialRequestsState(range));
const mapInterval = getTgu2DMapIntervalState(range, selectedPlan);
const mapInvalidRange = invalidRange || mapInterval.tooLarge;
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 : "Не удалось загрузить заявки."
}));
}
}, []);
const applyRequestsRange = useCallback(() => {
void loadRequests(requestsState.fromValue, requestsState.toValue);
}, [loadRequests, requestsState.fromValue, requestsState.toValue]);
const setRequestsFrom = useCallback((fromValue: string) => {
setRequestsState((current) => ({ ...current, fromValue }));
}, []);
const setRequestsTo = useCallback((toValue: string) => {
setRequestsState((current) => ({ ...current, toValue }));
}, []);
const setRequestsQuickRange = useCallback((fromMs: number, toMs: number) => {
const fromValue = formatDateTimeLocal(fromMs);
const toValue = formatDateTimeLocal(toMs);
setRequestsState((current) => ({ ...current, fromValue, toValue }));
void loadRequests(fromValue, toValue);
}, [loadRequests]);
useEffect(() => {
setRequestsState(buildInitialRequestsState(range));
}, [range]);
const toggleSpacecraft = useCallback((id: string) => {
setHiddenIds((prev) => {
const next = new Set(prev);
@@ -56,8 +141,14 @@ export function Tgu2DMapTab({
const intervalLabel = `${formatDate(mapInterval.range.fromMs)} - ${formatDate(mapInterval.range.toMs)}`;
const scene = useMemo(
() => buildTgu2DMapScene({ range: mapInterval.range, selectedPlan, platforms, hiddenSpacecraftIds: hiddenIds }),
[mapInterval.range, platforms, selectedPlan, hiddenIds]
() => buildTgu2DMapScene({
range: mapInterval.range,
selectedPlan,
platforms,
hiddenSpacecraftIds: hiddenIds,
requestItems: layers.requests ? requestsState.items : []
}),
[mapInterval.range, platforms, selectedPlan, hiddenIds, requestsState.items, layers.requests]
);
const layerAvailability = useMemo(() => getLayerAvailability(layers, scene), [layers, scene]);
@@ -89,10 +180,15 @@ export function Tgu2DMapTab({
layers={layers}
search={search}
layerAvailability={layerAvailability}
requestsState={requestsState}
onSearchChange={setSearch}
onToggleSpacecraft={toggleSpacecraft}
onShowAll={showAll}
onToggleLayer={toggleLayer}
onRequestsFromChange={setRequestsFrom}
onRequestsToChange={setRequestsTo}
onRequestsApply={applyRequestsRange}
onRequestsQuickRange={setRequestsQuickRange}
/>
<section className="tgu-map-stage">
{overlayMessage && <div className="tgu-map-overlay">{overlayMessage}</div>}
@@ -129,6 +225,16 @@ function formatDate(ms: number): string {
});
}
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}`;
}
function layerLabel(layer: Tgu2DMapLayerKey): string {
switch (layer) {
case "tracks":
@@ -141,5 +247,7 @@ function layerLabel(layer: Tgu2DMapLayerKey): string {
return "Станции";
case "spacecraftMarkers":
return "Маркеры КА";
case "requests":
return "Заявки на съёмку";
}
}