import { useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useVirtualizer } from "@tanstack/react-virtual"; import { fetchRequestsForMap } from "../../api/requestApi"; import { bookRequestSlots, cancelBookedSlots, fetchBookedSlotsByRequest, fetchRequestSlots } from "../../api/slotsApi"; import type { RequestMapItem } from "../../model/requestTypes"; import type { BookedSlotView, 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 { formatLocalDateTimeSeconds, parseLocal, toLocalInputValue, toUtcIso } from "../../utils/localTime"; import { SlotDaySelector, type SlotDay } from "./SlotDaySelector"; import { PassStepper } from "./PassStepper"; import { groupPasses } from "./slotPasses"; import { SlotPickMenu, type SlotPick } from "./SlotPickMenu"; import { SlotFilterBar, type SlotSortMode } from "./SlotFilterBar"; import { CoveragePanel } from "./CoveragePanel"; import { buildCoverageGrid, coverageOfMasks, rankByMarginalGain, sampleSlotMask, unionMasks, type RankCandidate, type SlotMask } from "./coverage"; import { applySlotFilters, slotStatusKey, type SlotStatusKey } from "./slotFilters"; import { computeConflicts } from "./slotConflicts"; import { deselectStripRange, selectionAfterCheck, selectStripRange } from "./slotSelection"; import { groupStrips, pickStripAnchor, pickStripEnd, type PolosaId } from "./slotStrips"; import { runChunked } from "./runChunked"; import { ChunkProgress } from "./ChunkProgress"; const DAY_MS = 24 * 60 * 60 * 1000; /** Размер чанка при бронировании больших наборов (бэк бронирует слоты по одному). */ const BOOK_CHUNK = 200; /** Оценка высоты строки таблицы (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 [strategy, setStrategy] = useState("GREEDY"); // Результаты: слоты-кандидаты и уже забронированные слоты заявки (с bookedSlotId для снятия по id). const [slots, setSlots] = useState([]); const [bookedSlots, setBookedSlots] = useState([]); const [selectedKeys, setSelectedKeys] = useState>(() => new Set()); // Тумблер следа покрытия на карте (D4 — локальный state, вкл. по умолчанию; глобальный // Tgu2DMapLayersState не трогаем). Оператор может снять след, оставив только % в панели. const [coverageOn, setCoverageOn] = useState(true); // Тумблер подсветки конфликтов выбора (тусклый слой). Вкл. по умолчанию; выключение и гасит слой, // и пропускает пересчёт (см. conflictKeys) — заодно дешевле на больших наборах. const [showConflicts, setShowConflicts] = useState(true); // Сортировка таблицы: «время» (порядок расчёта), «вклад» (жадное ранжирование по приросту // покрытия, колонка +N%), «крен» (по модулю угла крена). Вклад доступен только при наличии // геометрии заявки (иначе чип «вклад» заблокирован в SlotFilterBar). const [sortMode, setSortMode] = useState("time"); // Фильтр отображения по суткам: индексы (1-based) выбранного диапазона суток расчёта. const [dayFrom, setDayFrom] = useState(1); const [dayTo, setDayTo] = useState(1); // Курсор прохождения (витка): индекс в хронологическом списке passes; -1 — проход не выбран. // Пока проход выбран, отображение изолировано до него; «Все» (clearPass) возвращает курсор в -1. const [passCursor, setPassCursor] = useState(-1); // Фильтры видимости (статус/КА/ветвь). Единая модель «selected = visible»: множество = что // видно, пусто = ничего. Дефолт — всё видно; satFilter инициализируется всеми КА при расчёте. const [statusFilter, setStatusFilter] = useState>( () => new Set(["free", "booked", "occupied"]) ); const [satFilter, setSatFilter] = useState>(() => new Set()); const [branchFilter, setBranchFilter] = useState>( () => new Set(["ASC", "DESC"]) ); const [calculating, setCalculating] = useState(false); const [autoFilling, setAutoFilling] = useState(false); const [booking, setBooking] = useState(false); // Снятие выбранных броней пачками (request-aware batch) — отдельный флаг «в полёте». const [cancellingSel, setCancellingSel] = useState(false); // Прогресс крупной чанковой операции (бронь / снятие выбранных) — общий бар . const [progress, setProgress] = useState<{ label: string; done: number; total: number } | null>(null); const [pendingBook, setPendingBook] = useState(null); // «Снять всё» убирает ВСЕ брони заявки — необратимо, поэтому всегда через подтверждение. // cancelling отделяет «снятие всего в полёте» от брони/снятия выбранных. const [pendingCancel, setPendingCancel] = useState(false); const [cancelling, setCancelling] = useState(false); const [error, setError] = useState(); const [notice, setNotice] = useState(); // Кэш распарсенных WKT-контуров по ключу слота — парсим контур один раз, а не на каждый // ререндер сцены (критично на тысячах слотов). Чистится при смене заявки. const wktCacheRef = useRef(new Map()); // Контроллер отмены текущего расчёта/автоподбора — прерывается при повторе или смене заявки. const abortRef = useRef(null); // Якорь жеста «полоса от первого к последнему»: ключ первого кликнутого слота. Состояние (не ref): // по нему подсвечиваем строки той же полосы, чтобы оператор видел границу выделения. const [anchorKey, setAnchorKey] = useState(null); // Последний конец Shift-диапазона в таблице: повторный Shift+клик по тому же концу снимает диапазон // (toggle). Ref — для логики жеста, перерисовка не нужна; сбрасывается при смене якоря/набора. const lastShiftEndRef = 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]); // Бронь по ключу слота — чтобы из выбранной/кликнутой строки достать bookedSlotId для снятия. const bookedByKey = useMemo(() => { const map = new Map(); for (const slot of bookedSlots) map.set(slotKey(slot), slot); return map; }, [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]); // Строки таблицы = кандидаты + брони, которых нет среди кандидатов (Finding 2-A). Так бронь // выбираема и снимаема, даже если кандидаты ещё не рассчитаны или бронь в другом наборе. const displaySlots = useMemo(() => { const slotKeySet = new Set(slots.map(slotKey)); const orphanBooked = bookedSlots.filter((slot) => !slotKeySet.has(slotKey(slot))); return orphanBooked.length === 0 ? slots : [...slots, ...orphanBooked]; }, [slots, bookedSlots]); // bookedSlotId выбранных броней (для «Снять выбранные», чанкуется) и число выбранных кандидатов // под бронь (для «Забронировать»). Бронь и кандидат различаем по bookedKeys. const selectedBookedIds = useMemo(() => { const ids: number[] = []; for (const key of selectedKeys) { const booked = bookedByKey.get(key); if (booked) ids.push(booked.bookedSlotId); } return ids; }, [selectedKeys, bookedByKey]); const selectedBookableCount = useMemo(() => { let n = 0; for (const key of selectedKeys) { const slot = slotByKey.get(key); if (!bookedKeys.has(key) && !(slot && slotIsBusy(slot))) n += 1; } return n; }, [selectedKeys, bookedKeys, slotByKey]); // Сутки, по которым раскладываются строки таблицы (для фильтра отображения). const days = useMemo(() => { const buckets = new Map(); for (const slot of displaySlots) { 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) })); }, [displaySlots]); // Сигнатура набора суток (границы дней). Бронь меняет bookedSlots → новый референс days, но сам // набор суток тот же — по сигнатуре отличаем «реально другой набор» от «то же, новый массив». const dayKeys = useMemo(() => days.map((d) => d.dayMs).join(","), [days]); // Новый расчёт меняет НАБОР суток — раскрываем фильтр на весь диапазон. Сброс только при смене // сигнатуры (новый расчёт/иной интервал), а не на каждое изменение bookedSlots после брони. useEffect(() => { const count = dayKeys === "" ? 0 : dayKeys.split(",").length; setDayFrom(1); setDayTo(count || 1); }, [dayKeys]); const effDayFrom = Math.max(1, Math.min(dayFrom, days.length || 1)); const effDayTo = Math.max(effDayFrom, Math.min(dayTo, days.length || 1)); // Витки (проходы) расчёта в хронологическом порядке — для пошаговой навигации ◀/▶. // Строим над СТАБИЛЬНЫМ displaySlots (как полосы), чтобы курсор доходил до любого витка // независимо от фильтров видимости и окна суток. const passes = useMemo(() => groupPasses(displaySlots, slotKey), [displaySlots]); // Новый расчёт меняет набор проходов — сбрасываем курсор (как окно суток по dayKeys). useEffect(() => { setPassCursor(-1); }, [dayKeys]); // Курсор не должен «висеть» за пределами усохшего набора (бронь/смена набора): клампим к -1. const effPassCursor = passCursor >= 0 && passCursor < passes.length ? passCursor : -1; const currentPass = effPassCursor >= 0 ? passes[effPassCursor] : null; // Ключи слотов текущего витка — для подсветки строк и режима изоляции. const passKeySet = useMemo(() => new Set(currentPass?.keys ?? []), [currentPass]); // Слоты в окне суток (до фильтров видимости) — база для счётчиков статусов и фильтрации. // Пока выбрано прохождение — показываем только его слоты (изоляция); иначе — окно суток. const dayWindowed = useMemo(() => { if (currentPass) { return displaySlots.filter((slot) => passKeySet.has(slotKey(slot))); } if (days.length === 0) return displaySlots; const fromMs = days[effDayFrom - 1].dayMs; const toMsExcl = days[effDayTo - 1].dayMs + DAY_MS; return displaySlots.filter((slot) => { const ms = parseLocal(slot.tn); return Number.isFinite(ms) && ms >= fromMs && ms < toMsExcl; }); }, [displaySlots, days, effDayFrom, effDayTo, currentPass, passKeySet]); // Счётчики статусов по окну суток (до статус-фильтра) — стабильны при переключении чипов. const statusCounts = useMemo>(() => { const counts: Record = { free: 0, booked: 0, occupied: 0 }; for (const slot of dayWindowed) counts[slotStatusKey(slot, bookedKeys.has(slotKey(slot)))] += 1; return counts; }, [dayWindowed, bookedKeys]); // КА в наборе (для чипов фильтра); сигнатура — чтобы инициализировать satFilter только при // смене набора КА (новый расчёт), а не при каждой брони. const satellites = useMemo(() => { const ids = new Set(); for (const slot of displaySlots) ids.add(slot.satelliteId); return Array.from(ids).sort((a, b) => a - b); }, [displaySlots]); const satSignature = satellites.join(","); // Новый набор КА → показываем все КА (бронирование набор КА не меняет, поэтому не сбрасываем). // useLayoutEffect — чтобы satFilter раскрылся ДО отрисовки и не мигнул пустой таблицей. useLayoutEffect(() => { setSatFilter(new Set(satellites)); // eslint-disable-next-line react-hooks/exhaustive-deps }, [satSignature]); // Слоты, видимые в таблице и на карте: окно суток + фильтры видимости (статус/КА/ветвь). const filteredSlots = useMemo( () => applySlotFilters( dayWindowed, { status: statusFilter, satellites: satFilter, branches: branchFilter }, (slot) => bookedKeys.has(slotKey(slot)), ), [dayWindowed, statusFilter, satFilter, branchFilter, bookedKeys], ); // Ключи слотов, конфликтующих с текущим выбором (тот же КА + недостаточный зазор на манёвр) — гасим // их как подсказку «станет недоступным». Приближённая клиентская эвристика; реальная выполнимость // (slew/maxChainLength) — за бэкендом (it.2), поэтому слоты остаются выбираемыми. См. slotConflicts. // // Производительность: выбор берём через useDeferredValue — во время Shift+drag (selectedKeys меняется // на каждый pointermove) пересчёт идёт фоном/после паузы, не блокируя жест. Кандидаты — только видимый // filteredSlots; источник занятых интервалов — полный displaySlots (выбор может быть вне окна суток). // Разметка полос — над СТАБИЛЬНЫМ набором displaySlots (НЕ filteredSlots/sortedSlots: иначе // фильтр-дырка ложно разорвала или склеила бы полосу). Полоса = один КА + один крен + подряд по // времени (виток не граница). idByKey — id полосы по ключу слота; membersOf — члены полосы по времени. // Нужна и для конфликтов ниже: слоты одной полосы между собой не конфликтуют. const polosa = useMemo(() => groupStrips(displaySlots, slotKey), [displaySlots]); const deferredSelectedKeys = useDeferredValue(selectedKeys); const conflictKeys = useMemo( () => showConflicts ? computeConflicts( displaySlots, filteredSlots, slotKey, (slot) => deferredSelectedKeys.has(slotKey(slot)), (slot) => bookedKeys.has(slotKey(slot)) || slotIsBusy(slot), (slot) => polosa.idByKey.get(slotKey(slot)), ) : new Set(), [showConflicts, displaySlots, filteredSlots, deferredSelectedKeys, bookedKeys, polosa] ); // id полигонов конфликтных слотов — приглушение на карте (параллель markedSlotIds). const dimmedSlotIds = useMemo( () => new Set(Array.from(conflictKeys, (key) => `slot:${key}`)), [conflictKeys] ); // Конфликтные слоты нельзя добавлять в выбор под бронь (жёсткая блокировка). Держим текущий набор в // ref, чтобы обработчики жестов (useCallback) читали его без пересоздания на каждый ререндер выбора. const conflictKeysRef = useRef>(conflictKeys); conflictKeysRef.current = conflictKeys; // Убирает заблокированные (конфликтные) ключи из нового набора выбора. Уже выбранные слоты в // conflictKeys не попадают, поэтому снятие выбора и прежний выбор не затрагиваются — режутся только // конфликтные слоты, которые жест попытался добавить. Мутирует переданный (свежий) Set. const dropConflicts = useCallback((next: Set): Set => { const blocked = conflictKeysRef.current; if (blocked.size > 0) for (const key of blocked) next.delete(key); return next; }, []); // Состояние жеста «выбор полосы мышью на карте» (Shift+drag). Держим в ref: правки идут на каждом // движении указателя, перерисовка не нужна. members — члены полосы якоря без BUSY (их не бронируем); // endKey — текущий конец (на старте = якорь; меняется в drag) — нужен commit, чтобы отличить // одиночный клик (end === anchor) от протяжки. const mapStripRef = useRef<{ anchorKey: string; anchorId: PolosaId; members: string[]; endKey: string } | null>(null); // Последний конец одиночного Shift-клика на карте: повтор по тому же слоту снимает его (toggle). const lastMapEndRef = useRef(null); // Shift+нажатие по слотам на карте: якорь полосы = самый ранний по времени выбираемый слот под // курсором. Выбор этой полосы сбрасываем к одному якорю (далее drag растянет), как и в таблице. const handleStripAnchor = useCallback( (keys: string[]) => { const tnMsOf = (key: string) => { const slot = slotByKey.get(key); return slot ? parseLocal(slot.tn) : Number.NaN; }; // Конфликтные слоты в выбор не берём — на них нельзя ни встать якорем, ни попасть в полосу. const blocked = conflictKeysRef.current; const selectable = keys.filter((key) => { const slot = slotByKey.get(key); return slot && !slotIsBusy(slot) && !blocked.has(key); }); const anchor = pickStripAnchor(selectable, tnMsOf); const anchorId = anchor ? polosa.idByKey.get(anchor) : undefined; if (!anchor || !anchorId) { mapStripRef.current = null; return; } const members = (polosa.membersOf.get(anchorId) ?? []).filter((key) => { const slot = slotByKey.get(key); return !(slot && slotIsBusy(slot)) && !blocked.has(key); }); mapStripRef.current = { anchorKey: anchor, anchorId, members, endKey: anchor }; setAnchorKey(anchor); lastShiftEndRef.current = null; setSelectedKeys((current) => selectStripRange(current, members, anchor, anchor)); }, [polosa, slotByKey] ); // Движение указателя в жесте: конец полосы = самый поздний по времени член ТОЙ ЖЕ полосы под // курсором (граница полосы соблюдается). Никого из полосы под курсором — оставляем прежний конец. const handleStripDrag = useCallback( (keys: string[]) => { const gesture = mapStripRef.current; if (!gesture) return; const end = pickStripEnd(polosa, gesture.anchorId, keys); if (!end) return; gesture.endKey = end; setSelectedKeys((current) => selectStripRange(current, gesture.members, gesture.anchorKey, end)); }, [polosa] ); const handleStripCommit = useCallback(() => { const gesture = mapStripRef.current; mapStripRef.current = null; if (!gesture) return; // Одиночный Shift+клик (без протяжки): end === anchor. Повтор по тому же слоту снимает его // (anchor на pointerdown уже переотметил его — на commit гасим). Протяжку (диапазон) не тогглим. const isClick = gesture.endKey === gesture.anchorKey; if (isClick && lastMapEndRef.current === gesture.anchorKey) { setSelectedKeys((current) => { const next = new Set(current); next.delete(gesture.anchorKey); return next; }); lastMapEndRef.current = null; } else { lastMapEndRef.current = isClick ? gesture.anchorKey : null; } }, []); // Якорь жеста привязан к ВИДИМОМУ набору: смена фильтров/окна суток/брони делает прежний якорь // неактуальным → сбрасываем (как при смене сортировки), иначе Shift выделил бы не ту полосу. useEffect(() => { setAnchorKey(null); mapStripRef.current = null; lastShiftEndRef.current = null; lastMapEndRef.current = null; }, [filteredSlots]); // Загрузка заявок интервала (на карту); вызывается на входе, при смене диапазона и из фильтра. 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: toUtcIso(fromMs), beginTo: toUtcIso(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]); 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; }, []); // Сетка сэмплов покрытия по геометрии заявки (кэш на заявку, D1). null — заявка без геометрии // или дегенерат (0 валидных сэмплов) → панель «недоступно». const coverageGrid = useMemo( () => (selectedRequest?.geometry ? buildCoverageGrid(parseWktPolygon(selectedRequest.geometry)) : null), [selectedRequest?.geometry] ); // Маска слота на сетке заявки, с кэшем по контуру (D2 — сигнатура grid+контур). Кэш живёт ровно // пока стабильна сетка: новый coverageGrid (смена заявки/геометрии) пересоздаёт Map → чистка. const maskFor = useMemo(() => { const grid = coverageGrid; const cache = new Map(); return (key: string, contour: string): SlotMask | null => { if (!grid) return null; let mask = cache.get(contour); if (!mask) { mask = sampleSlotMask(grid, ringsFor(key, contour)); cache.set(contour, mask); } return mask; }; }, [coverageGrid, ringsFor]); // Кольца следа покрытия для карты = union контуров (забронированное ∪ выбранное галочками), // независимо от фильтров вида (codex P1). Пусто, когда тумблер выключен или нет заявки. const coverageRings = useMemo(() => { if (!coverageOn || !hasRequest) return undefined; const rings: GeoPoint[][] = []; const seen = new Set(); const push = (key: string, contour: string) => { if (seen.has(key)) return; seen.add(key); for (const ring of ringsFor(key, contour)) if (ring.length >= 3) rings.push(ring); }; for (const slot of bookedSlots) push(slotKey(slot), slot.contour); for (const key of selectedKeys) { const slot = slotByKey.get(key); if (slot) push(key, slot.contour); } return rings; }, [coverageOn, hasRequest, bookedSlots, selectedKeys, slotByKey, ringsFor]); // % покрытия для панели: забронированным набором и набором (забронированное ∪ выбранное). // Семантика codex: % = покрытие union(booked ∪ selected), независимо от фильтров вида. const coverageStats = useMemo(() => { if (!coverageGrid) return { booked: null, withSelection: null, hasSelection: false }; const bookedMasks: SlotMask[] = []; for (const slot of bookedSlots) { const mask = maskFor(slotKey(slot), slot.contour); if (mask) bookedMasks.push(mask); } const selMasks: SlotMask[] = []; let hasSelection = false; for (const key of selectedKeys) { if (bookedKeys.has(key)) continue; // бронь уже учтена const slot = slotByKey.get(key); if (!slot) continue; const mask = maskFor(key, slot.contour); if (mask) { selMasks.push(mask); hasSelection = true; } } return { booked: coverageOfMasks(coverageGrid, bookedMasks), withSelection: coverageOfMasks(coverageGrid, [...bookedMasks, ...selMasks]), hasSelection, }; }, [coverageGrid, maskFor, bookedSlots, selectedKeys, bookedKeys, slotByKey]); // Покрытие доступно (есть геометрия заявки) — гейт сортировки «по вкладу» и колонки +N%. const coverageAvailable = coverageGrid !== null; // Ранжирование «по вкладу»: жадно (rankByMarginalGain) поверх забронированного union по ВИДИМЫМ // незабронированным кандидатам. Вклад фиксируется на момент ранжирования (не live — отсюда подпись // колонки). Считаем только в режиме сортировки «вклад» и при наличии сетки. key → gainPercent + ранг. const gainRanking = useMemo(() => { if (sortMode !== "gain" || !coverageGrid) return null; const bookedMasks: SlotMask[] = []; for (const slot of bookedSlots) { const mask = maskFor(slotKey(slot), slot.contour); if (mask) bookedMasks.push(mask); } const baseUnion = unionMasks(coverageGrid, bookedMasks); const candidates: RankCandidate[] = []; for (const slot of filteredSlots) { const key = slotKey(slot); if (bookedKeys.has(key)) continue; // бронь уже в baseUnion → её вклад 0 const mask = maskFor(key, slot.contour); if (mask) candidates.push({ key, mask }); } const ranked = rankByMarginalGain(coverageGrid, candidates, baseUnion); const order = new Map(); const pct = new Map(); ranked.forEach((r, i) => { order.set(r.key, i); pct.set(r.key, r.gainPercent); }); return { order, pct }; }, [sortMode, coverageGrid, maskFor, bookedSlots, filteredSlots, bookedKeys]); // Колонку +N% показываем только в режиме «вклад» и при наличии геометрии. const showGain = sortMode === "gain" && coverageAvailable; // Порядок строк таблицы по выбранной сортировке. «время» — исходный (порядок расчёта, как есть). // «крен» — по модулю крена (меньше = лучше геометрия), стабильно. «вклад» — жадный ранг; брони // (вне ранжирования) уходят в конец, сохраняя исходный порядок. sort стабилен (ES2019+). const sortedSlots = useMemo(() => { if (sortMode === "roll") { return [...filteredSlots].sort((a, b) => Math.abs(a.roll) - Math.abs(b.roll)); } if (sortMode === "gain" && gainRanking) { const order = gainRanking.order; return [...filteredSlots].sort((a, b) => { const ra = order.get(slotKey(a)); const rb = order.get(slotKey(b)); if (ra == null && rb == null) return 0; if (ra == null) return 1; if (rb == null) return -1; return ra - rb; }); } return filteredSlots; }, [sortMode, filteredSlots, gainRanking]); // Виртуализация таблицы: в DOM держим только видимое окно строк (+overscan), а не все слоты — // иначе на тысячах строк рендер встаёт. Скролл-контейнер — секция списка (listRef). const rowVirtualizer = useVirtualizer({ count: sortedSlots.length, getScrollElement: () => listRef.current, estimateSize: () => ROW_HEIGHT, overscan: 12 }); // Клик по слоту на карте подсвечивает строку — центрируем её в видимой части таблицы. // Скроллим контейнер вручную (по rect-ам), а не через scrollIntoView: тот может выбрать другой // скролл-предок или счесть строку «достаточно близкой» и не сдвинуться. useEffect(() => { if (!activeSlotId || !activeSlotId.startsWith("slot:")) return; const key = activeSlotId.slice("slot:".length); const index = sortedSlots.findIndex((slot) => slotKey(slot) === key); if (index >= 0) rowVirtualizer.scrollToIndex(index, { align: "center" }); }, [activeSlotId, sortedSlots, rowVirtualizer]); // Переход на прохождение прокручивает таблицу к началу (без подсветки строки): оператор видит // первый слот изолированного прохождения. Только при выбранном проходе — иначе скролл не трогаем. useEffect(() => { if (effPassCursor >= 0) rowVirtualizer.scrollToIndex(0, { align: "start" }); }, [effPassCursor, rowVirtualizer]); // Загружает брони выбранной заявки (для бейджей и слоя карты). 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(); setAnchorKey(null); setSlots([]); setSelectedKeys(new Set()); setBookedSlots([]); setPendingBook(null); setPendingCancel(false); setNotice(undefined); setError(undefined); setActiveSlotId(undefined); // Смена заявки — фильтры видимости в дефолт (satFilter раскроется через satSignature-эффект). setStatusFilter(new Set(["free", "booked", "occupied"])); setBranchFilter(new Set(["ASC", "DESC"])); setSortMode("time"); 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; setAnchorKey(null); if (auto) setAutoFilling(true); else setCalculating(true); setError(undefined); setNotice(undefined); try { const result = await fetchRequestSlots( { requestId: selectedRequestId, timeStart: toUtcIso(parsedRange.fromMs), timeStop: toUtcIso(parsedRange.toMs), revSign: revSign || undefined, // cov=true (режим покрытия) ровно когда задана стратегия — это путь «Подобрать // покрытие». «Показать все слоты» идёт без стратегии и получает полный набор кандидатов, // включая занятые бронью (all=true) — занятые показываем для обзора, бронировать нельзя. cov: auto, all: !auto, 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}.` : "Для выбранного интервала слотов покрытия не найдено." ); } // Карту НЕ перецентрируем: result[0] — первый по времени/витку, географически произвольный // угол, а зум до 4.5 спрятал бы остальной набор. Оставляем общий обзор заявки на месте; // прицельный фокус — по клику на строку таблицы (focusOn в onRowClick). } 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(); // Любая операция с бронью «в полёте» — общий гейт блокировки кнопок (бронь / снятие выбранных / // снятие всего). Отделён от busy (расчёт/автоподбор). const opBusy = booking || cancelling || cancellingSel; // Бронирует набор чанками (бэк бронирует по одному в транзакции — крупный набор бьём на части // через runChunked) и показывает прогресс. bookedSlotId берём из ответа POST. const performBook = async (toBook: SlotBookingRequest[]) => { setBooking(true); setError(undefined); setNotice(undefined); setProgress(toBook.length > BOOK_CHUNK ? { label: "Бронирование", done: 0, total: toBook.length } : null); try { const parts = await runChunked( toBook, BOOK_CHUNK, (part) => bookRequestSlots(selectedRequestId, part), (done, total) => setProgress({ label: "Бронирование", done, total }), ); const collected = parts.flat(); // Источник истины — ответ POST: BookedSlotDTO несёт bookedSlotId и тот же cycle, что мы // отправили (= cycle рассчитанного слота). Контур берём из рассчитанного слота по ключу; // by-request не перечитываем. Так bookedSlots получает и контур, и bookedSlotId. const idByKey = new Map( collected.map((item) => [`${item.satelliteId}:${item.slotNum}:${item.cycle}`, item.bookedSlotId]), ); const justBooked: BookedSlotView[] = slots .filter((slot) => idByKey.has(slotKey(slot))) .map((slot) => ({ ...slot, bookedSlotId: idByKey.get(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); setProgress(null); } }; // Выбранные кандидаты под бронь (без уже забронированных — их бронировать незачем). const collectSelected = (): SlotBookingRequest[] => slots .filter( (slot) => selectedKeys.has(slotKey(slot)) && !bookedKeys.has(slotKey(slot)) && !slotIsBusy(slot), ) .map((slot) => ({ satelliteId: slot.satelliteId, slotNum: slot.slotNumber, cycle: slot.cycle })); const book = () => { if (!selectedRequestId || opBusy || selectedBookableCount === 0) return; const toBook = collectSelected(); if (toBook.length === 0) return; // Бронь — всегда через подтверждение (симметрично «Снять всё»), затем чанкованный POST. setPendingBook(toBook); }; const confirmPendingBook = () => { const toBook = pendingBook; setPendingBook(null); if (toBook) void performBook(toBook); }; const cancelPendingBook = () => setPendingBook(null); // Общий чанковый движок снятия: бьёт ids на части (request-aware batch), показывает прогресс // (тот же бар, что у брони), затем убирает снятые из bookedSlots и из выбора. На него опираются // и «Снять выбранные», и «Снять всё», и ✕ в строке — снятие везде пачками с прогрессом, а не // одним bulk-запросом (по одному HTTP нельзя показать ход на 7000 слотов). setBusy — флаг кнопки. const runCancel = async (ids: number[], setBusy: (value: boolean) => void, forceProgress = false) => { setBusy(true); setError(undefined); setNotice(undefined); // forceProgress — для «Снять всё»: держим бар весь процесс независимо от объёма (операция // делает доп. сверку by-request, поэтому заметно дольше точечного снятия даже на малом наборе). setProgress(forceProgress || ids.length > BOOK_CHUNK ? { label: "Снятие брони", done: 0, total: ids.length } : null); try { const counts = await runChunked( ids, BOOK_CHUNK, (part) => cancelBookedSlots(selectedRequestId, part), (done, total) => setProgress({ label: "Снятие брони", done, total }), ); const removed = counts.reduce((a, b) => a + b, 0); const idSet = new Set(ids); setSelectedKeys((prev) => { const next = new Set(prev); for (const slot of bookedSlots) if (idSet.has(slot.bookedSlotId)) next.delete(slotKey(slot)); return next; }); setBookedSlots((prev) => prev.filter((slot) => !idSet.has(slot.bookedSlotId))); setNotice(`Снято броней: ${removed}.`); } catch (caught) { setError(caught instanceof Error ? caught.message : "Не удалось снять бронь."); } finally { setBusy(false); setProgress(null); } }; // Снять выбранные брони (по галочкам). Выбор явный — без подтверждения. const cancelSelected = () => { if (!selectedRequestId || opBusy || selectedBookedIds.length === 0) return; void runCancel(selectedBookedIds, setCancellingSel); }; // Снять одну бронь из строки таблицы — батч из одного id (обратимо ре-бронью, без подтверждения). const cancelOne = (bookedSlotId: number) => { if (!selectedRequestId || opBusy) return; void runCancel([bookedSlotId], setCancellingSel); }; // Снять ВСЕ брони заявки — тоже пачками (request-aware batch) с прогрессом. Необратимо → // подтверждение. Отдельный флаг cancelling, чтобы «Снятие…» горело именно на этой кнопке. // Источник истины — backend: перечитываем брони by-request и снимаем ОБЪЕДИНЕНИЕ локального // набора и серверного. Так «Снять всё» убирает вообще все брони заявки, даже если локальный // bookedSlots разошёлся с базой (bookedSlotId надёжен независимо от cycle-фрейма брони). const performCancelAll = async () => { // Мгновенная обратная связь (как у «Забронировать»/«Снять выбранные»): входим в busy и // показываем прогресс СРАЗУ, ещё до сетевой сверки by-request, а не после её round-trip. setCancelling(true); setError(undefined); setNotice(undefined); setProgress({ label: "Снятие брони", done: 0, total: bookedSlots.length }); const idSet = new Set(bookedSlots.map((slot) => slot.bookedSlotId)); try { const fresh = await fetchBookedSlotsByRequest(selectedRequestId); for (const slot of fresh) idSet.add(slot.bookedSlotId); } catch { // by-request недоступен — снимаем по локальному набору броней. } await runCancel(Array.from(idSet), setCancelling, true); }; const cancelAll = () => { if (!selectedRequestId || opBusy || bookedSlots.length === 0) return; setPendingCancel(true); }; const confirmPendingCancel = () => { setPendingCancel(false); void performCancelAll(); }; const cancelPendingCancel = () => setPendingCancel(false); // Отметка слота из таблицы. // • Обычный клик — переключает один слот и ставит его якорем полосы. // • Shift+клик — жест «полоса от первого к последнему»: выделяет всех членов ОДНОЙ полосы между // якорем и текущим слотом (включительно). Если текущий слот из другой полосы (другой КА/крен/ // разрыв по времени) — выбор не меняем и подсказываем: выйти за полосу нельзя. const handleSlotCheck = (index: number, shiftKey: boolean) => { const targetKey = slotKey(sortedSlots[index]); if (shiftKey && anchorKey) { const anchorId = polosa.idByKey.get(anchorKey); const targetId = polosa.idByKey.get(targetKey); if (!anchorId || anchorId !== targetId) { setNotice("Выделить можно только в пределах одной полосы (один КА, один крен, подряд по времени)."); return; // якорь оставляем — оператор может выбрать другой конец той же полосы } // Члены полосы по времени, без BUSY (их бронировать нельзя — в выделение не берём). const members = (polosa.membersOf.get(anchorId) ?? []).filter((k) => { const slot = slotByKey.get(k); return !(slot && slotIsBusy(slot)); }); if (lastShiftEndRef.current === targetKey) { // Повторный Shift+клик по тому же концу — снимаем выделенный диапазон [якорь..конец]. setSelectedKeys((current) => deselectStripRange(current, members, anchorKey, targetKey)); lastShiftEndRef.current = null; } else { // Конфликтные члены полосы в выбор не берём (dropConflicts) — их выбрать нельзя. setSelectedKeys((current) => dropConflicts(selectStripRange(current, members, anchorKey, targetKey))); lastShiftEndRef.current = targetKey; } // Якорь НЕ двигаем: остаётся первым кликом, чтобы повторный Shift+клик задавал диапазон // от того же якоря — ближе к нему «сжимал» выделение, дальше — расширял. return; } // Обычный клик по конфликтному слоту — выбрать нельзя (снять выбор не блокируем: конфликтные // слоты в выбор не попадают, значит targetKey тут заведомо не выбран). if (conflictKeysRef.current.has(targetKey)) { setNotice("Слот конфликтует с выбором (не хватает времени на манёвр того же КА) — выбрать нельзя."); return; } setSelectedKeys((current) => selectionAfterCheck(current, (i) => slotKey(sortedSlots[i]), index, false, null) ); setAnchorKey(targetKey); lastShiftEndRef.current = null; lastMapEndRef.current = null; }; // Чекбокс шапки — выбрать/снять все ВИДИМЫЕ (по окну суток) выбираемые слоты. BUSY (занятые // бронью) бронировать нельзя — в массовый выбор не берём. const selectableVisible = useMemo(() => filteredSlots.filter((slot) => !slotIsBusy(slot)), [filteredSlots]); const allVisibleSelected = selectableVisible.length > 0 && selectableVisible.every((slot) => selectedKeys.has(slotKey(slot))); const toggleAllVisible = () => { setSelectedKeys((current) => { const next = new Set(current); if (allVisibleSelected) { for (const slot of selectableVisible) next.delete(slotKey(slot)); } else { for (const slot of selectableVisible) next.add(slotKey(slot)); dropConflicts(next); // конфликтные видимые слоты не добавляем } return next; }); setAnchorKey(null); }; // Выбрать все слоты расчёта (по всем суткам, кроме занятых бронью и конфликтных видимых), снять весь // выбор. Конфликты считаются по видимому набору, поэтому режем известные (dropConflicts) — бэкенд // остаётся финальным арбитром выполнимости. const selectAllDays = () => setSelectedKeys(dropConflicts(new Set(displaySlots.filter((slot) => !slotIsBusy(slot)).map(slotKey)))); const clearSelection = () => { setSelectedKeys(new Set()); setAnchorKey(null); }; // Тогглы фильтров видимости (selected = visible: клик добавляет/убирает значение из множества). const toggleInSet = (set: ReadonlySet, value: T): Set => { const next = new Set(set); if (next.has(value)) next.delete(value); else next.add(value); return next; }; // Смена сортировки меняет позиции строк — якорь Shift-выбора больше не валиден, сбрасываем. const changeSort = (mode: SlotSortMode) => { setSortMode(mode); setAnchorKey(null); }; const toggleStatus = (key: SlotStatusKey) => setStatusFilter((prev) => toggleInSet(prev, key)); const toggleSat = (id: number) => setSatFilter((prev) => toggleInSet(prev, id)); const toggleBranch = (branch: RevolutionSign) => setBranchFilter((prev) => toggleInSet(prev, branch)); // Фильтры в дефолте — всё видно (на всех трёх осях выбраны все значения). const filtersDefault = statusFilter.size === 3 && branchFilter.size === 2 && satFilter.size === satellites.length; const resetFilters = () => { setStatusFilter(new Set(["free", "booked", "occupied"])); setBranchFilter(new Set(["ASC", "DESC"])); setSatFilter(new Set(satellites)); }; // Сколько выбрано из видимых суток и сколько — на других сутках (для прозрачности охвата). 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 goToPass = useCallback( (target: number) => { if (passes.length === 0) return; const idx = Math.max(0, Math.min(target, passes.length - 1)); setPassCursor(idx); const pass = passes[idx]; // Окно суток следует за курсором: если проход вне окна — сдвигаем, сохраняя ширину диапазона. if (days.length > 0 && Number.isFinite(pass.startMs)) { const di = days.findIndex((d) => d.dayMs === dayStartMs(pass.startMs)); if (di >= 0) { const dayIndex = di + 1; const width = effDayTo - effDayFrom; if (dayIndex < effDayFrom) { setDayFrom(dayIndex); setDayTo(Math.min(days.length, dayIndex + width)); } else if (dayIndex > effDayTo) { setDayTo(dayIndex); setDayFrom(Math.max(1, dayIndex - width)); } } } // Снимаем активную подсветку строки с прошлого прохождения: при переходе не должен светиться // никакой слот (прокрутку к началу делает отдельный эффект по effPassCursor, без подсветки). setActiveSlotId(undefined); }, [passes, days, effDayFrom, effDayTo] ); // ◀ из «не выбран» входит с конца, ▶ — с начала; дальше клампим в [0, last]. const prevPass = () => goToPass(effPassCursor < 0 ? passes.length - 1 : effPassCursor - 1); const nextPass = () => goToPass(effPassCursor < 0 ? 0 : effPassCursor + 1); const clearPass = () => setPassCursor(-1); const passLabel = currentPass ? `КА ${currentPass.satelliteId} · прохождение ${currentPass.revolution} ${ currentPass.revolutionSign === "DESC" ? "нисх" : "восх" } · ${formatLocalDateTimeSeconds(currentPass.startMs)}` : ""; // Выбор заявки из инспектора — закрываем инспектор, переходим в режим расчёта слотов. 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++ }); } } // Рисуем все отфильтрованные слоты без потолка: общую картину не режем, объём сужает // оператор фильтрами (статус/КА/ветвь/сутки). Таблица виртуализирована, карта — canvas. 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++ }); } } // Брони вне текущего окна суток (их нет в filteredSlots) — показываем на карте отдельным слоем, // чтобы оператор видел все брони заявки. Те, что в окне, уже нарисованы циклом выше. НО слой // подчиняем фильтрам видимости: выключен статус «бронь» (или скрыт КА/ветвь) — бронь не рисуем, // иначе фильтр убирал бы бронь из таблицы, но не с карты. if (statusFilter.has("booked")) { const slotKeysShown = new Set(filteredSlots.map(slotKey)); for (const slot of bookedSlots) { const key = slotKey(slot); if (slotKeysShown.has(key)) continue; if (!satFilter.has(slot.satelliteId)) continue; if (!branchFilter.has(slot.revolutionSign)) 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: [], coverageRings }; }, [ hasRequest, requestItems, selectedRequest, slots, filteredSlots, bookedSlots, bookedKeys, ringsFor, statusFilter, satFilter, branchFilter, coverageRings ]); 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; // Число колонок таблицы (для colSpan распорок виртуализатора) — +1 на колонку «Вклад». const colCount = showGain ? 8 : 7; // Видимое окно строк виртуализатора и распорки сверху/снизу (держат суммарную высоту списка). 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; return (
Слоты
Расчёт покрытия и бронирование слотов заявки
Заявка
{selectedRequest ? selectedRequest.name : "—"}
{(slots.length > 0 || bookedSlots.length > 0) && (
слотов {slots.length} · забронировано {bookedSlots.length}
)}
{hasRequest ? ( <> {displaySlots.length > 0 && ( )} {displaySlots.length > 0 && ( )}
Выбрано: {selectedKeys.size} {otherDaysSelectedCount > 0 && ( {" "} (видно {visibleSelectedCount}, на др. сутки {otherDaysSelectedCount}) )} Полоса: клик+Shift в таблице · Shift+тянуть на карте
{filteredSlots.length === 0 ? (
{displaySlots.length === 0 ? ( "Слоты не рассчитаны." ) : dayWindowed.length === 0 ? ( "На выбранные сутки слотов нет." ) : ( <> Слоты скрыты фильтром — включите статус/КА или сбросьте фильтры.
)}
) : ( {showGain && ( )} {padTop > 0 && ( )} {virtualRows.map((vrow) => { const slot = sortedSlots[vrow.index]; const key = slotKey(slot); const id = `slot:${key}`; const booked = bookedKeys.has(key); const isActive = activeSlotId === id; // Подсветка полосы текущего якоря: оператор видит, что выделит Shift+клик, // и что за границу полосы (другой КА/крен/разрыв) выйти нельзя. const polosaId = polosa.idByKey.get(key); const inAnchorPolosa = anchorKey != null && polosaId != null && polosaId === polosa.idByKey.get(anchorKey); const isAnchorRow = anchorKey === key; // Подсветка строк текущего витка (курсор ◀/▶) — оператор видит проход целиком. const inPass = passKeySet.has(key); // Конфликт с выбором (тот же КА, пересечение по времени) — гасим строку. const isConflict = conflictKeys.has(key); const rowClass = [ inPass ? "tgu-slots-row--pass" : "", isConflict ? "tgu-slots-row--conflict" : "", isActive ? "is-active" : "", inAnchorPolosa ? "tgu-slots-row--strip" : "", isAnchorRow ? "is-strip-anchor" : "", ] .filter(Boolean) .join(" "); return ( { // Клик по строке = и подсветка на карте, и жест выбора полосы: // обычный клик выбирает слот + ставит якорь, Shift+клик тянет полосу // до этой строки. BUSY не выбираем (бронировать нельзя). selectRow(slot); if (!slotIsBusy(slot)) handleSlotCheck(vrow.index, event.shiftKey); }} > {showGain && ( )} ); })} {padBottom > 0 && ( )}
КА Виток Начало Длит. Крен Вклад Статус
event.stopPropagation()}> {}} onClick={(event) => handleSlotCheck(vrow.index, event.shiftKey)} disabled={slotIsBusy(slot)} title={slotIsBusy(slot) ? "Слот недоступен — конфликт с бронью, бронировать нельзя" : undefined} aria-label="Выбрать слот" /> {slot.satelliteId} {slot.revolution} / {slot.revolutionSign === "DESC" ? "нисх" : "восх"} /{" "} {slot.cycle} {formatLocalDateTimeSeconds(parseLocal(slot.tn))} {slotDurationSec(slot)} с {slot.roll.toFixed(1)}° {booked ? "—" : `+${gainRanking?.pct.get(key) ?? 0}%`} {booked ? ( бронь ) : ( {slotStateLabel(slot.state)} {slot.state === "BUSY" && slot.holders?.map((holder) => ( {holder.name ?? holder.requestId} {holder.importance != null && ( {holder.importance.toFixed(2)} )} ))} )}
)}
) : (
Заявки на интервал
{requestsError && (
{requestsError}
)} {requestsLoaded && !requestsError && (
{requestItems.length === 0 ? "Заявок не найдено." : `Загружено: ${requestItems.length} из ${requestsTotal}`} {requestsTruncated && ( (показаны первые 5000 — уточните интервал) )}
)} {requestItems.length > 0 && (
OPTICS RSA COMBINED
)}
Кликните заявку на карте, чтобы выбрать.
)}
{overlayMessage && (
{overlayMessage} {busy && ( )}
)} {pendingBook && (
Забронировать выбранные слоты ({pendingBook.length})?
)} {pendingCancel && (
Снять все брони заявки ({bookedSlots.length})? Действие необратимо.
)} {progress && ( )} {hasRequest && ( )} {hasRequest && days.length > 0 && (
{passes.length > 0 && ( )}
)} {!hasRequest && mapHits.length > 0 && ( )} {hasRequest && slotPicks.length > 0 && ( )}
{notice &&
{notice}
} {error &&
{error}
}
); } function strategyLabel(strategy: SlotCoverageStrategy): string { return strategy === "CONTINUOUS" ? "непрерывный" : "жадный"; } /** Длительность слота в секундах (tk − tn). Слоты короткие, поэтому показываем секунды. */ function slotDurationSec(slot: SlotDTO): number { return Math.max(0, Math.round((parseLocal(slot.tk) - parseLocal(slot.tn)) / 1000)); } /** Слот занят бронью на этом проходе КА (state BUSY): показываем для обзора, но забронировать * нельзя — исключаем из выбора под бронь. Бронь самой заявки приходит как BOOKED, не BUSY. */ function slotIsBusy(slot: Pick): boolean { return slot.state === "BUSY"; } /** Детерминированный оттенок (0..359) полосы по её StripId — для приглушённого рельса слева, * чтобы соседние полосы визуально различались. Чистый хэш строки, без состояния. */ function stripHue(stripId: string): number { let h = 0; for (let i = 0; i < stripId.length; i++) h = (h * 31 + stripId.charCodeAt(i)) >>> 0; return h % 360; } function slotStateLabel(state: string): string { switch (state) { case "AVAILABLE": return "свободен"; case "BOOKED": return "бронь"; case "BUSY": return "недоступен"; default: return state || "—"; } }