Files
dc-observatio/services/pcp-tgu-ui-service/src/features/tgu-slots/TguSlotsTab.tsx
T
Дмитрий Соловьев c64222a90d feat(tgu-ui): бронирование слотов — виртуализация, автоподбор покрытия, эргономичный мультивыбор
Вкладка «Слоты» масштабирована под ~20k слотов/мес без торможения:
- wktCache: контур слота парсится один раз (кэш по slotKey), не на каждый ререндер сцены
- виртуализация таблицы (@tanstack/react-virtual): в DOM только видимое окно строк
- автоподбор покрытия: проброс coverageStrategy (GREEDY/CONTINUOUS) в request-cover,
  результат — преднабор брони (solver не дублируется, используется бэкендный)
- мультивыбор: Shift-диапазон по отсортированному списку, «все сутки»/«видимые»,
  счётчик охвата выбора
- бронь крупных наборов чанками (по 200) + подтверждение выше 500 + прогресс
- отмена расчёта (AbortController), мягкая подсказка перегруза карты
- чистая логика выбора/чанкинга вынесена в slotSelection.ts + юнит-тесты

Тесты: 70/70 (17 новых). tsc -b и vite build чисто.
2026-06-11 17:09:58 +03:00

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