Files
2026-06-25 16:21:17 +03:00

1855 lines
98 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 || "—";
}
}