feat(tgu-ui): степпер по прохождениям и подсветка конфликтов выбора слотов

Степпер прохождений (виток как проход над районом):
- slotPasses.ts: группировка слотов в проходы (КА·cycle·revolution·sign) в
  хронологии по всем КА + юнит-тесты;
- PassStepper.tsx: ◀/▶ листают проходы, окно суток следует за курсором,
  таблица скроллится к проходу, карта не прыгает; выбранный проход
  изолирует отображение, «Все» — назад ко всему набору;
- дата/время прохождения отдельной строкой во всю ширину (видно целиком).

Подсветка конфликтов выбора (мягкая подсказка «станет недоступным»):
- slotConflicts.ts: тот же КА + недостаточный зазор на манёвр — 10 с при том
  же крене, 30 с при смене крена (ROLL_EPSILON); конфликтные слоты гаснут в
  таблице и на карте, остаются выбираемыми (жёсткая выполнимость — за бэком);
- производительность: оконный поиск по времени (~O(N·logM)), кандидаты —
  только видимые, выбор через useDeferredValue (не молотит во время drag);
- чекбокс «Гасить конфликты» в строке выбора: выключает и слой, и расчёт;
- юнит-тесты на правило зазора и оконный поиск.

Карта: проп dimmedObjectIds — серый «налёт» поверх конфликтных слотов в SVG
overlay (без изменения canvas).
This commit is contained in:
Дмитрий Соловьев
2026-06-17 13:31:28 +03:00
parent aabb550a0a
commit c402f376c5
9 changed files with 701 additions and 10 deletions
@@ -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<ReadonlySet<SlotStatusKey>>(
@@ -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<Record<SlotStatusKey, number>>(() => {
@@ -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<string>(),
[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+тянуть на карте
</span>
<div className="tgu-slots-selbar__actions">
<label
className="tgu-slots-selbar__toggle"
title="Гасить слоты, недоступные из-за текущего выбора (тот же КА, не хватает времени на манёвр)"
>
<input
type="checkbox"
checked={showConflicts}
onChange={(event) => setShowConflicts(event.target.checked)}
/>
Гасить конфликты
</label>
<button
className="tgu-button tgu-button--sm"
type="button"
@@ -1351,7 +1461,13 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
polosaId != null &&
polosaId === polosa.idByKey.get(anchorKey);
const isAnchorRow = anchorKey === key;
// Подсветка строк текущего витка (курсор ◀/▶) — оператор видит проход целиком.
const inPass = passKeySet.has(key);
// Конфликт с выбором (тот же КА, пересечение по времени) — гасим строку.
const isConflict = conflictKeys.has(key);
const rowClass = [
inPass ? "tgu-slots-row--pass" : "",
isConflict ? "tgu-slots-row--conflict" : "",
isActive ? "is-active" : "",
inAnchorPolosa ? "tgu-slots-row--strip" : "",
isAnchorRow ? "is-strip-anchor" : "",
@@ -1571,13 +1687,27 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
</button>
)}
{hasRequest && days.length > 0 && (
<SlotDaySelector
days={days}
fromIndex={effDayFrom}
toIndex={effDayTo}
onFromChange={setDayFrom}
onToChange={setDayTo}
/>
<div className="tgu-slots-map-controls">
<SlotDaySelector
days={days}
fromIndex={effDayFrom}
toIndex={effDayTo}
onFromChange={setDayFrom}
onToChange={setDayTo}
/>
{passes.length > 0 && (
<PassStepper
index={effPassCursor}
total={passes.length}
label={passLabel}
canPrev={effPassCursor !== 0}
canNext={effPassCursor !== passes.length - 1}
onPrev={prevPass}
onNext={nextPass}
onClear={clearPass}
/>
)}
</div>
)}
<Tgu2DMapView
scene={scene}
@@ -1595,6 +1725,7 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
onStripCommit={hasRequest ? handleStripCommit : undefined}
selectedObjectId={hasRequest ? activeSlotId : activeHitId}
markedObjectIds={hasRequest ? markedSlotIds : undefined}
dimmedObjectIds={hasRequest ? dimmedSlotIds : undefined}
focusPoint={focusPoint}
focusToken={focusToken}
/>