import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useVirtualizer } from "@tanstack/react-virtual"; import { fetchRequestsForMap } from "../../api/requestApi"; import { bookRequestSlots, cancelRequestBooking, fetchBookedSlotsByRequest, fetchRequestSlots } from "../../api/slotsApi"; import type { RequestMapItem } from "../../model/requestTypes"; import type { BookedSlotDTO, RevolutionSign, SlotBookingRequest, SlotCoverageStrategy, SlotDTO } from "../../model/slotTypes"; import type { TimelineRange } from "../../model/timelineTypes"; import { DateTime24Field } from "../../components/timeFields"; import { parseWktPolygon } from "../tgu-map-2d/geometry/wktParser"; import type { GeoPoint, MapPolygon, Tgu2DMapScene } from "../tgu-map-2d/model/mapTypes"; import type { Tgu2DMapLayersState } from "../tgu-map-2d/model/mapLayerTypes"; import type { Tgu2DMapSelection } from "../tgu-map-2d/model/mapSelectionTypes"; import { MapObjectInspector } from "../tgu-map-2d/MapObjectInspector"; import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView"; import { formatLocalDateTime, parseLocal, toLocalInputValue, toLocalIso } from "../../utils/localTime"; import { SlotDaySelector, type SlotDay } from "./SlotDaySelector"; import { SlotPickMenu, type SlotPick } from "./SlotPickMenu"; import { chunk, selectionAfterCheck } from "./slotSelection"; const DAY_MS = 24 * 60 * 60 * 1000; /** Размер чанка при бронировании больших наборов (бэк бронирует слоты по одному). */ const BOOK_CHUNK = 200; /** Выше этого числа слотов бронь требует подтверждения. */ const BOOK_CONFIRM_THRESHOLD = 500; /** Выше этого числа полигонов в окне суток показываем подсказку «сузьте сутки». */ const MAP_HINT_THRESHOLD = 800; /** Оценка высоты строки таблицы (px) для виртуализатора. */ const ROW_HEIGHT = 34; /** Начало локальных суток момента (мс) — для группировки слотов по суткам. */ function dayStartMs(ms: number): number { const d = new Date(ms); d.setHours(0, 0, 0, 0); return d.getTime(); } /** Метка суток ДД.ММ (локально). */ function dayLabel(ms: number): string { const d = new Date(ms); return `${String(d.getDate()).padStart(2, "0")}.${String(d.getMonth() + 1).padStart(2, "0")}`; } type TguSlotsTabProps = { /** Диапазон вкладки «Планы» — начальный интервал отбора заявок и расчёта слотов. */ range: TimelineRange; }; /** Ключ слота: КА + номер слота + цикл — уникален в пределах расчёта и совпадает с ключом брони. */ function slotKey(slot: { satelliteId: number; slotNumber: number; cycle: number }): string { return `${slot.satelliteId}:${slot.slotNumber}:${slot.cycle}`; } export function TguSlotsTab({ range }: TguSlotsTabProps) { // Заявки интервала (показываются на карте, выбираются кликом) и выбранная заявка. const [requestItems, setRequestItems] = useState([]); const [requestsLoading, setRequestsLoading] = useState(false); const [requestsLoaded, setRequestsLoaded] = useState(false); const [requestsTotal, setRequestsTotal] = useState(0); const [requestsTruncated, setRequestsTruncated] = useState(false); const [requestsError, setRequestsError] = useState(); const [selectedRequestId, setSelectedRequestId] = useState(""); // Интервал отбора заявок — какие заявки показываем на карте (аналог фильтра вкладки «Карта»). const [reqFromValue, setReqFromValue] = useState(() => toLocalInputValue(range.fromMs)); const [reqToValue, setReqToValue] = useState(() => toLocalInputValue(range.toMs)); // Интервал и фильтры расчёта слотов выбранной заявки. const [fromValue, setFromValue] = useState(() => toLocalInputValue(range.fromMs)); const [toValue, setToValue] = useState(() => toLocalInputValue(range.toMs)); const [revSign, setRevSign] = useState<"" | RevolutionSign>(""); const [withCoverage, setWithCoverage] = useState(false); // Стратегия автоформирования покрытия (кнопка «Автоподбор»). const [strategy, setStrategy] = useState("GREEDY"); // Результаты: слоты-кандидаты и уже забронированные слоты заявки. const [slots, setSlots] = useState([]); const [bookedSlots, setBookedSlots] = useState([]); const [selectedKeys, setSelectedKeys] = useState>(() => new Set()); // Фильтр отображения по суткам: индексы (1-based) выбранного диапазона суток расчёта. const [dayFrom, setDayFrom] = useState(1); const [dayTo, setDayTo] = useState(1); const [calculating, setCalculating] = useState(false); const [autoFilling, setAutoFilling] = useState(false); const [booking, setBooking] = useState(false); // Прогресс чанкованной брони «X/Y» и набор, ждущий подтверждения (крупная бронь). const [bookProgress, setBookProgress] = useState(); const [pendingBook, setPendingBook] = useState(null); const [error, setError] = useState(); const [notice, setNotice] = useState(); // Кэш распарсенных WKT-контуров по ключу слота — парсим контур один раз, а не на каждый // ререндер сцены (критично на тысячах слотов). Чистится при смене заявки. const wktCacheRef = useRef(new Map()); // Контроллер отмены текущего расчёта/автоподбора — прерывается при повторе или смене заявки. const abortRef = useRef(null); // Якорь для выбора диапазона по Shift+клику (индекс в filteredSlots). const anchorIndexRef = useRef(null); // Подсветка строки/контура по клику на карте и центрирование на слоте. const [activeSlotId, setActiveSlotId] = useState(); const [focusPoint, setFocusPoint] = useState(); const focusTokenRef = useRef(0); const [focusToken, setFocusToken] = useState(0); // Скролл-контейнер списка слотов — служит scrollElement для виртуализатора. const listRef = useRef(null); // Инспектор заявки справа над картой: объекты под кликом и раскрытый (только до выбора заявки). const [mapHits, setMapHits] = useState([]); const [activeHitId, setActiveHitId] = useState(); // Меню выбора слота при плотном наложении (несколько слотов под точкой клика). const [slotPicks, setSlotPicks] = useState([]); const hasRequest = !!selectedRequestId; const selectedRequest = useMemo( () => requestItems.find((item) => item.id === selectedRequestId), [requestItems, selectedRequestId] ); const parsedRange = useMemo( () => ({ fromMs: parseLocal(fromValue), toMs: parseLocal(toValue) }), [fromValue, toValue] ); const rangeInvalid = !Number.isFinite(parsedRange.fromMs) || !Number.isFinite(parsedRange.toMs) || parsedRange.fromMs >= parsedRange.toMs; // Ключи забронированных слотов — для бейджа «бронь» в таблице и отдельного слоя на карте. const bookedKeys = useMemo(() => new Set(bookedSlots.map(slotKey)), [bookedSlots]); // id полигонов слотов, отобранных галочками под бронь — постоянная подсветка на карте. const markedSlotIds = useMemo( () => new Set(Array.from(selectedKeys, (key) => `slot:${key}`)), [selectedKeys] ); // Слот по ключу — чтобы из выбора на карте (id "slot:key"/"booked:key") восстановить полные данные. const slotByKey = useMemo(() => { const map = new Map(); for (const slot of slots) map.set(slotKey(slot), slot); for (const slot of bookedSlots) if (!map.has(slotKey(slot))) map.set(slotKey(slot), slot); return map; }, [slots, bookedSlots]); // Сутки, по которым раскладываются рассчитанные слоты (для фильтра отображения). const days = useMemo(() => { const buckets = new Map(); for (const slot of slots) { const ms = parseLocal(slot.tn); if (!Number.isFinite(ms)) continue; const start = dayStartMs(ms); buckets.set(start, (buckets.get(start) ?? 0) + 1); } return Array.from(buckets.entries()) .sort(([a], [b]) => a - b) .map(([dayMs, count], idx) => ({ index: idx + 1, dayMs, count, label: dayLabel(dayMs) })); }, [slots]); // Новый расчёт меняет набор суток — раскрываем фильтр на весь диапазон. useEffect(() => { setDayFrom(1); setDayTo(days.length || 1); }, [days]); const effDayFrom = Math.max(1, Math.min(dayFrom, days.length || 1)); const effDayTo = Math.max(effDayFrom, Math.min(dayTo, days.length || 1)); // Слоты выбранного диапазона суток — то, что видно и в таблице, и на карте. const filteredSlots = useMemo(() => { if (days.length === 0) return slots; const fromMs = days[effDayFrom - 1].dayMs; const toMsExcl = days[effDayTo - 1].dayMs + DAY_MS; return slots.filter((slot) => { const ms = parseLocal(slot.tn); return Number.isFinite(ms) && ms >= fromMs && ms < toMsExcl; }); }, [slots, days, effDayFrom, effDayTo]); // Виртуализация таблицы: в DOM держим только видимое окно строк (+overscan), а не все слоты — // иначе на тысячах строк рендер встаёт. Скролл-контейнер — секция списка (listRef). const rowVirtualizer = useVirtualizer({ count: filteredSlots.length, getScrollElement: () => listRef.current, estimateSize: () => ROW_HEIGHT, overscan: 12 }); // Загрузка заявок интервала (на карту); вызывается на входе, при смене диапазона и из фильтра. const loadRequests = useCallback(async (fromInput: string, toInput: string) => { const fromMs = parseLocal(fromInput); const toMs = parseLocal(toInput); if (!Number.isFinite(fromMs) || !Number.isFinite(toMs) || fromMs >= toMs) return; setRequestsLoading(true); setRequestsError(undefined); try { const result = await fetchRequestsForMap({ endFrom: toLocalIso(fromMs), beginTo: toLocalIso(toMs) }); setRequestItems(result.items); setRequestsTotal(result.totalItems); setRequestsTruncated(result.truncated); setRequestsLoaded(true); } catch (caught) { setRequestsError(caught instanceof Error ? caught.message : "Не удалось загрузить заявки."); } finally { setRequestsLoading(false); } }, []); // Заявки за диапазон вкладки — грузим на входе и при смене диапазона. useEffect(() => { const fromInput = toLocalInputValue(range.fromMs); const toInput = toLocalInputValue(range.toMs); setReqFromValue(fromInput); setReqToValue(toInput); void loadRequests(fromInput, toInput); }, [range.fromMs, range.toMs, loadRequests]); // Клик по слоту на карте подсвечивает строку — центрируем её в видимой части таблицы. // Скроллим контейнер вручную (по rect-ам), а не через scrollIntoView: тот может выбрать другой // скролл-предок или счесть строку «достаточно близкой» и не сдвинуться. useEffect(() => { if (!activeSlotId || !activeSlotId.startsWith("slot:")) return; const key = activeSlotId.slice("slot:".length); const index = filteredSlots.findIndex((slot) => slotKey(slot) === key); if (index >= 0) rowVirtualizer.scrollToIndex(index, { align: "center" }); }, [activeSlotId, filteredSlots, rowVirtualizer]); const focusOn = useCallback((point?: GeoPoint) => { if (!point) return; setFocusPoint(point); focusTokenRef.current += 1; setFocusToken(focusTokenRef.current); }, []); // Кольца контура слота по ключу — из кэша, парсим WKT не более одного раза на слот. const ringsFor = useCallback((key: string, contour: string): GeoPoint[][] => { const cache = wktCacheRef.current; let rings = cache.get(key); if (rings === undefined) { rings = parseWktPolygon(contour); cache.set(key, rings); } return rings; }, []); // Загружает брони выбранной заявки (для бейджей и слоя карты). const loadBooked = useCallback(async (requestId: string) => { try { const booked = await fetchBookedSlotsByRequest(requestId); setBookedSlots(booked); } catch (caught) { setBookedSlots([]); setError(caught instanceof Error ? caught.message : "Не удалось загрузить брони заявки."); } }, []); // Смена заявки — отменяем текущий расчёт, чистим кэш контуров и результаты, // подтягиваем уже существующие брони. useEffect(() => { abortRef.current?.abort(); wktCacheRef.current.clear(); anchorIndexRef.current = null; setSlots([]); setSelectedKeys(new Set()); setBookedSlots([]); setPendingBook(null); setNotice(undefined); setError(undefined); setActiveSlotId(undefined); if (selectedRequestId) void loadBooked(selectedRequestId); }, [selectedRequestId, loadBooked]); const reqFromMs = parseLocal(reqFromValue); const reqToMs = parseLocal(reqToValue); const reqRangeInvalid = !Number.isFinite(reqFromMs) || !Number.isFinite(reqToMs) || reqFromMs >= reqToMs; const applyRequests = () => void loadRequests(reqFromValue, reqToValue); const setQuickRange = (fromMs: number, toMs: number) => { const fromInput = toLocalInputValue(fromMs); const toInput = toLocalInputValue(toMs); setReqFromValue(fromInput); setReqToValue(toInput); void loadRequests(fromInput, toInput); }; const quickToday = () => { const now = new Date(); const fromMs = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); setQuickRange(fromMs, fromMs + DAY_MS); }; const quick7days = () => setQuickRange(Date.now(), Date.now() + 7 * DAY_MS); const quick30days = () => setQuickRange(Date.now(), Date.now() + 30 * DAY_MS); // Общий расчёт слотов. Без стратегии — все слоты-кандидаты («Рассчитать»). Со стратегией — // автоформирование покрытия: бэк отдаёт достаточный набор, кладём его в преднабор брони. const runCover = async (coverageStrategy?: SlotCoverageStrategy) => { if (!selectedRequestId || rangeInvalid) return; const auto = !!coverageStrategy; if (auto ? autoFilling : calculating) return; // Прерываем предыдущий расчёт, если он ещё идёт. abortRef.current?.abort(); const ctrl = new AbortController(); abortRef.current = ctrl; anchorIndexRef.current = null; if (auto) setAutoFilling(true); else setCalculating(true); setError(undefined); setNotice(undefined); try { const result = await fetchRequestSlots( { requestId: selectedRequestId, timeStart: toLocalIso(parsedRange.fromMs), timeStop: toLocalIso(parsedRange.toMs), revSign: revSign || undefined, cov: withCoverage, coverageStrategy }, ctrl.signal ); if (ctrl.signal.aborted) return; setSlots(result); if (auto) { // Автонабор → преднабор брони: предлагаем оператору весь сформированный набор. setSelectedKeys(new Set(result.map(slotKey))); setNotice( result.length > 0 ? `Автоподбор (${strategyLabel(coverageStrategy!)}): предложено ${result.length} слотов — проверьте и забронируйте.` : "Покрытие для интервала не сформировано." ); } else { setSelectedKeys(new Set()); setNotice( result.length > 0 ? `Найдено слотов: ${result.length}.` : "Для выбранного интервала слотов покрытия не найдено." ); } if (result.length > 0) { focusOn({ lat: result[0].latitude, lon: result[0].longitude }); } } catch (caught) { // Отмена (повтор/смена заявки) — это не ошибка, молча выходим. if (ctrl.signal.aborted || (caught instanceof DOMException && caught.name === "AbortError")) return; setSlots([]); setError(caught instanceof Error ? caught.message : "Не удалось рассчитать слоты."); } finally { if (abortRef.current === ctrl) abortRef.current = null; if (auto) setAutoFilling(false); else setCalculating(false); } }; const calculate = () => void runCover(); const autoCover = () => void runCover(strategy); const cancelCalculation = () => abortRef.current?.abort(); // Бронирует набор чанками (бэк бронирует слоты по одному в одной транзакции — крупный набор // бьём на части, чтобы не упереться в таймаут/долгую транзакцию) и показывает прогресс. const performBook = async (toBook: SlotBookingRequest[]) => { setBooking(true); setError(undefined); setNotice(undefined); setBookProgress(toBook.length > BOOK_CHUNK ? `0/${toBook.length}` : undefined); try { const collected: BookedSlotDTO[] = []; let done = 0; for (const part of chunk(toBook, BOOK_CHUNK)) { const booked = await bookRequestSlots(selectedRequestId, part); collected.push(...booked); done += part.length; if (toBook.length > BOOK_CHUNK) setBookProgress(`${done}/${toBook.length}`); } // Источник истины — ответ POST: BookedSlotDTO несёт тот же cycle, что мы отправили (= cycle // рассчитанного слота). А вот GET booking/by-request отдаёт базовый слот в ДРУГОМ цикле // (slot.toDTO без сдвига на cycle брони), из-за чего ключ sat:slotNum:cycle не совпадает и // бейдж «бронь» не появляется. Поэтому отмечаем забронированными ровно те рассчитанные слоты // (с контуром), что вернул POST, не перечитывая by-request. const bookedSet = new Set(collected.map((item) => `${item.satelliteId}:${item.slotNum}:${item.cycle}`)); const justBooked = slots.filter((slot) => bookedSet.has(slotKey(slot))); setBookedSlots((prev) => { const merged = new Map(prev.map((slot) => [slotKey(slot), slot])); for (const slot of justBooked) merged.set(slotKey(slot), slot); return Array.from(merged.values()); }); setSelectedKeys(new Set()); setNotice(`Забронировано слотов: ${collected.length}.`); } catch (caught) { setError(caught instanceof Error ? caught.message : "Не удалось забронировать слоты."); } finally { setBooking(false); setBookProgress(undefined); } }; const collectSelected = (): SlotBookingRequest[] => slots .filter((slot) => selectedKeys.has(slotKey(slot))) .map((slot) => ({ satelliteId: slot.satelliteId, slotNum: slot.slotNumber, cycle: slot.cycle })); const book = () => { if (!selectedRequestId || booking || selectedKeys.size === 0) return; const toBook = collectSelected(); if (toBook.length === 0) return; // Крупная бронь — сначала подтверждение, потом чанкованный POST. if (toBook.length > BOOK_CONFIRM_THRESHOLD) { setPendingBook(toBook); return; } void performBook(toBook); }; const confirmPendingBook = () => { const toBook = pendingBook; setPendingBook(null); if (toBook) void performBook(toBook); }; const cancelPendingBook = () => setPendingBook(null); const cancelBooking = async () => { if (!selectedRequestId || booking || bookedSlots.length === 0) return; setBooking(true); setError(undefined); setNotice(undefined); try { const cancelled = await cancelRequestBooking(selectedRequestId); // Снимаются все брони заявки — очищаем локально (by-request не перечитываем, см. book()). setBookedSlots([]); setNotice(`Снято броней: ${cancelled}.`); } catch (caught) { setError(caught instanceof Error ? caught.message : "Не удалось снять бронь."); } finally { setBooking(false); } }; // Отметка слота из таблицы. Shift+клик — выбрать диапазон от якоря (предыдущего клика) до // текущей строки; направление (выбрать/снять) задаётся новым состоянием текущей строки. const handleSlotCheck = (index: number, shiftKey: boolean) => { setSelectedKeys((current) => selectionAfterCheck(current, (i) => slotKey(filteredSlots[i]), index, shiftKey, anchorIndexRef.current) ); anchorIndexRef.current = index; }; // Чекбокс шапки — выбрать/снять все ВИДИМЫЕ (по окну суток) слоты. const allVisibleSelected = filteredSlots.length > 0 && filteredSlots.every((slot) => selectedKeys.has(slotKey(slot))); const toggleAllVisible = () => { setSelectedKeys((current) => { const next = new Set(current); if (allVisibleSelected) for (const slot of filteredSlots) next.delete(slotKey(slot)); else for (const slot of filteredSlots) next.add(slotKey(slot)); return next; }); anchorIndexRef.current = null; }; // Выбрать все слоты расчёта (по всем суткам), снять весь выбор. const selectAllDays = () => setSelectedKeys(new Set(slots.map(slotKey))); const clearSelection = () => { setSelectedKeys(new Set()); anchorIndexRef.current = null; }; // Сколько выбрано из видимых суток и сколько — на других сутках (для прозрачности охвата). const visibleSelectedCount = useMemo( () => filteredSlots.reduce((n, slot) => (selectedKeys.has(slotKey(slot)) ? n + 1 : n), 0), [filteredSlots, selectedKeys] ); const otherDaysSelectedCount = selectedKeys.size - visibleSelectedCount; const selectRow = (slot: SlotDTO) => { const id = `slot:${slotKey(slot)}`; setActiveSlotId(id); focusOn({ lat: slot.latitude, lon: slot.longitude }); }; // Выбор заявки из инспектора — закрываем инспектор, переходим в режим расчёта слотов. const chooseRequest = useCallback((requestId: string) => { setSelectedRequestId(requestId); setMapHits([]); setActiveHitId(undefined); }, []); // Сброс окна к состоянию «заявка не выбрана, слоты не показаны». const resetSelection = useCallback(() => { setSelectedRequestId(""); setMapHits([]); setActiveHitId(undefined); setActiveSlotId(undefined); }, []); // Сцена карты. До выбора заявки — все заявки интервала (для выбора кликом). После выбора — // контур заявки (слой requests), слоты-кандидаты (swath) и брони (complexPlan). const scene = useMemo(() => { const polygons: MapPolygon[] = []; let z = 0; if (!hasRequest) { for (const item of requestItems) { if (!item.geometry) continue; for (const ring of parseWktPolygon(item.geometry)) { if (ring.length < 3) continue; polygons.push({ id: `request:${item.id}`, name: item.name, kind: "request", layer: "requests", points: ring, requestId: item.id, zIndex: z++ }); } } return { polygons, lines: [], markers: [] }; } if (selectedRequest?.geometry) { for (const ring of parseWktPolygon(selectedRequest.geometry)) { if (ring.length < 3) continue; polygons.push({ id: `request:${selectedRequest.id}`, name: selectedRequest.name, kind: "request", layer: "requests", points: ring, requestId: selectedRequest.id, // Контур заявки рисуем, но из выбора исключаем — иначе он «съедает» меню выбора слотов // (как единственный requests-hit под точкой) и любой клик внутри выбирал бы заявку. pickable: false, zIndex: z++ }); } } for (const slot of filteredSlots) { const key = slotKey(slot); for (const ring of ringsFor(key, slot.contour)) { if (ring.length < 3) continue; polygons.push({ id: `slot:${key}`, name: `КА ${slot.satelliteId} · слот ${slot.slotNumber}`, kind: bookedKeys.has(key) ? "complexPlan" : "swath", layer: bookedKeys.has(key) ? "complexPlan" : "swath", points: ring, spacecraftId: String(slot.satelliteId), revolution: slot.revolution, zIndex: z++ }); } } // Брони, которых нет в текущем расчёте (напр. до нажатия «Рассчитать»), — показываем отдельно. const slotKeysShown = new Set(slots.map(slotKey)); for (const slot of bookedSlots) { const key = slotKey(slot); if (slotKeysShown.has(key)) continue; for (const ring of ringsFor(key, slot.contour)) { if (ring.length < 3) continue; polygons.push({ id: `booked:${key}`, name: `КА ${slot.satelliteId} · слот ${slot.slotNumber} (бронь)`, kind: "complexPlan", layer: "complexPlan", points: ring, spacecraftId: String(slot.satelliteId), revolution: slot.revolution, zIndex: z++ }); } } return { polygons, lines: [], markers: [] }; }, [hasRequest, requestItems, selectedRequest, slots, filteredSlots, bookedSlots, bookedKeys, ringsFor]); const layers = useMemo( () => ({ tracks: false, swath: true, planWorks: false, complexPlan: true, heatmap: false, stations: false, spacecraftMarkers: false, requests: true }), [] ); // Клик по карте. С выбранной заявкой — подсветка слота (синхрон со строкой). Без заявки — // открываем инспектор заявки (одиночный клик) для выбора. const handleObjectSelect = useCallback( (selection?: Tgu2DMapSelection) => { if (hasRequest) { // Одиночное попадание — подсветку строки ведём только по клику на слот; клик по пустому // месту снимает подсветку. Меню выбора (наложение) при этом закрываем. setSlotPicks([]); const id = selection?.id; if (!selection) setActiveSlotId(undefined); else if (id && (id.startsWith("slot:") || id.startsWith("booked:"))) setActiveSlotId(id); return; } if (selection && selection.type === "polygon" && selection.layer === "requests") { setMapHits([selection]); setActiveHitId(selection.id); } else { setMapHits([]); setActiveHitId(undefined); } }, [hasRequest] ); // Наложение объектов под точкой. С выбранной заявкой — меню выбора слота (плотные наложения); // без заявки — список заявок в инспекторе с раскрытием нужной. const handleObjectsAtPoint = useCallback( (selections: Tgu2DMapSelection[]) => { if (hasRequest) { const seen = new Set(); const picks: SlotPick[] = []; for (const sel of selections) { if (sel.type !== "polygon") continue; if (!sel.id.startsWith("slot:") && !sel.id.startsWith("booked:")) continue; if (seen.has(sel.id)) continue; const key = sel.id.slice(sel.id.indexOf(":") + 1); const slot = slotByKey.get(key); if (!slot) continue; seen.add(sel.id); picks.push({ id: sel.id, slot, booked: bookedKeys.has(key) }); } setSlotPicks(picks); return; } const requests = selections.filter((s) => s.type === "polygon" && s.layer === "requests"); const hits = requests.length > 0 ? requests : selections; setMapHits(hits); setActiveHitId(hits[0]?.id); }, [hasRequest, slotByKey, bookedKeys] ); // Выбор слота из меню наложения — подсвечиваем строку, но меню оставляем открытым // (с пометкой активного пункта), чтобы можно было перебрать наложенные слоты; закрыть — крестиком. const pickSlot = useCallback((id: string) => { setActiveSlotId(id); }, []); const closeSlotPick = useCallback(() => setSlotPicks([]), []); const toggleHit = useCallback((selection: Tgu2DMapSelection) => { setActiveHitId((current) => (current === selection.id ? undefined : selection.id)); }, []); const closeInspector = useCallback(() => { setMapHits([]); setActiveHitId(undefined); }, []); const busy = calculating || autoFilling; const canCalculate = hasRequest && !rangeInvalid && !busy; const overlayMessage = calculating ? "Расчёт слотов…" : autoFilling ? "Автоподбор покрытия…" : !hasRequest ? requestsLoading ? "Загрузка заявок…" : undefined : slots.length === 0 && bookedSlots.length === 0 ? "Слоты не рассчитаны — нажмите «Рассчитать слоты»." : undefined; // Видимое окно строк виртуализатора и распорки сверху/снизу (держат суммарную высоту списка). const virtualRows = rowVirtualizer.getVirtualItems(); const virtualTotal = rowVirtualizer.getTotalSize(); const padTop = virtualRows.length > 0 ? virtualRows[0].start : 0; const padBottom = virtualRows.length > 0 ? virtualTotal - virtualRows[virtualRows.length - 1].end : 0; // Слишком много полигонов в окне суток — подсказываем сузить (карту не блокируем). const mapOverloaded = filteredSlots.length > MAP_HINT_THRESHOLD; return (
Слоты
Расчёт покрытия и бронирование слотов заявки
Заявка
{selectedRequest ? selectedRequest.name : "—"}
{(slots.length > 0 || bookedSlots.length > 0) && (
слотов {slots.length} · забронировано {bookedSlots.length}
)}
{hasRequest ? ( <>
Выбрано: {selectedKeys.size} {otherDaysSelectedCount > 0 && ( {" "} (видно {visibleSelectedCount}, на др. сутки {otherDaysSelectedCount}) )}
{filteredSlots.length === 0 ? (
{slots.length > 0 ? "На выбранные сутки слотов нет." : "Слоты не рассчитаны."}
) : ( {padTop > 0 && ( )} {virtualRows.map((vrow) => { const slot = filteredSlots[vrow.index]; const key = slotKey(slot); const id = `slot:${key}`; const booked = bookedKeys.has(key); const isActive = activeSlotId === id; return ( selectRow(slot)} > ); })} {padBottom > 0 && ( )}
КА Виток Слот / цикл Начало Конец Крен Статус
event.stopPropagation()}> {}} onClick={(event) => handleSlotCheck(vrow.index, event.shiftKey)} aria-label="Выбрать слот" /> {slot.satelliteId} {slot.revolution} / {slot.revolutionSign === "DESC" ? "нисх" : "восх"} {slot.slotNumber} / {slot.cycle} {formatLocalDateTime(parseLocal(slot.tn))} {formatLocalDateTime(parseLocal(slot.tk))} {slot.roll.toFixed(1)}° {booked ? ( бронь ) : ( {slotStateLabel(slot.state)} )}
)}
) : (
Заявки на интервал
{requestsError && (
{requestsError}
)} {requestsLoaded && !requestsError && (
{requestItems.length === 0 ? "Заявок не найдено." : `Загружено: ${requestItems.length} из ${requestsTotal}`} {requestsTruncated && ( (показаны первые 5000 — уточните интервал) )}
)} {requestItems.length > 0 && (
OPTICS RSA COMBINED
)}
Кликните заявку на карте, чтобы выбрать.
)}
{overlayMessage && (
{overlayMessage} {busy && ( )}
)} {pendingBook && (
Забронировать {pendingBook.length} слотов? Это крупная бронь.
)} {hasRequest && ( )} {hasRequest && mapOverloaded && (
Много слотов в окне ({filteredSlots.length}) — сузьте сутки для удобной карты.
)} {hasRequest && days.length > 0 && ( )} {!hasRequest && mapHits.length > 0 && ( )} {hasRequest && slotPicks.length > 0 && ( )}
{notice &&
{notice}
} {error &&
{error}
}
); } function strategyLabel(strategy: SlotCoverageStrategy): string { return strategy === "CONTINUOUS" ? "непрерывный" : "жадный"; } function slotStateLabel(state: string): string { switch (state) { case "AVAILABLE": return "свободен"; case "BOOKED": return "бронь"; case "OCCUPIED": return "занят"; default: return state || "—"; } }