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:
@@ -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}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user