1855 lines
98 KiB
TypeScript
1855 lines
98 KiB
TypeScript
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<RequestMapItem[]>([]);
|
||
const [requestsLoading, setRequestsLoading] = useState(false);
|
||
const [requestsLoaded, setRequestsLoaded] = useState(false);
|
||
const [requestsTotal, setRequestsTotal] = useState(0);
|
||
const [requestsTruncated, setRequestsTruncated] = useState(false);
|
||
const [requestsError, setRequestsError] = useState<string>();
|
||
const [selectedRequestId, setSelectedRequestId] = useState<string>("");
|
||
|
||
// Интервал отбора заявок — какие заявки показываем на карте (аналог фильтра вкладки «Карта»).
|
||
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<SlotCoverageStrategy>("GREEDY");
|
||
|
||
// Результаты: слоты-кандидаты и уже забронированные слоты заявки (с bookedSlotId для снятия по id).
|
||
const [slots, setSlots] = useState<SlotDTO[]>([]);
|
||
const [bookedSlots, setBookedSlots] = useState<BookedSlotView[]>([]);
|
||
const [selectedKeys, setSelectedKeys] = useState<ReadonlySet<string>>(() => new Set());
|
||
// Тумблер следа покрытия на карте (D4 — локальный state, вкл. по умолчанию; глобальный
|
||
// Tgu2DMapLayersState не трогаем). Оператор может снять след, оставив только % в панели.
|
||
const [coverageOn, setCoverageOn] = useState(true);
|
||
// Тумблер подсветки конфликтов выбора (тусклый слой). Вкл. по умолчанию; выключение и гасит слой,
|
||
// и пропускает пересчёт (см. conflictKeys) — заодно дешевле на больших наборах.
|
||
const [showConflicts, setShowConflicts] = useState(true);
|
||
// Сортировка таблицы: «время» (порядок расчёта), «вклад» (жадное ранжирование по приросту
|
||
// покрытия, колонка +N%), «крен» (по модулю угла крена). Вклад доступен только при наличии
|
||
// геометрии заявки (иначе чип «вклад» заблокирован в SlotFilterBar).
|
||
const [sortMode, setSortMode] = useState<SlotSortMode>("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<ReadonlySet<SlotStatusKey>>(
|
||
() => new Set<SlotStatusKey>(["free", "booked", "occupied"])
|
||
);
|
||
const [satFilter, setSatFilter] = useState<ReadonlySet<number>>(() => new Set());
|
||
const [branchFilter, setBranchFilter] = useState<ReadonlySet<RevolutionSign>>(
|
||
() => new Set<RevolutionSign>(["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);
|
||
// Прогресс крупной чанковой операции (бронь / снятие выбранных) — общий бар <ChunkProgress>.
|
||
const [progress, setProgress] = useState<{ label: string; done: number; total: number } | null>(null);
|
||
const [pendingBook, setPendingBook] = useState<SlotBookingRequest[] | null>(null);
|
||
// «Снять всё» убирает ВСЕ брони заявки — необратимо, поэтому всегда через подтверждение.
|
||
// cancelling отделяет «снятие всего в полёте» от брони/снятия выбранных.
|
||
const [pendingCancel, setPendingCancel] = useState(false);
|
||
const [cancelling, setCancelling] = useState(false);
|
||
const [error, setError] = useState<string>();
|
||
const [notice, setNotice] = useState<string>();
|
||
|
||
// Кэш распарсенных WKT-контуров по ключу слота — парсим контур один раз, а не на каждый
|
||
// ререндер сцены (критично на тысячах слотов). Чистится при смене заявки.
|
||
const wktCacheRef = useRef(new Map<string, GeoPoint[][]>());
|
||
// Контроллер отмены текущего расчёта/автоподбора — прерывается при повторе или смене заявки.
|
||
const abortRef = useRef<AbortController | null>(null);
|
||
// Якорь жеста «полоса от первого к последнему»: ключ первого кликнутого слота. Состояние (не ref):
|
||
// по нему подсвечиваем строки той же полосы, чтобы оператор видел границу выделения.
|
||
const [anchorKey, setAnchorKey] = useState<string | null>(null);
|
||
// Последний конец Shift-диапазона в таблице: повторный Shift+клик по тому же концу снимает диапазон
|
||
// (toggle). Ref — для логики жеста, перерисовка не нужна; сбрасывается при смене якоря/набора.
|
||
const lastShiftEndRef = useRef<string | null>(null);
|
||
|
||
// Подсветка строки/контура по клику на карте и центрирование на слоте.
|
||
const [activeSlotId, setActiveSlotId] = useState<string>();
|
||
const [focusPoint, setFocusPoint] = useState<GeoPoint>();
|
||
const focusTokenRef = useRef(0);
|
||
const [focusToken, setFocusToken] = useState(0);
|
||
// Скролл-контейнер списка слотов — служит scrollElement для виртуализатора.
|
||
const listRef = useRef<HTMLDivElement>(null);
|
||
|
||
// Инспектор заявки справа над картой: объекты под кликом и раскрытый (только до выбора заявки).
|
||
const [mapHits, setMapHits] = useState<Tgu2DMapSelection[]>([]);
|
||
const [activeHitId, setActiveHitId] = useState<string>();
|
||
|
||
// Меню выбора слота при плотном наложении (несколько слотов под точкой клика).
|
||
const [slotPicks, setSlotPicks] = useState<SlotPick[]>([]);
|
||
|
||
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<string, BookedSlotView>();
|
||
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<string, SlotDTO>();
|
||
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<SlotDTO[]>(() => {
|
||
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<SlotDay[]>(() => {
|
||
const buckets = new Map<number, number>();
|
||
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<Record<SlotStatusKey, number>>(() => {
|
||
const counts: Record<SlotStatusKey, number> = { 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<number>();
|
||
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<string>(),
|
||
[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<ReadonlySet<string>>(conflictKeys);
|
||
conflictKeysRef.current = conflictKeys;
|
||
// Убирает заблокированные (конфликтные) ключи из нового набора выбора. Уже выбранные слоты в
|
||
// conflictKeys не попадают, поэтому снятие выбора и прежний выбор не затрагиваются — режутся только
|
||
// конфликтные слоты, которые жест попытался добавить. Мутирует переданный (свежий) Set.
|
||
const dropConflicts = useCallback((next: Set<string>): Set<string> => {
|
||
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<string | null>(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<string, SlotMask>();
|
||
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<GeoPoint[][] | undefined>(() => {
|
||
if (!coverageOn || !hasRequest) return undefined;
|
||
const rings: GeoPoint[][] = [];
|
||
const seen = new Set<string>();
|
||
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<string, number>();
|
||
const pct = new Map<string, number>();
|
||
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<SlotDTO[]>(() => {
|
||
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<SlotStatusKey>(["free", "booked", "occupied"]));
|
||
setBranchFilter(new Set<RevolutionSign>(["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<string, number>(
|
||
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 = <T,>(set: ReadonlySet<T>, value: T): Set<T> => {
|
||
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<SlotStatusKey>(["free", "booked", "occupied"]));
|
||
setBranchFilter(new Set<RevolutionSign>(["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<Tgu2DMapScene>(() => {
|
||
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<Tgu2DMapLayersState>(
|
||
() => ({
|
||
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<string>();
|
||
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 (
|
||
<main className="tgu-map-workspace">
|
||
<div className="tgu-map-toolbar">
|
||
<div>
|
||
<div className="tgu-map-toolbar__title">Слоты</div>
|
||
<div className="tgu-map-toolbar__subtitle">Расчёт покрытия и бронирование слотов заявки</div>
|
||
</div>
|
||
<div className="tgu-field tgu-slots-request">
|
||
<span>Заявка</span>
|
||
<div className="tgu-slots-request__name" title={selectedRequest?.name}>
|
||
{selectedRequest ? selectedRequest.name : "—"}
|
||
</div>
|
||
</div>
|
||
<label className="tgu-field">
|
||
<span>С</span>
|
||
<DateTime24Field value={fromValue} onChange={setFromValue} />
|
||
</label>
|
||
<label className="tgu-field">
|
||
<span>По</span>
|
||
<DateTime24Field value={toValue} onChange={setToValue} />
|
||
</label>
|
||
<label className="tgu-field">
|
||
<span>Ветвь</span>
|
||
<select
|
||
className="tgu-select"
|
||
value={revSign}
|
||
onChange={(event) => setRevSign(event.target.value as "" | RevolutionSign)}
|
||
>
|
||
<option value="">обе</option>
|
||
<option value="ASC">восходящая</option>
|
||
<option value="DESC">нисходящая</option>
|
||
</select>
|
||
</label>
|
||
<button className="tgu-button tgu-button--primary" type="button" onClick={calculate} disabled={!canCalculate} title="Показать весь набор слотов-кандидатов для ручного отбора">
|
||
{calculating ? "Расчёт…" : "Показать все слоты"}
|
||
</button>
|
||
<label className="tgu-field" title="Стратегия отбора покрытия для «Подобрать покрытие»">
|
||
<span>Стратегия покрытия</span>
|
||
<select
|
||
className="tgu-select"
|
||
value={strategy}
|
||
onChange={(event) => setStrategy(event.target.value as SlotCoverageStrategy)}
|
||
disabled={busy}
|
||
>
|
||
<option value="GREEDY">жадный</option>
|
||
<option value="CONTINUOUS">непрерывный</option>
|
||
</select>
|
||
</label>
|
||
<button className="tgu-button" type="button" onClick={autoCover} disabled={!canCalculate} title="Отобрать достаточное покрытие по стратегии и предложить под бронь">
|
||
{autoFilling ? "Подбор…" : "Подобрать покрытие"}
|
||
</button>
|
||
<button
|
||
className="tgu-button"
|
||
type="button"
|
||
onClick={book}
|
||
disabled={opBusy || selectedBookableCount === 0}
|
||
title="Забронировать выбранные слоты-кандидаты"
|
||
>
|
||
{booking
|
||
? "Бронь…"
|
||
: `Забронировать${selectedBookableCount > 0 ? ` (${selectedBookableCount})` : ""}`}
|
||
</button>
|
||
<button
|
||
className="tgu-button"
|
||
type="button"
|
||
onClick={cancelSelected}
|
||
disabled={opBusy || selectedBookedIds.length === 0}
|
||
title="Снять бронь с выбранных слотов (пачками)"
|
||
>
|
||
{cancellingSel
|
||
? "Снятие…"
|
||
: `Снять выбранные${selectedBookedIds.length > 0 ? ` (${selectedBookedIds.length})` : ""}`}
|
||
</button>
|
||
<button
|
||
className="tgu-button"
|
||
type="button"
|
||
onClick={cancelAll}
|
||
disabled={opBusy || bookedSlots.length === 0}
|
||
title="Снять все брони заявки"
|
||
>
|
||
{cancelling ? "Снятие…" : `Снять всё${bookedSlots.length > 0 ? ` (${bookedSlots.length})` : ""}`}
|
||
</button>
|
||
<div className="tgu-map-toolbar__spacer" style={{ flex: 1 }} />
|
||
{(slots.length > 0 || bookedSlots.length > 0) && (
|
||
<div className="tgu-map-toolbar__subtitle">
|
||
слотов {slots.length} · забронировано {bookedSlots.length}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="tgu-slots-body">
|
||
<section className="tgu-slots-list">
|
||
{hasRequest ? (
|
||
<>
|
||
{displaySlots.length > 0 && (
|
||
<SlotFilterBar
|
||
statusCounts={statusCounts}
|
||
statusFilter={statusFilter}
|
||
onToggleStatus={toggleStatus}
|
||
satellites={satellites}
|
||
satFilter={satFilter}
|
||
onToggleSat={toggleSat}
|
||
branchFilter={branchFilter}
|
||
onToggleBranch={toggleBranch}
|
||
sortMode={sortMode}
|
||
onSortMode={changeSort}
|
||
gainAvailable={coverageAvailable}
|
||
isDefault={filtersDefault}
|
||
onReset={resetFilters}
|
||
/>
|
||
)}
|
||
{displaySlots.length > 0 && (
|
||
<CoveragePanel
|
||
booked={coverageStats.booked}
|
||
withSelection={coverageStats.withSelection}
|
||
hasSelection={coverageStats.hasSelection}
|
||
available={coverageGrid !== null}
|
||
coverageOn={coverageOn}
|
||
onToggleCoverage={setCoverageOn}
|
||
showConflicts={showConflicts}
|
||
onToggleConflicts={setShowConflicts}
|
||
/>
|
||
)}
|
||
<div className="tgu-slots-selbar">
|
||
<span className="tgu-slots-selbar__count">
|
||
Выбрано: {selectedKeys.size}
|
||
{otherDaysSelectedCount > 0 && (
|
||
<span className="tgu-slots-selbar__sub">
|
||
{" "}
|
||
(видно {visibleSelectedCount}, на др. сутки {otherDaysSelectedCount})
|
||
</span>
|
||
)}
|
||
</span>
|
||
<span className="tgu-slots-selbar__sub" title="Таблица: кликните первый слот, затем Shift+клик по последнему — выделится полоса между ними. На карте: Shift+зажать на слоте и протянуть мышью вдоль полосы. Выйти за полосу (другой КА/крен/разрыв по времени) нельзя.">
|
||
Полоса: клик+Shift в таблице · Shift+тянуть на карте
|
||
</span>
|
||
<div className="tgu-slots-selbar__actions">
|
||
<button
|
||
className="tgu-button tgu-button--sm"
|
||
type="button"
|
||
onClick={selectAllDays}
|
||
disabled={displaySlots.length === 0}
|
||
title="Выбрать все строки (кандидаты и брони, по всем суткам)"
|
||
>
|
||
Все сутки
|
||
</button>
|
||
<button
|
||
className="tgu-button tgu-button--sm"
|
||
type="button"
|
||
onClick={clearSelection}
|
||
disabled={selectedKeys.size === 0}
|
||
>
|
||
Снять выбор
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div className="tgu-slots-scroll" ref={listRef}>
|
||
{filteredSlots.length === 0 ? (
|
||
<div className="tgu-slots-empty">
|
||
{displaySlots.length === 0 ? (
|
||
"Слоты не рассчитаны."
|
||
) : dayWindowed.length === 0 ? (
|
||
"На выбранные сутки слотов нет."
|
||
) : (
|
||
<>
|
||
Слоты скрыты фильтром — включите статус/КА или сбросьте фильтры.
|
||
<div className="tgu-slots-empty__action">
|
||
<button type="button" className="tgu-button tgu-button--sm" onClick={resetFilters}>
|
||
Сбросить фильтры
|
||
</button>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<table className="tgu-slots-table">
|
||
<thead>
|
||
<tr>
|
||
<th>
|
||
<input
|
||
type="checkbox"
|
||
checked={allVisibleSelected}
|
||
onChange={toggleAllVisible}
|
||
aria-label="Выбрать видимые сутки"
|
||
/>
|
||
</th>
|
||
<th>КА</th>
|
||
<th>Виток</th>
|
||
<th>Начало</th>
|
||
<th>Длит.</th>
|
||
<th>Крен</th>
|
||
{showGain && (
|
||
<th
|
||
className="tgu-slots-gain-col"
|
||
title="Прирост покрытия в порядке ранжирования — фиксируется на момент ранжирования, не пересчитывается при выборе"
|
||
>
|
||
Вклад
|
||
</th>
|
||
)}
|
||
<th>Статус</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{padTop > 0 && (
|
||
<tr aria-hidden="true" style={{ height: padTop }}>
|
||
<td colSpan={colCount} />
|
||
</tr>
|
||
)}
|
||
{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 (
|
||
<tr
|
||
key={key}
|
||
data-slot-id={id}
|
||
style={{
|
||
height: ROW_HEIGHT,
|
||
...(inAnchorPolosa && polosaId
|
||
? { ["--strip-hue" as string]: String(stripHue(polosaId)) }
|
||
: {}),
|
||
}}
|
||
className={rowClass || undefined}
|
||
onClick={(event) => {
|
||
// Клик по строке = и подсветка на карте, и жест выбора полосы:
|
||
// обычный клик выбирает слот + ставит якорь, Shift+клик тянет полосу
|
||
// до этой строки. BUSY не выбираем (бронировать нельзя).
|
||
selectRow(slot);
|
||
if (!slotIsBusy(slot)) handleSlotCheck(vrow.index, event.shiftKey);
|
||
}}
|
||
>
|
||
<td onClick={(event) => event.stopPropagation()}>
|
||
<input
|
||
type="checkbox"
|
||
checked={selectedKeys.has(key)}
|
||
onChange={() => {}}
|
||
onClick={(event) => handleSlotCheck(vrow.index, event.shiftKey)}
|
||
disabled={slotIsBusy(slot)}
|
||
title={slotIsBusy(slot) ? "Слот недоступен — конфликт с бронью, бронировать нельзя" : undefined}
|
||
aria-label="Выбрать слот"
|
||
/>
|
||
</td>
|
||
<td>{slot.satelliteId}</td>
|
||
<td>
|
||
{slot.revolution} / {slot.revolutionSign === "DESC" ? "нисх" : "восх"} /{" "}
|
||
{slot.cycle}
|
||
</td>
|
||
<td>{formatLocalDateTimeSeconds(parseLocal(slot.tn))}</td>
|
||
<td>{slotDurationSec(slot)} с</td>
|
||
<td>{slot.roll.toFixed(1)}°</td>
|
||
{showGain && (
|
||
<td className="tgu-slots-gain">
|
||
{booked ? "—" : `+${gainRanking?.pct.get(key) ?? 0}%`}
|
||
</td>
|
||
)}
|
||
<td>
|
||
{booked ? (
|
||
<span className="tgu-slots-status-cell">
|
||
<span className="tgu-slots-badge tgu-slots-badge--booked">бронь</span>
|
||
<button
|
||
type="button"
|
||
className="tgu-slots-cancel-one"
|
||
title="Снять бронь с этого слота"
|
||
aria-label="Снять бронь с этого слота"
|
||
disabled={opBusy}
|
||
onClick={(event) => {
|
||
event.stopPropagation();
|
||
const id = bookedByKey.get(key)?.bookedSlotId;
|
||
if (id != null) cancelOne(id);
|
||
}}
|
||
>
|
||
✕
|
||
</button>
|
||
</span>
|
||
) : (
|
||
<span className="tgu-slots-status-cell">
|
||
<span
|
||
className={
|
||
slot.state === "BUSY"
|
||
? "tgu-slots-badge tgu-slots-badge--occupied"
|
||
: "tgu-slots-badge"
|
||
}
|
||
>
|
||
{slotStateLabel(slot.state)}
|
||
</span>
|
||
{slot.state === "BUSY" &&
|
||
slot.holders?.map((holder) => (
|
||
<span
|
||
key={holder.requestId}
|
||
className="tgu-slots-holder"
|
||
title={`Слот держит заявка ${holder.name ?? holder.requestId}${
|
||
holder.importance != null
|
||
? ` · важность ${holder.importance.toFixed(2)}`
|
||
: ""
|
||
}`}
|
||
>
|
||
{holder.name ?? holder.requestId}
|
||
{holder.importance != null && (
|
||
<span className="tgu-slots-holder__imp">
|
||
{holder.importance.toFixed(2)}
|
||
</span>
|
||
)}
|
||
</span>
|
||
))}
|
||
</span>
|
||
)}
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
{padBottom > 0 && (
|
||
<tr aria-hidden="true" style={{ height: padBottom }}>
|
||
<td colSpan={colCount} />
|
||
</tr>
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
)}
|
||
</div>
|
||
</>
|
||
) : (
|
||
<div className="tgu-slots-requests-panel">
|
||
<div className="tgu-map-panel__section">
|
||
<div className="tgu-map-panel__label">Заявки на интервал</div>
|
||
<div className="tgu-map-requests-filter">
|
||
<label className="tgu-field">
|
||
<span>С</span>
|
||
<DateTime24Field value={reqFromValue} onChange={setReqFromValue} />
|
||
</label>
|
||
<label className="tgu-field">
|
||
<span>По</span>
|
||
<DateTime24Field value={reqToValue} onChange={setReqToValue} />
|
||
</label>
|
||
<div className="tgu-map-requests-filter__actions">
|
||
<button
|
||
className="tgu-button tgu-button--primary tgu-button--sm"
|
||
type="button"
|
||
onClick={applyRequests}
|
||
disabled={requestsLoading || reqRangeInvalid}
|
||
>
|
||
{requestsLoading ? "Загрузка..." : "Загрузить"}
|
||
</button>
|
||
<button className="tgu-button tgu-button--sm" type="button" onClick={quickToday} disabled={requestsLoading}>
|
||
Сегодня
|
||
</button>
|
||
<button className="tgu-button tgu-button--sm" type="button" onClick={quick7days} disabled={requestsLoading}>
|
||
7 дней
|
||
</button>
|
||
<button className="tgu-button tgu-button--sm" type="button" onClick={quick30days} disabled={requestsLoading}>
|
||
30 дней
|
||
</button>
|
||
</div>
|
||
{requestsError && (
|
||
<div className="tgu-alert tgu-alert--danger tgu-alert--compact">{requestsError}</div>
|
||
)}
|
||
{requestsLoaded && !requestsError && (
|
||
<div className="tgu-map-requests-filter__status">
|
||
{requestItems.length === 0
|
||
? "Заявок не найдено."
|
||
: `Загружено: ${requestItems.length} из ${requestsTotal}`}
|
||
{requestsTruncated && (
|
||
<span className="tgu-map-requests-filter__truncated"> (показаны первые 5000 — уточните интервал)</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
{requestItems.length > 0 && (
|
||
<div className="tgu-map-requests-legend">
|
||
<span><i className="req-optics" />OPTICS</span>
|
||
<span><i className="req-rsa" />RSA</span>
|
||
<span><i className="req-combined" />COMBINED</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<div className="tgu-slots-empty">Кликните заявку на карте, чтобы выбрать.</div>
|
||
</div>
|
||
)}
|
||
</section>
|
||
|
||
<section className="tgu-map-stage">
|
||
{overlayMessage && (
|
||
<div className="tgu-map-overlay">
|
||
<span>{overlayMessage}</span>
|
||
{busy && (
|
||
<button type="button" className="tgu-button tgu-button--sm" onClick={cancelCalculation}>
|
||
Отмена
|
||
</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
{pendingBook && (
|
||
<div className="tgu-slots-confirm">
|
||
<span>Забронировать выбранные слоты ({pendingBook.length})?</span>
|
||
<div className="tgu-slots-confirm__actions">
|
||
<button className="tgu-button tgu-button--primary tgu-button--sm" type="button" onClick={confirmPendingBook}>
|
||
Забронировать
|
||
</button>
|
||
<button className="tgu-button tgu-button--sm" type="button" onClick={cancelPendingBook}>
|
||
Отмена
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{pendingCancel && (
|
||
<div className="tgu-slots-confirm">
|
||
<span>Снять все брони заявки ({bookedSlots.length})? Действие необратимо.</span>
|
||
<div className="tgu-slots-confirm__actions">
|
||
<button className="tgu-button tgu-button--primary tgu-button--sm" type="button" onClick={confirmPendingCancel}>
|
||
Снять всё
|
||
</button>
|
||
<button className="tgu-button tgu-button--sm" type="button" onClick={cancelPendingCancel}>
|
||
Отмена
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{progress && (
|
||
<ChunkProgress label={progress.label} done={progress.done} total={progress.total} />
|
||
)}
|
||
{hasRequest && (
|
||
<button type="button" className="tgu-button tgu-slots-reset" onClick={resetSelection}>
|
||
Сброс
|
||
</button>
|
||
)}
|
||
{hasRequest && days.length > 0 && (
|
||
<div className="tgu-slots-map-controls">
|
||
<SlotDaySelector
|
||
days={days}
|
||
fromIndex={effDayFrom}
|
||
toIndex={effDayTo}
|
||
onFromChange={setDayFrom}
|
||
onToChange={setDayTo}
|
||
/>
|
||
{passes.length > 0 && (
|
||
<PassStepper
|
||
index={effPassCursor}
|
||
total={passes.length}
|
||
label={passLabel}
|
||
canPrev={effPassCursor !== 0}
|
||
canNext={effPassCursor !== passes.length - 1}
|
||
onPrev={prevPass}
|
||
onNext={nextPass}
|
||
onClear={clearPass}
|
||
/>
|
||
)}
|
||
</div>
|
||
)}
|
||
<Tgu2DMapView
|
||
scene={scene}
|
||
layers={layers}
|
||
redrawToken={
|
||
hasRequest
|
||
? filteredSlots.length + bookedSlots.length + selectedKeys.size + (coverageOn ? 1 : 0)
|
||
: requestItems.length
|
||
}
|
||
enableObjectChooser
|
||
onObjectsAtPoint={handleObjectsAtPoint}
|
||
onObjectSelect={handleObjectSelect}
|
||
onStripAnchor={hasRequest ? handleStripAnchor : undefined}
|
||
onStripDrag={hasRequest ? handleStripDrag : undefined}
|
||
onStripCommit={hasRequest ? handleStripCommit : undefined}
|
||
selectedObjectId={hasRequest ? activeSlotId : activeHitId}
|
||
markedObjectIds={hasRequest ? markedSlotIds : undefined}
|
||
dimmedObjectIds={hasRequest ? dimmedSlotIds : undefined}
|
||
focusPoint={focusPoint}
|
||
focusToken={focusToken}
|
||
/>
|
||
{!hasRequest && mapHits.length > 0 && (
|
||
<aside className="tgu-slots-inspector">
|
||
<MapObjectInspector
|
||
hits={mapHits}
|
||
activeId={activeHitId}
|
||
onToggle={toggleHit}
|
||
onClose={closeInspector}
|
||
requestItems={requestItems}
|
||
onSelectRequest={chooseRequest}
|
||
/>
|
||
</aside>
|
||
)}
|
||
{hasRequest && slotPicks.length > 0 && (
|
||
<SlotPickMenu picks={slotPicks} activeId={activeSlotId} onPick={pickSlot} onClose={closeSlotPick} />
|
||
)}
|
||
</section>
|
||
</div>
|
||
|
||
<div className="tgu-map-errors">
|
||
{notice && <div className="tgu-alert">{notice}</div>}
|
||
{error && <div className="tgu-alert tgu-alert--danger">{error}</div>}
|
||
</div>
|
||
</main>
|
||
);
|
||
}
|
||
|
||
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<SlotDTO, "state">): 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 || "—";
|
||
}
|
||
}
|