diff --git a/services/pcp-tgu-ui-service/package.json b/services/pcp-tgu-ui-service/package.json index ce1d2d1..f10dfae 100644 --- a/services/pcp-tgu-ui-service/package.json +++ b/services/pcp-tgu-ui-service/package.json @@ -19,6 +19,7 @@ "@nstart/ui": "^0.4.0", "@phosphor-icons/react": "^2.1.10", "@tanstack/react-query": "^5.96.1", + "@tanstack/react-virtual": "^3.14.2", "d3-geo": "^3.1.1", "react": "^19.2.4", "react-dom": "^19.2.4", diff --git a/services/pcp-tgu-ui-service/src/api/slotsApi.test.ts b/services/pcp-tgu-ui-service/src/api/slotsApi.test.ts new file mode 100644 index 0000000..e78f813 --- /dev/null +++ b/services/pcp-tgu-ui-service/src/api/slotsApi.test.ts @@ -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); + }); +}); diff --git a/services/pcp-tgu-ui-service/src/api/slotsApi.ts b/services/pcp-tgu-ui-service/src/api/slotsApi.ts index 622b23b..36afec8 100644 --- a/services/pcp-tgu-ui-service/src/api/slotsApi.ts +++ b/services/pcp-tgu-ui-service/src/api/slotsApi.ts @@ -26,11 +26,17 @@ async function readErrorMessage(response: Response, fallback: string): Promise { +export async function fetchRequestSlots( + params: RequestSlotsParams, + signal?: AbortSignal, +): Promise { 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 query.append('satellites', String(id))) const response = await apiFetch(`${BASE}/request-cover?${query}`, { headers: { Accept: 'application/json' }, + signal, }) if (!response.ok) { diff --git a/services/pcp-tgu-ui-service/src/features/tgu-slots/TguSlotsTab.tsx b/services/pcp-tgu-ui-service/src/features/tgu-slots/TguSlotsTab.tsx index 0a8f29c..46f14fb 100644 --- a/services/pcp-tgu-ui-service/src/features/tgu-slots/TguSlotsTab.tsx +++ b/services/pcp-tgu-ui-service/src/features/tgu-slots/TguSlotsTab.tsx @@ -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("GREEDY"); // Результаты: слоты-кандидаты и уже забронированные слоты заявки. const [slots, setSlots] = useState([]); @@ -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(); + const [pendingBook, setPendingBook] = useState(null); const [error, setError] = useState(); const [notice, setNotice] = useState(); + // Кэш распарсенных WKT-контуров по ключу слота — парсим контур один раз, а не на каждый + // ререндер сцены (критично на тысячах слотов). Чистится при смене заявки. + const wktCacheRef = useRef(new Map()); + // Контроллер отмены текущего расчёта/автоподбора — прерывается при повторе или смене заявки. + const abortRef = useRef(null); + // Якорь для выбора диапазона по Shift+клику (индекс в filteredSlots). + const anchorIndexRef = useRef(null); + // Подсветка строки/контура по клику на карте и центрирование на слоте. const [activeSlotId, setActiveSlotId] = useState(); const [focusPoint, setFocusPoint] = useState(); const focusTokenRef = useRef(0); const [focusToken, setFocusToken] = useState(0); - // Контейнер списка слотов — по нему ищем активную строку (data-slot-id) и скроллим её в зону видимости. - const listRef = useRef(null); + // Скролл-контейнер списка слотов — служит scrollElement для виртуализатора. + const listRef = useRef(null); // Инспектор заявки справа над картой: объекты под кликом и раскрытый (только до выбора заявки). const [mapHits, setMapHits] = useState([]); @@ -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(`[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( () => ({ @@ -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 (
@@ -590,13 +734,32 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) { + + + +
- ) : ( - - - - - - - - - - - - - - - {filteredSlots.map((slot) => { - const key = slotKey(slot); - const id = `slot:${key}`; - const booked = bookedKeys.has(key); - const isActive = activeSlotId === id; - return ( - selectRow(slot)} - > - + {padTop > 0 && ( + + + )} + {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 ( + selectRow(slot)} + > + + + + + + + + + + ); + })} + {padBottom > 0 && ( + + + )} + +
- - КАВитокСлот / циклНачалоКонецКренСтатус
event.stopPropagation()}> +
+ {filteredSlots.length === 0 ? ( +
+ {slots.length > 0 ? "На выбранные сутки слотов нет." : "Слоты не рассчитаны."} +
+ ) : ( + + + + - - - - - - + + + + + + + + - ); - })} - -
toggleSlot(key)} - aria-label="Выбрать слот" + checked={allVisibleSelected} + onChange={toggleAllVisible} + aria-label="Выбрать видимые сутки" /> - - {slot.satelliteId} - {slot.revolution} / {slot.revolutionSign === "DESC" ? "нисх" : "восх"} - - {slot.slotNumber} / {slot.cycle} - {formatLocalDateTime(parseLocal(slot.tn))}{formatLocalDateTime(parseLocal(slot.tk))}{slot.roll.toFixed(1)}° - {booked ? ( - бронь - ) : ( - {slotStateLabel(slot.state)} - )} - КАВитокСлот / циклНачалоКонецКренСтатус
- ) + +
event.stopPropagation()}> + {}} + onClick={(event) => handleSlotCheck(vrow.index, event.shiftKey)} + aria-label="Выбрать слот" + /> + {slot.satelliteId} + {slot.revolution} / {slot.revolutionSign === "DESC" ? "нисх" : "восх"} + + {slot.slotNumber} / {slot.cycle} + {formatLocalDateTime(parseLocal(slot.tn))}{formatLocalDateTime(parseLocal(slot.tk))}{slot.roll.toFixed(1)}° + {booked ? ( + бронь + ) : ( + {slotStateLabel(slot.state)} + )} +
+ )} + + ) : (
@@ -741,12 +956,39 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
- {overlayMessage &&
{overlayMessage}
} + {overlayMessage && ( +
+ {overlayMessage} + {busy && ( + + )} +
+ )} + {pendingBook && ( +
+ Забронировать {pendingBook.length} слотов? Это крупная бронь. +
+ + +
+
+ )} {hasRequest && ( )} + {hasRequest && mapOverloaded && ( +
+ Много слотов в окне ({filteredSlots.length}) — сузьте сутки для удобной карты. +
+ )} {hasRequest && days.length > 0 && ( `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]]); + }); +}); diff --git a/services/pcp-tgu-ui-service/src/features/tgu-slots/slotSelection.ts b/services/pcp-tgu-ui-service/src/features/tgu-slots/slotSelection.ts new file mode 100644 index 0000000..8345d71 --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-slots/slotSelection.ts @@ -0,0 +1,52 @@ +// Чистая логика выбора слотов и разбиения на чанки — выделена из TguSlotsTab, +// чтобы покрыть юнит-тестами без рендера компонента. + +/** + * Новое множество выбора после клика по строке. + * Обычный клик — переключает один ключ. Shift+клик при заданном якоре — выбирает (или + * снимает) непрерывный диапазон от якоря до текущего индекса; направление задаётся новым + * состоянием текущей строки (если она была не выбрана — диапазон выбираем, иначе снимаем). + * + * @param current Текущее множество выбранных ключей. + * @param keyAt Доступ к ключу слота по индексу в видимом (отфильтрованном) списке. + * @param index Индекс строки, по которой кликнули. + * @param shiftKey Был ли зажат Shift. + * @param anchorIndex Индекс предыдущего клика (якорь) либо null. + */ +export function selectionAfterCheck( + current: ReadonlySet, + keyAt: (index: number) => string, + index: number, + shiftKey: boolean, + anchorIndex: number | null, +): Set { + 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(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 +} diff --git a/services/pcp-tgu-ui-service/src/model/slotTypes.ts b/services/pcp-tgu-ui-service/src/model/slotTypes.ts index ebecf33..66cafb1 100644 --- a/services/pcp-tgu-ui-service/src/model/slotTypes.ts +++ b/services/pcp-tgu-ui-service/src/model/slotTypes.ts @@ -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; }; diff --git a/services/pcp-tgu-ui-service/src/styles/tgu-planning.css b/services/pcp-tgu-ui-service/src/styles/tgu-planning.css index 2b85c5e..10f26c9 100644 --- a/services/pcp-tgu-ui-service/src/styles/tgu-planning.css +++ b/services/pcp-tgu-ui-service/src/styles/tgu-planning.css @@ -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;