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 { buildTgu2DMapScene, getLayerAvailability } from "./Tgu2DMapLayers"; import { Tgu2DMapDetails } from "./Tgu2DMapDetails"; import { MapObjectInspector } from "./MapObjectInspector"; import { Tgu2DMapSidebar } from "./Tgu2DMapSidebar"; import { Tgu2DMapToolbar } from "./Tgu2DMapToolbar"; import { Tgu2DMapView } from "./Tgu2DMapView"; import { getTgu2DMapIntervalState, TGU_2D_MAP_INTERVAL_WARNING } from "./tgu2DMapInterval"; import { parseLocal, toLocalInputValue, toUtcIso } from "../../utils/localTime"; 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: toLocalInputValue(range.fromMs), toValue: toLocalInputValue(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 [hiddenIds, setHiddenIds] = useState>(new Set()); const [requestsState, setRequestsState] = useState(() => buildInitialRequestsState(range)); const [stations, setStations] = useState([]); const [stationsFetchFailed, setStationsFetchFailed] = useState(false); // Инспектор объекта карты справа (единый для всех вкладок): объекты под кликом и раскрытый. const [mapHits, setMapHits] = useState([]); const [activeHitId, setActiveHitId] = useState(); 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 = parseLocal(fromValue); const toMs = parseLocal(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: toUtcIso(fromMs), beginTo: toUtcIso(toMs) }, (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 = toLocalInputValue(fromMs); const toValue = toLocalInputValue(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] })); }; // Клик по карте: 0/1 объект — карточка сразу; ≥2 (наложение) — список под точкой клика. const handleObjectSelect = useCallback((selection?: Tgu2DMapSelection) => { setMapHits(selection ? [selection] : []); setActiveHitId(selection?.id); }, []); const handleObjectsAtPoint = useCallback((selections: Tgu2DMapSelection[]) => { setMapHits(selections); setActiveHitId(selections[0]?.id); }, []); const toggleHit = useCallback((selection: Tgu2DMapSelection) => { setActiveHitId((current) => (current === selection.id ? undefined : selection.id)); }, []); const closeInfoPanel = useCallback(() => { setMapHits([]); setActiveHitId(undefined); }, []); return (
setRedrawToken((value) => value + 1)} onToggleLayer={toggleLayer} />
0 ? " has-side-panel" : ""}`}>
{overlayMessage &&
{overlayMessage}
}
{mapHits.length > 0 && ( )}
{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", { 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 "complexPlan": return "Комплексный план"; case "heatmap": return "Тепловая карта"; case "stations": return "Станции"; case "spacecraftMarkers": return "Маркеры КА"; case "requests": return "Заявки на съёмку"; } }