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 чисто.
This commit is contained in:
Дмитрий Соловьев
2026-06-11 17:09:58 +03:00
parent 345529cde0
commit c64222a90d
8 changed files with 641 additions and 122 deletions
@@ -0,0 +1,63 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { bookRequestSlots, fetchRequestSlots } from "./slotsApi";
function jsonResponse(body: unknown): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
const baseParams = { requestId: "r1", timeStart: "2026-01-01T00:00:00", timeStop: "2026-01-02T00:00:00" };
afterEach(() => vi.restoreAllMocks());
describe("fetchRequestSlots", () => {
it("прокидывает coverageStrategy в query", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse([]));
await fetchRequestSlots({ ...baseParams, coverageStrategy: "CONTINUOUS" });
const url = String(fetchMock.mock.calls[0][0]);
expect(url).toContain("request-cover");
expect(url).toContain("coverageStrategy=CONTINUOUS");
});
it("не добавляет coverageStrategy, если не задана", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse([]));
await fetchRequestSlots(baseParams);
expect(String(fetchMock.mock.calls[0][0])).not.toContain("coverageStrategy");
});
it("пробрасывает AbortSignal в fetch", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse([]));
const ctrl = new AbortController();
await fetchRequestSlots(baseParams, ctrl.signal);
expect(fetchMock.mock.calls[0][1]?.signal).toBe(ctrl.signal);
});
it("отменённый запрос пробрасывает ошибку отмены", async () => {
vi.spyOn(globalThis, "fetch").mockRejectedValue(new DOMException("aborted", "AbortError"));
await expect(fetchRequestSlots(baseParams)).rejects.toThrow();
});
it("на не-ok бросает с текстом ошибки сервера", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("boom", { status: 500 }));
await expect(fetchRequestSlots(baseParams)).rejects.toThrow("boom");
});
});
describe("bookRequestSlots", () => {
it("POST с телом requestId+slots и возвращает забронированные", async () => {
const fetchMock = vi
.spyOn(globalThis, "fetch")
.mockResolvedValue(jsonResponse([{ bookedSlotId: 1, satelliteId: 1, slotNum: 2, cycle: 0 }]));
const res = await bookRequestSlots("r1", [{ satelliteId: 1, slotNum: 2, cycle: 0 }]);
const [url, init] = fetchMock.mock.calls[0];
expect(String(url)).toContain("booking/request");
expect(init?.method).toBe("POST");
expect(JSON.parse(String(init?.body))).toEqual({
requestId: "r1",
slots: [{ satelliteId: 1, slotNum: 2, cycle: 0 }],
});
expect(res).toHaveLength(1);
});
});
@@ -26,11 +26,17 @@ async function readErrorMessage(response: Response, fallback: string): Promise<s
/**
* Рассчитывает слоты покрытия заявки (slots-service: GET /api/slots/request-cover).
* @param params Заявка, интервал и фильтры (ветвь, признак покрытия, КА, угол Солнца).
* @param params Заявка, интервал и фильтры (ветвь, признак покрытия, КА, угол Солнца,
* стратегия автоформирования покрытия).
* @param signal Сигнал отмены (AbortController) — прерывает долгий расчёт.
*
* @returns Список слотов-кандидатов с контурами для карты.
* @returns Список слотов-кандидатов с контурами для карты. При заданной coverageStrategy —
* уже отобранный достаточный набор покрытия (преднабор для брони).
*/
export async function fetchRequestSlots(params: RequestSlotsParams): Promise<SlotDTO[]> {
export async function fetchRequestSlots(
params: RequestSlotsParams,
signal?: AbortSignal,
): Promise<SlotDTO[]> {
const query = new URLSearchParams()
query.set('requestId', params.requestId)
query.set('timeStart', params.timeStart)
@@ -38,10 +44,12 @@ export async function fetchRequestSlots(params: RequestSlotsParams): Promise<Slo
if (params.revSign) query.set('revSign', params.revSign)
if (params.cov != null) query.set('cov', String(params.cov))
if (params.sun != null) query.set('sun', String(params.sun))
if (params.coverageStrategy) query.set('coverageStrategy', params.coverageStrategy)
params.satellites?.forEach((id) => query.append('satellites', String(id)))
const response = await apiFetch(`${BASE}/request-cover?${query}`, {
headers: { Accept: 'application/json' },
signal,
})
if (!response.ok) {
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import { fetchRequestsForMap } from "../../api/requestApi";
import {
bookRequestSlots,
@@ -7,7 +8,13 @@ import {
fetchRequestSlots
} from "../../api/slotsApi";
import type { RequestMapItem } from "../../model/requestTypes";
import type { RevolutionSign, SlotDTO } from "../../model/slotTypes";
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";
@@ -19,9 +26,19 @@ 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);
@@ -64,6 +81,8 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
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[]>([]);
@@ -75,17 +94,29 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
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);
// Контейнер списка слотов — по нему ищем активную строку (data-slot-id) и скроллим её в зону видимости.
const listRef = useRef<HTMLElement>(null);
// Скролл-контейнер списка слотов — служит scrollElement для виртуализатора.
const listRef = useRef<HTMLDivElement>(null);
// Инспектор заявки справа над картой: объекты под кликом и раскрытый (только до выбора заявки).
const [mapHits, setMapHits] = useState<Tgu2DMapSelection[]>([]);
@@ -161,6 +192,15 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
});
}, [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);
@@ -194,16 +234,11 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
// Скроллим контейнер вручную (по rect-ам), а не через scrollIntoView: тот может выбрать другой
// скролл-предок или счесть строку «достаточно близкой» и не сдвинуться.
useEffect(() => {
if (!activeSlotId) return;
const container = listRef.current;
const row = container?.querySelector<HTMLElement>(`[data-slot-id="${activeSlotId}"]`);
if (!container || !row) return;
const cRect = container.getBoundingClientRect();
const rRect = row.getBoundingClientRect();
if (rRect.top >= cRect.top && rRect.bottom <= cRect.bottom) return; // строка уже целиком видна
const delta = rRect.top + rRect.height / 2 - (cRect.top + container.clientHeight / 2);
container.scrollBy({ top: delta, behavior: "smooth" });
}, [activeSlotId]);
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;
@@ -212,6 +247,17 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
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 {
@@ -223,11 +269,16 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
}
}, []);
// Смена заявки — сбрасываем результаты и подтягиваем уже существующие брони.
// Смена заявки — отменяем текущий расчёт, чистим кэш контуров и результаты,
// подтягиваем уже существующие брони.
useEffect(() => {
abortRef.current?.abort();
wktCacheRef.current.clear();
anchorIndexRef.current = null;
setSlots([]);
setSelectedKeys(new Set());
setBookedSlots([]);
setPendingBook(null);
setNotice(undefined);
setError(undefined);
setActiveSlotId(undefined);
@@ -255,54 +306,92 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
const quick7days = () => setQuickRange(Date.now(), Date.now() + 7 * DAY_MS);
const quick30days = () => setQuickRange(Date.now(), Date.now() + 30 * DAY_MS);
const calculate = async () => {
if (!selectedRequestId || rangeInvalid || calculating) return;
setCalculating(true);
// Общий расчёт слотов. Без стратегии — все слоты-кандидаты («Рассчитать»). Со стратегией —
// автоформирование покрытия: бэк отдаёт достаточный набор, кладём его в преднабор брони.
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
});
setSlots(result);
setSelectedKeys(new Set());
setNotice(
result.length > 0
? `Найдено слотов: ${result.length}.`
: "Для выбранного интервала слотов покрытия не найдено."
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 {
setCalculating(false);
if (abortRef.current === ctrl) abortRef.current = null;
if (auto) setAutoFilling(false);
else setCalculating(false);
}
};
const book = async () => {
if (!selectedRequestId || booking || selectedKeys.size === 0) return;
const toBook = slots
.filter((slot) => selectedKeys.has(slotKey(slot)))
.map((slot) => ({ satelliteId: slot.satelliteId, slotNum: slot.slotNumber, cycle: slot.cycle }));
if (toBook.length === 0) return;
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 booked = await bookRequestSlots(selectedRequestId, toBook);
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(booked.map((item) => `${item.satelliteId}:${item.slotNum}:${item.cycle}`));
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]));
@@ -310,14 +399,39 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
return Array.from(merged.values());
});
setSelectedKeys(new Set());
setNotice(`Забронировано слотов: ${booked.length}.`);
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);
@@ -335,21 +449,40 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
}
};
const toggleSlot = (key: string) => {
setSelectedKeys((current) => {
const next = new Set(current);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
// Отметка слота из таблицы. Shift+клик — выбрать диапазон от якоря (предыдущего клика) до
// текущей строки; направление (выбрать/снять) задаётся новым состоянием текущей строки.
const handleSlotCheck = (index: number, shiftKey: boolean) => {
setSelectedKeys((current) =>
selectionAfterCheck(current, (i) => slotKey(filteredSlots[i]), index, shiftKey, anchorIndexRef.current)
);
anchorIndexRef.current = index;
};
// «Выбрать все» работает по видимым (отфильтрованным) слотам.
const allSelected =
// Чекбокс шапки — выбрать/снять все ВИДИМЫЕ (по окну суток) слоты.
const allVisibleSelected =
filteredSlots.length > 0 && filteredSlots.every((slot) => selectedKeys.has(slotKey(slot)));
const toggleAll = () => {
setSelectedKeys(allSelected ? new Set() : new Set(filteredSlots.map(slotKey)));
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)}`;
@@ -416,7 +549,7 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
}
for (const slot of filteredSlots) {
const key = slotKey(slot);
for (const ring of parseWktPolygon(slot.contour)) {
for (const ring of ringsFor(key, slot.contour)) {
if (ring.length < 3) continue;
polygons.push({
id: `slot:${key}`,
@@ -435,7 +568,7 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
for (const slot of bookedSlots) {
const key = slotKey(slot);
if (slotKeysShown.has(key)) continue;
for (const ring of parseWktPolygon(slot.contour)) {
for (const ring of ringsFor(key, slot.contour)) {
if (ring.length < 3) continue;
polygons.push({
id: `booked:${key}`,
@@ -450,7 +583,7 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
}
}
return { polygons, lines: [], markers: [] };
}, [hasRequest, requestItems, selectedRequest, slots, filteredSlots, bookedSlots, bookedKeys]);
}, [hasRequest, requestItems, selectedRequest, slots, filteredSlots, bookedSlots, bookedKeys, ringsFor]);
const layers = useMemo<Tgu2DMapLayersState>(
() => ({
@@ -535,10 +668,13 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
setActiveHitId(undefined);
}, []);
const canCalculate = hasRequest && !rangeInvalid && !calculating;
const busy = calculating || autoFilling;
const canCalculate = hasRequest && !rangeInvalid && !busy;
const overlayMessage = calculating
? "Расчёт слотов…"
: !hasRequest
: autoFilling
? "Автоподбор покрытия…"
: !hasRequest
? requestsLoading
? "Загрузка заявок…"
: undefined
@@ -546,6 +682,14 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
? "Слоты не рассчитаны — нажмите «Рассчитать слоты»."
: 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">
@@ -590,13 +734,32 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
<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}
>
Забронировать{selectedKeys.size > 0 ? ` (${selectedKeys.size})` : ""}
{booking
? bookProgress
? `Бронь… ${bookProgress}`
: "Бронь…"
: `Забронировать${selectedKeys.size > 0 ? ` (${selectedKeys.size})` : ""}`}
</button>
<button
className="tgu-button"
@@ -615,72 +778,124 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
</div>
<div className="tgu-slots-body">
<section className="tgu-slots-list" ref={listRef}>
<section className="tgu-slots-list">
{hasRequest ? (
filteredSlots.length === 0 ? (
<div className="tgu-slots-empty">
{slots.length > 0 ? "На выбранные сутки слотов нет." : "Слоты не рассчитаны."}
<>
<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>
) : (
<table className="tgu-slots-table">
<thead>
<tr>
<th>
<input type="checkbox" checked={allSelected} onChange={toggleAll} aria-label="Выбрать все" />
</th>
<th>КА</th>
<th>Виток</th>
<th>Слот / цикл</th>
<th>Начало</th>
<th>Конец</th>
<th>Крен</th>
<th>Статус</th>
</tr>
</thead>
<tbody>
{filteredSlots.map((slot) => {
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}
className={isActive ? "is-active" : undefined}
onClick={() => selectRow(slot)}
>
<td onClick={(event) => event.stopPropagation()}>
<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={selectedKeys.has(key)}
onChange={() => toggleSlot(key)}
aria-label="Выбрать слот"
checked={allVisibleSelected}
onChange={toggleAllVisible}
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>
</th>
<th>КА</th>
<th>Виток</th>
<th>Слот / цикл</th>
<th>Начало</th>
<th>Конец</th>
<th>Крен</th>
<th>Статус</th>
</tr>
);
})}
</tbody>
</table>
)
</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">
@@ -741,12 +956,39 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
</section>
<section className="tgu-map-stage">
{overlayMessage && <div className="tgu-map-overlay">{overlayMessage}</div>}
{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}
@@ -794,6 +1036,10 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
);
}
function strategyLabel(strategy: SlotCoverageStrategy): string {
return strategy === "CONTINUOUS" ? "непрерывный" : "жадный";
}
function slotStateLabel(state: string): string {
switch (state) {
case "AVAILABLE":
@@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import { chunk, selectionAfterCheck } from "./slotSelection";
const keyAt = (i: number) => `k${i}`;
describe("selectionAfterCheck", () => {
it("добавляет ключ при обычном клике по невыбранной строке", () => {
const next = selectionAfterCheck(new Set(), keyAt, 2, false, null);
expect([...next]).toEqual(["k2"]);
});
it("снимает ключ при обычном клике по выбранной строке", () => {
const next = selectionAfterCheck(new Set(["k2"]), keyAt, 2, false, 5);
expect(next.has("k2")).toBe(false);
});
it("Shift выбирает диапазон от якоря до индекса (целевая была не выбрана)", () => {
const next = selectionAfterCheck(new Set(), keyAt, 4, true, 1);
expect([...next].sort()).toEqual(["k1", "k2", "k3", "k4"]);
});
it("Shift снимает диапазон, если целевая строка уже выбрана", () => {
const current = new Set(["k1", "k2", "k3", "k4", "k9"]);
const next = selectionAfterCheck(current, keyAt, 4, true, 1);
expect([...next]).toEqual(["k9"]);
});
it("Shift работает при обратном порядке (якорь больше индекса)", () => {
const next = selectionAfterCheck(new Set(), keyAt, 1, true, 4);
expect([...next].sort()).toEqual(["k1", "k2", "k3", "k4"]);
});
it("Shift без якоря ведёт себя как обычное переключение", () => {
const next = selectionAfterCheck(new Set(), keyAt, 3, true, null);
expect([...next]).toEqual(["k3"]);
});
it("не мутирует исходное множество", () => {
const current = new Set(["k2"]);
selectionAfterCheck(current, keyAt, 5, false, null);
expect([...current]).toEqual(["k2"]);
});
});
describe("chunk", () => {
it("бьёт массив на части заданного размера, последняя — остаток", () => {
expect(chunk([1, 2, 3, 4, 5], 2)).toEqual([[1, 2], [3, 4], [5]]);
});
it("размер >= длины — один чанк", () => {
expect(chunk([1, 2], 10)).toEqual([[1, 2]]);
});
it("пустой массив — нет чанков", () => {
expect(chunk([], 3)).toEqual([]);
});
it("size<=0 — единственный чанк со всем массивом", () => {
expect(chunk([1, 2, 3], 0)).toEqual([[1, 2, 3]]);
});
});
@@ -0,0 +1,52 @@
// Чистая логика выбора слотов и разбиения на чанки — выделена из TguSlotsTab,
// чтобы покрыть юнит-тестами без рендера компонента.
/**
* Новое множество выбора после клика по строке.
* Обычный клик — переключает один ключ. Shift+клик при заданном якоре — выбирает (или
* снимает) непрерывный диапазон от якоря до текущего индекса; направление задаётся новым
* состоянием текущей строки (если она была не выбрана — диапазон выбираем, иначе снимаем).
*
* @param current Текущее множество выбранных ключей.
* @param keyAt Доступ к ключу слота по индексу в видимом (отфильтрованном) списке.
* @param index Индекс строки, по которой кликнули.
* @param shiftKey Был ли зажат Shift.
* @param anchorIndex Индекс предыдущего клика (якорь) либо null.
*/
export function selectionAfterCheck(
current: ReadonlySet<string>,
keyAt: (index: number) => string,
index: number,
shiftKey: boolean,
anchorIndex: number | null,
): Set<string> {
const next = new Set(current)
const targetKey = keyAt(index)
if (shiftKey && anchorIndex != null) {
const lo = Math.min(anchorIndex, index)
const hi = Math.max(anchorIndex, index)
const select = !current.has(targetKey)
for (let i = lo; i <= hi; i++) {
const k = keyAt(i)
if (select) next.add(k)
else next.delete(k)
}
} else if (next.has(targetKey)) {
next.delete(targetKey)
} else {
next.add(targetKey)
}
return next
}
/**
* Разбивает массив на чанки заданного размера (для чанкованной брони крупных наборов).
* @param items Исходный массив.
* @param size Размер чанка; <= 0 — один чанк со всем массивом.
*/
export function chunk<T>(items: readonly T[], size: number): T[][] {
if (size <= 0) return items.length ? [items.slice()] : []
const out: T[][] = []
for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size))
return out
}
@@ -4,6 +4,12 @@
/** Признак ветви витка (восходящая/нисходящая). */
export type RevolutionSign = "ASC" | "DESC";
/**
* Стратегия автоформирования покрытия (slots-service: SlotCoverageStrategy).
* GREEDY — жадный подбор; CONTINUOUS — непрерывное покрытие. Не задано — все слоты-кандидаты.
*/
export type SlotCoverageStrategy = "GREEDY" | "CONTINUOUS";
/** Статус слота из расчёта покрытия. */
export type SlotStatus = "AVAILABLE" | "BOOKED" | "OCCUPIED" | string;
@@ -52,4 +58,6 @@ export type RequestSlotsParams = {
satellites?: number[];
/** Угол Солнца, град (для оптики); не задано — без ограничения. */
sun?: number;
/** Стратегия автоформирования покрытия; не задано — все слоты-кандидаты. */
coverageStrategy?: SlotCoverageStrategy;
};
@@ -925,11 +925,44 @@
.tgu-slots-list {
min-width: 0;
min-height: 0;
overflow: auto;
display: flex;
flex-direction: column;
border-right: 1px solid var(--line);
background: var(--bg-panel);
}
/* Прокручиваемая область таблицы слотов — scrollElement виртуализатора. */
.tgu-slots-scroll {
min-height: 0;
flex: 1;
overflow: auto;
}
/* Панель выбора над таблицей: счётчик охвата + действия (все сутки / снять). */
.tgu-slots-selbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.4rem 0.55rem;
border-bottom: 1px solid var(--line);
background: var(--bg-panel);
font-size: 0.78rem;
}
.tgu-slots-selbar__count {
color: var(--text);
}
.tgu-slots-selbar__sub {
color: var(--text-dim);
}
.tgu-slots-selbar__actions {
display: flex;
gap: 0.35rem;
}
.tgu-slots-empty {
padding: 1rem;
color: var(--text-dim);
@@ -1363,6 +1396,53 @@
backdrop-filter: blur(6px);
}
/* Кнопка отмены внутри оверлея расчёта/автоподбора — на отдельной строке под текстом. */
.tgu-map-overlay button {
display: block;
margin-top: 0.5rem;
}
/* Подсказка о перегрузе карты слотами (карту не блокируем). */
.tgu-slots-map-hint {
position: absolute;
z-index: 5;
left: 50%;
bottom: 0.85rem;
transform: translateX(-50%);
max-width: 90%;
padding: 0.4rem 0.7rem;
border: 1px solid rgba(240, 173, 46, 0.42);
border-radius: 7px;
background: rgba(17, 19, 21, 0.88);
color: #ffe7b5;
font-size: 0.78rem;
backdrop-filter: blur(6px);
}
/* Баннер подтверждения крупной брони. */
.tgu-slots-confirm {
position: absolute;
z-index: 6;
top: 0.85rem;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.6rem 0.8rem;
border: 1px solid var(--accent);
border-radius: 8px;
background: rgba(17, 19, 21, 0.94);
color: var(--text);
font-size: 0.82rem;
backdrop-filter: blur(6px);
}
.tgu-slots-confirm__actions {
display: flex;
gap: 0.4rem;
}
.tgu-map-errors {
display: grid;
gap: 0.4rem;