diff --git a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/Tgu2DMapView.tsx b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/Tgu2DMapView.tsx index 947eab0..cadac9b 100644 --- a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/Tgu2DMapView.tsx +++ b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/Tgu2DMapView.tsx @@ -27,6 +27,8 @@ type Tgu2DMapViewProps = { selectedObjectId?: string; /** Отмеченные объекты (постоянная подсветка независимо от выбора) — напр. слоты, отобранные под бронь. */ markedObjectIds?: ReadonlySet; + /** Приглушённые объекты — напр. слоты, конфликтующие с текущим выбором (подсказка «недоступно»). */ + dimmedObjectIds?: ReadonlySet; /** Точка, на которую центрировать карту; перецентрирование срабатывает при смене focusToken. */ focusPoint?: GeoPoint; focusToken?: number; @@ -98,6 +100,7 @@ export function Tgu2DMapView({ selectedRequestPolygonId, selectedObjectId, markedObjectIds, + dimmedObjectIds, focusPoint, focusToken, enableObjectChooser = false, @@ -604,6 +607,14 @@ export function Tgu2DMapView({ : [], [markedObjectIds, activePolygons] ); + // Приглушённые (конфликтующие с выбором) объекты — рисуем под отмеченными/выбранными. + const dimmedPolygons = useMemo( + () => + dimmedObjectIds && dimmedObjectIds.size > 0 + ? activePolygons.filter((p) => dimmedObjectIds.has(p.id)) + : [], + [dimmedObjectIds, activePolygons] + ); const overlayMarkers = uniqueMarkers([selectedMarker, hoverMarkerRef.current]); // Накопленное расстояние (км) от первой вершины линейки до каждой — по дуге большого круга. @@ -638,6 +649,24 @@ export function Tgu2DMapView({ >
+
+ Прохождение + {active ? `${index + 1} / ${total}` : `— / ${total}`} + {active ? "" : "выберите прохождение ◀ ▶"} + {active && ( + + )} +
+ + {active &&
{label}
} + +
+ + +
+
+ ); +} 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 386494d..dbcce49 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,4 @@ -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useVirtualizer } from "@tanstack/react-virtual"; import { fetchRequestsForMap } from "../../api/requestApi"; import { @@ -25,6 +25,8 @@ import { MapObjectInspector } from "../tgu-map-2d/MapObjectInspector"; import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView"; import { formatLocalDateTimeSeconds, parseLocal, toLocalInputValue, toLocalIso } from "../../utils/localTime"; import { SlotDaySelector, type SlotDay } from "./SlotDaySelector"; +import { PassStepper } from "./PassStepper"; +import { groupPasses } from "./slotPasses"; import { SlotPickMenu, type SlotPick } from "./SlotPickMenu"; import { SlotFilterBar, type SlotSortMode } from "./SlotFilterBar"; import { CoveragePanel } from "./CoveragePanel"; @@ -38,6 +40,7 @@ import { type SlotMask } from "./coverage"; import { applySlotFilters, slotStatusKey, type SlotStatusKey } from "./slotFilters"; +import { computeConflicts } from "./slotConflicts"; import { selectionAfterCheck, selectStripRange } from "./slotSelection"; import { groupStrips, pickStripAnchor, pickStripEnd, type PolosaId } from "./slotStrips"; import { runChunked } from "./runChunked"; @@ -101,6 +104,9 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) { // Тумблер следа покрытия на карте (D4 — локальный state, вкл. по умолчанию; глобальный // Tgu2DMapLayersState не трогаем). Оператор может снять след, оставив только % в панели. const [coverageOn, setCoverageOn] = useState(true); + // Тумблер подсветки конфликтов выбора (тусклый слой). Вкл. по умолчанию; выключение и гасит слой, + // и пропускает пересчёт (см. conflictKeys) — заодно дешевле на больших наборах. + const [showConflicts, setShowConflicts] = useState(true); // Сортировка таблицы: «время» (порядок расчёта), «вклад» (жадное ранжирование по приросту // покрытия, колонка +N%), «крен» (по модулю угла крена). Вклад доступен только при наличии // геометрии заявки (иначе чип «вклад» заблокирован в SlotFilterBar). @@ -110,6 +116,10 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) { const [dayFrom, setDayFrom] = useState(1); const [dayTo, setDayTo] = useState(1); + // Курсор прохождения (витка): индекс в хронологическом списке passes; -1 — проход не выбран. + // Пока проход выбран, отображение изолировано до него; «Все» (clearPass) возвращает курсор в -1. + const [passCursor, setPassCursor] = useState(-1); + // Фильтры видимости (статус/КА/ветвь). Единая модель «selected = visible»: множество = что // видно, пусто = ничего. Дефолт — всё видно; satFilter инициализируется всеми КА при расчёте. const [statusFilter, setStatusFilter] = useState>( @@ -254,8 +264,28 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) { const effDayFrom = Math.max(1, Math.min(dayFrom, days.length || 1)); const effDayTo = Math.max(effDayFrom, Math.min(dayTo, days.length || 1)); + // Витки (проходы) расчёта в хронологическом порядке — для пошаговой навигации ◀/▶. + // Строим над СТАБИЛЬНЫМ displaySlots (как полосы), чтобы курсор доходил до любого витка + // независимо от фильтров видимости и окна суток. + const passes = useMemo(() => groupPasses(displaySlots, slotKey), [displaySlots]); + + // Новый расчёт меняет набор проходов — сбрасываем курсор (как окно суток по dayKeys). + useEffect(() => { + setPassCursor(-1); + }, [dayKeys]); + + // Курсор не должен «висеть» за пределами усохшего набора (бронь/смена набора): клампим к -1. + const effPassCursor = passCursor >= 0 && passCursor < passes.length ? passCursor : -1; + const currentPass = effPassCursor >= 0 ? passes[effPassCursor] : null; + // Ключи слотов текущего витка — для подсветки строк и режима изоляции. + const passKeySet = useMemo(() => new Set(currentPass?.keys ?? []), [currentPass]); + // Слоты в окне суток (до фильтров видимости) — база для счётчиков статусов и фильтрации. + // Пока выбрано прохождение — показываем только его слоты (изоляция); иначе — окно суток. const dayWindowed = useMemo(() => { + if (currentPass) { + return displaySlots.filter((slot) => passKeySet.has(slotKey(slot))); + } if (days.length === 0) return displaySlots; const fromMs = days[effDayFrom - 1].dayMs; const toMsExcl = days[effDayTo - 1].dayMs + DAY_MS; @@ -263,7 +293,7 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) { const ms = parseLocal(slot.tn); return Number.isFinite(ms) && ms >= fromMs && ms < toMsExcl; }); - }, [displaySlots, days, effDayFrom, effDayTo]); + }, [displaySlots, days, effDayFrom, effDayTo, currentPass, passKeySet]); // Счётчики статусов по окну суток (до статус-фильтра) — стабильны при переключении чипов. const statusCounts = useMemo>(() => { @@ -299,6 +329,33 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) { [dayWindowed, statusFilter, satFilter, branchFilter, bookedKeys], ); + // Ключи слотов, конфликтующих с текущим выбором (тот же КА + недостаточный зазор на манёвр) — гасим + // их как подсказку «станет недоступным». Приближённая клиентская эвристика; реальная выполнимость + // (slew/maxChainLength) — за бэкендом (it.2), поэтому слоты остаются выбираемыми. См. slotConflicts. + // + // Производительность: выбор берём через useDeferredValue — во время Shift+drag (selectedKeys меняется + // на каждый pointermove) пересчёт идёт фоном/после паузы, не блокируя жест. Кандидаты — только видимый + // filteredSlots; источник занятых интервалов — полный displaySlots (выбор может быть вне окна суток). + const deferredSelectedKeys = useDeferredValue(selectedKeys); + const conflictKeys = useMemo( + () => + showConflicts + ? computeConflicts( + displaySlots, + filteredSlots, + slotKey, + (slot) => deferredSelectedKeys.has(slotKey(slot)), + (slot) => bookedKeys.has(slotKey(slot)) || slotIsBusy(slot), + ) + : new Set(), + [showConflicts, displaySlots, filteredSlots, deferredSelectedKeys, bookedKeys] + ); + // id полигонов конфликтных слотов — приглушение на карте (параллель markedSlotIds). + const dimmedSlotIds = useMemo( + () => new Set(Array.from(conflictKeys, (key) => `slot:${key}`)), + [conflictKeys] + ); + // Разметка полос — над СТАБИЛЬНЫМ набором displaySlots (НЕ filteredSlots/sortedSlots: иначе // фильтр-дырка ложно разорвала или склеила бы полосу). Полоса = один КА + один крен + подряд по // времени (виток не граница). idByKey — id полосы по ключу слота; membersOf — члены полосы по времени. @@ -908,6 +965,48 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) { focusOn({ lat: slot.latitude, lon: slot.longitude }); }; + // Переход на прохождение по индексу: ставит курсор, подтягивает окно суток к проходу (сохраняя + // ширину), скроллит таблицу к первой строке и подсвечивает проход. Карту НЕ двигаем — оператор + // листает прохождения, не теряя текущий обзор/зум; центрирование осталось только на клике по слоту. + const goToPass = useCallback( + (target: number) => { + if (passes.length === 0) return; + const idx = Math.max(0, Math.min(target, passes.length - 1)); + setPassCursor(idx); + const pass = passes[idx]; + // Окно суток следует за курсором: если проход вне окна — сдвигаем, сохраняя ширину диапазона. + if (days.length > 0 && Number.isFinite(pass.startMs)) { + const di = days.findIndex((d) => d.dayMs === dayStartMs(pass.startMs)); + if (di >= 0) { + const dayIndex = di + 1; + const width = effDayTo - effDayFrom; + if (dayIndex < effDayFrom) { + setDayFrom(dayIndex); + setDayTo(Math.min(days.length, dayIndex + width)); + } else if (dayIndex > effDayTo) { + setDayTo(dayIndex); + setDayFrom(Math.max(1, dayIndex - width)); + } + } + } + // Скролл таблицы к проходу — через тот же activeSlotId-эффект, что и клик по слоту, но без + // focusOn: карту оставляем на месте (правка «карта не прыгает при работе с фильтром»). + const firstKey = pass.keys[0]; + if (firstKey && slotByKey.has(firstKey)) setActiveSlotId(`slot:${firstKey}`); + }, + [passes, days, effDayFrom, effDayTo, slotByKey] + ); + + // ◀ из «не выбран» входит с конца, ▶ — с начала; дальше клампим в [0, last]. + const prevPass = () => goToPass(effPassCursor < 0 ? passes.length - 1 : effPassCursor - 1); + const nextPass = () => goToPass(effPassCursor < 0 ? 0 : effPassCursor + 1); + const clearPass = () => setPassCursor(-1); + const passLabel = currentPass + ? `КА ${currentPass.satelliteId} · прохождение ${currentPass.revolution} ${ + currentPass.revolutionSign === "DESC" ? "нисх" : "восх" + } · ${formatLocalDateTimeSeconds(currentPass.startMs)}` + : ""; + // Выбор заявки из инспектора — закрываем инспектор, переходим в режим расчёта слотов. const chooseRequest = useCallback((requestId: string) => { setSelectedRequestId(requestId); @@ -1266,6 +1365,17 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) { Полоса: клик+Shift в таблице · Shift+тянуть на карте
+