import { useCallback, useEffect, useMemo, useState } from "react"; import { fetchRequestsForMap } from "../../api/requestApi"; import { fetchStations, type StationDto } from "../../api/stationsApi"; 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"; import type { Tgu2DMapSelection } from "./model/mapSelectionTypes"; import type { MapPolygon } from "./model/mapTypes"; import { buildTgu2DMapScene, getLayerAvailability } from "./Tgu2DMapLayers"; import { Tgu2DMapDetails } from "./Tgu2DMapDetails"; import { Tgu2DMapRequestsPanel, type RequestAtPoint } from "./Tgu2DMapRequestsPanel"; import { Tgu2DMapSidebar } from "./Tgu2DMapSidebar"; import { Tgu2DMapStationPanel } from "./Tgu2DMapStationPanel"; import { Tgu2DMapToolbar } from "./Tgu2DMapToolbar"; import { Tgu2DMapView } from "./Tgu2DMapView"; import { getTgu2DMapIntervalState, TGU_2D_MAP_INTERVAL_WARNING } from "./tgu2DMapInterval"; import { parseUtc, toUtcInputValue } from "../../utils/utcTime"; type Tgu2DMapTabProps = { range: TimelineRange; invalidRange: boolean; selectedPlan?: TguPlanUi; 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: toUtcInputValue(range.fromMs), toValue: toUtcInputValue(range.toMs), items: [], loading: false, loadedCount: 0, totalItems: 0, truncated: false, loaded: false }; } export function Tgu2DMapTab({ range, invalidRange, selectedPlan, platforms }: Tgu2DMapTabProps) { const [layers, setLayers] = useState(DEFAULT_TGU_2D_MAP_LAYERS); const [search, setSearch] = useState(""); const [redrawToken, setRedrawToken] = useState(0); const [selectedObject, setSelectedObject] = useState(); const [hiddenIds, setHiddenIds] = useState>(new Set()); const [requestsState, setRequestsState] = useState(() => buildInitialRequestsState(range)); const [stations, setStations] = useState([]); const [stationsFetchFailed, setStationsFetchFailed] = useState(false); const [requestsAtPoint, setRequestsAtPoint] = useState([]); const [selectedRequestPolygonId, setSelectedRequestPolygonId] = useState(); const [hoveredRequestPolygonId, setHoveredRequestPolygonId] = useState(); 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 mapInvalidRange = invalidRange || mapInterval.tooLarge; const loadRequests = useCallback(async (fromValue: string, toValue: string) => { const fromMs = parseUtc(fromValue); const toMs = parseUtc(toValue); 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().slice(0, 19), beginTo: new Date(toMs).toISOString().slice(0, 19) }, (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 = toUtcInputValue(fromMs); const toValue = toUtcInputValue(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); if (next.has(id)) next.delete(id); else next.add(id); return next; }); }, []); const showAll = useCallback(() => setHiddenIds(new Set()), []); const planPlatform = useMemo( () => selectedPlan ? platforms.find((p) => p.spacecraftId === selectedPlan.spacecraftId) : undefined, [platforms, selectedPlan] ); const satelliteLabel = selectedPlan ? platformLabel(planPlatform, selectedPlan.spacecraftId) : hiddenIds.size > 0 ? `Скрыто: ${hiddenIds.size} КА` : "Все КА"; const intervalLabel = `${formatDate(mapInterval.range.fromMs)} - ${formatDate(mapInterval.range.toMs)}`; const scene = useMemo( () => buildTgu2DMapScene({ range: mapInterval.range, selectedPlan, platforms, hiddenSpacecraftIds: hiddenIds, requestItems: layers.requests ? requestsState.items : [], stationItems: layers.stations ? stations : [] }), [mapInterval.range, platforms, selectedPlan, hiddenIds, requestsState.items, layers.requests, stations, layers.stations] ); const layerAvailability = useMemo( () => getLayerAvailability(layers, scene, stationsFetchFailed), [layers, scene, stationsFetchFailed] ); const overlayMessage = invalidRange ? "Некорректный интервал: from должен быть меньше to." : mapInterval.tooLarge ? TGU_2D_MAP_INTERVAL_WARNING : undefined; const toggleLayer = (key: Tgu2DMapLayerKey) => { 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 (
setRedrawToken((value) => value + 1)} onToggleLayer={toggleLayer} />
0 || stationPanel != null) ? " has-requests-panel" : ""}`}>
{overlayMessage &&
{overlayMessage}
}
{requestsAtPoint.length > 0 && ( )} {stationPanel != null && ( )}
{Object.entries(layerAvailability).map(([layer, message]) => message ? (
{layerLabel(layer as Tgu2DMapLayerKey)}: {message}
) : null )}
); } function formatDate(ms: number): string { return new Date(ms).toLocaleString("ru-RU", { timeZone: "UTC", day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" }); } function layerLabel(layer: Tgu2DMapLayerKey): string { switch (layer) { case "tracks": return "Трасса КА"; case "swath": return "Полоса обзора"; case "planWorks": return "Работы плана"; case "stations": return "Станции"; case "spacecraftMarkers": return "Маркеры КА"; case "requests": return "Заявки на съёмку"; } }