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
@@ -0,0 +1,72 @@
type PassStepperProps = {
/** Индекс текущего витка (0-based) или -1, если виток не выбран. */
index: number;
/** Всего витков в наборе. */
total: number;
/** Подпись текущего витка (КА · оборот · ветвь · время) — пустая, если виток не выбран. */
label: string;
canPrev: boolean;
canNext: boolean;
onPrev: () => void;
onNext: () => void;
/** Сброс курсора витка — вернуться к показу всего набора. */
onClear: () => void;
};
/**
* Степпер по виткам (проходам): ◀/▶ переводят курсор на предыдущий/следующий проход в хронологии,
* таблица скроллится к нему. Пока прохождение выбрано — отображение изолировано до него; «Все»
* возвращает к показу всего набора. Стоит под фильтром «Сутки»: сутки — грубый диапазон, проход —
* точный курсор внутри.
*/
export function PassStepper({
index,
total,
label,
canPrev,
canNext,
onPrev,
onNext,
onClear,
}: PassStepperProps) {
const active = index >= 0;
return (
<div className="map-pass-selector tgu-pass-stepper">
<div className="map-pass-selector__header">
<span className="map-pass-selector__title">Прохождение</span>
<span className="map-pass-selector__nums">{active ? `${index + 1} / ${total}` : `— / ${total}`}</span>
<span className="map-pass-selector__times">{active ? "" : "выберите прохождение ◀ ▶"}</span>
{active && (
<button type="button" className="map-pass-selector__reset" title="Показать все прохождения" onClick={onClear}>
Все
</button>
)}
</div>
{active && <div className="tgu-pass-stepper__label">{label}</div>}
<div className="tgu-pass-stepper__nav">
<button
type="button"
className="tgu-pass-stepper__btn"
onClick={onPrev}
disabled={!canPrev}
title="Предыдущее прохождение"
aria-label="Предыдущее прохождение"
>
</button>
<button
type="button"
className="tgu-pass-stepper__btn"
onClick={onNext}
disabled={!canNext}
title="Следующее прохождение"
aria-label="Следующее прохождение"
>
</button>
</div>
</div>
);
}