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
@@ -27,6 +27,8 @@ type Tgu2DMapViewProps = {
selectedObjectId?: string; selectedObjectId?: string;
/** Отмеченные объекты (постоянная подсветка независимо от выбора) — напр. слоты, отобранные под бронь. */ /** Отмеченные объекты (постоянная подсветка независимо от выбора) — напр. слоты, отобранные под бронь. */
markedObjectIds?: ReadonlySet<string>; markedObjectIds?: ReadonlySet<string>;
/** Приглушённые объекты — напр. слоты, конфликтующие с текущим выбором (подсказка «недоступно»). */
dimmedObjectIds?: ReadonlySet<string>;
/** Точка, на которую центрировать карту; перецентрирование срабатывает при смене focusToken. */ /** Точка, на которую центрировать карту; перецентрирование срабатывает при смене focusToken. */
focusPoint?: GeoPoint; focusPoint?: GeoPoint;
focusToken?: number; focusToken?: number;
@@ -98,6 +100,7 @@ export function Tgu2DMapView({
selectedRequestPolygonId, selectedRequestPolygonId,
selectedObjectId, selectedObjectId,
markedObjectIds, markedObjectIds,
dimmedObjectIds,
focusPoint, focusPoint,
focusToken, focusToken,
enableObjectChooser = false, enableObjectChooser = false,
@@ -604,6 +607,14 @@ export function Tgu2DMapView({
: [], : [],
[markedObjectIds, activePolygons] [markedObjectIds, activePolygons]
); );
// Приглушённые (конфликтующие с выбором) объекты — рисуем под отмеченными/выбранными.
const dimmedPolygons = useMemo(
() =>
dimmedObjectIds && dimmedObjectIds.size > 0
? activePolygons.filter((p) => dimmedObjectIds.has(p.id))
: [],
[dimmedObjectIds, activePolygons]
);
const overlayMarkers = uniqueMarkers([selectedMarker, hoverMarkerRef.current]); const overlayMarkers = uniqueMarkers([selectedMarker, hoverMarkerRef.current]);
// Накопленное расстояние (км) от первой вершины линейки до каждой — по дуге большого круга. // Накопленное расстояние (км) от первой вершины линейки до каждой — по дуге большого круга.
@@ -638,6 +649,24 @@ export function Tgu2DMapView({
> >
<canvas className="tgu-2d-map__canvas" ref={canvasRef} /> <canvas className="tgu-2d-map__canvas" ref={canvasRef} />
<svg className="tgu-2d-map__overlay" width={size.width} height={size.height} aria-hidden="true"> <svg className="tgu-2d-map__overlay" width={size.width} height={size.height} aria-hidden="true">
{dimmedPolygons.flatMap((polygon) => {
const raw = polygon.points.map((point) => projection.project(point));
const pts = unwrapX(raw, projection.worldSize);
return ([0, projection.worldSize, -projection.worldSize] as const).flatMap((dx) => {
const visible = pts.some((p) => {
const x = p.x + dx;
return x >= -64 && x <= size.width + 64 && p.y >= -64 && p.y <= size.height + 64;
});
if (!visible) return [];
return (
<polygon
key={`dimmed:${polygon.id}:${dx}`}
points={pts.map((p) => `${p.x + dx},${p.y}`).join(" ")}
className="is-dimmed"
/>
);
});
})}
{markedPolygons.flatMap((polygon) => { {markedPolygons.flatMap((polygon) => {
const raw = polygon.points.map((point) => projection.project(point)); const raw = polygon.points.map((point) => projection.project(point));
const pts = unwrapX(raw, projection.worldSize); const pts = unwrapX(raw, projection.worldSize);
@@ -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>
);
}
@@ -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 { useVirtualizer } from "@tanstack/react-virtual";
import { fetchRequestsForMap } from "../../api/requestApi"; import { fetchRequestsForMap } from "../../api/requestApi";
import { import {
@@ -25,6 +25,8 @@ import { MapObjectInspector } from "../tgu-map-2d/MapObjectInspector";
import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView"; import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView";
import { formatLocalDateTimeSeconds, parseLocal, toLocalInputValue, toLocalIso } from "../../utils/localTime"; import { formatLocalDateTimeSeconds, parseLocal, toLocalInputValue, toLocalIso } from "../../utils/localTime";
import { SlotDaySelector, type SlotDay } from "./SlotDaySelector"; import { SlotDaySelector, type SlotDay } from "./SlotDaySelector";
import { PassStepper } from "./PassStepper";
import { groupPasses } from "./slotPasses";
import { SlotPickMenu, type SlotPick } from "./SlotPickMenu"; import { SlotPickMenu, type SlotPick } from "./SlotPickMenu";
import { SlotFilterBar, type SlotSortMode } from "./SlotFilterBar"; import { SlotFilterBar, type SlotSortMode } from "./SlotFilterBar";
import { CoveragePanel } from "./CoveragePanel"; import { CoveragePanel } from "./CoveragePanel";
@@ -38,6 +40,7 @@ import {
type SlotMask type SlotMask
} from "./coverage"; } from "./coverage";
import { applySlotFilters, slotStatusKey, type SlotStatusKey } from "./slotFilters"; import { applySlotFilters, slotStatusKey, type SlotStatusKey } from "./slotFilters";
import { computeConflicts } from "./slotConflicts";
import { selectionAfterCheck, selectStripRange } from "./slotSelection"; import { selectionAfterCheck, selectStripRange } from "./slotSelection";
import { groupStrips, pickStripAnchor, pickStripEnd, type PolosaId } from "./slotStrips"; import { groupStrips, pickStripAnchor, pickStripEnd, type PolosaId } from "./slotStrips";
import { runChunked } from "./runChunked"; import { runChunked } from "./runChunked";
@@ -101,6 +104,9 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
// Тумблер следа покрытия на карте (D4 — локальный state, вкл. по умолчанию; глобальный // Тумблер следа покрытия на карте (D4 — локальный state, вкл. по умолчанию; глобальный
// Tgu2DMapLayersState не трогаем). Оператор может снять след, оставив только % в панели. // Tgu2DMapLayersState не трогаем). Оператор может снять след, оставив только % в панели.
const [coverageOn, setCoverageOn] = useState(true); const [coverageOn, setCoverageOn] = useState(true);
// Тумблер подсветки конфликтов выбора (тусклый слой). Вкл. по умолчанию; выключение и гасит слой,
// и пропускает пересчёт (см. conflictKeys) — заодно дешевле на больших наборах.
const [showConflicts, setShowConflicts] = useState(true);
// Сортировка таблицы: «время» (порядок расчёта), «вклад» (жадное ранжирование по приросту // Сортировка таблицы: «время» (порядок расчёта), «вклад» (жадное ранжирование по приросту
// покрытия, колонка +N%), «крен» (по модулю угла крена). Вклад доступен только при наличии // покрытия, колонка +N%), «крен» (по модулю угла крена). Вклад доступен только при наличии
// геометрии заявки (иначе чип «вклад» заблокирован в SlotFilterBar). // геометрии заявки (иначе чип «вклад» заблокирован в SlotFilterBar).
@@ -110,6 +116,10 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
const [dayFrom, setDayFrom] = useState(1); const [dayFrom, setDayFrom] = useState(1);
const [dayTo, setDayTo] = useState(1); const [dayTo, setDayTo] = useState(1);
// Курсор прохождения (витка): индекс в хронологическом списке passes; -1 — проход не выбран.
// Пока проход выбран, отображение изолировано до него; «Все» (clearPass) возвращает курсор в -1.
const [passCursor, setPassCursor] = useState(-1);
// Фильтры видимости (статус/КА/ветвь). Единая модель «selected = visible»: множество = что // Фильтры видимости (статус/КА/ветвь). Единая модель «selected = visible»: множество = что
// видно, пусто = ничего. Дефолт — всё видно; satFilter инициализируется всеми КА при расчёте. // видно, пусто = ничего. Дефолт — всё видно; satFilter инициализируется всеми КА при расчёте.
const [statusFilter, setStatusFilter] = useState<ReadonlySet<SlotStatusKey>>( 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 effDayFrom = Math.max(1, Math.min(dayFrom, days.length || 1));
const effDayTo = Math.max(effDayFrom, Math.min(dayTo, 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(() => { const dayWindowed = useMemo(() => {
if (currentPass) {
return displaySlots.filter((slot) => passKeySet.has(slotKey(slot)));
}
if (days.length === 0) return displaySlots; if (days.length === 0) return displaySlots;
const fromMs = days[effDayFrom - 1].dayMs; const fromMs = days[effDayFrom - 1].dayMs;
const toMsExcl = days[effDayTo - 1].dayMs + DAY_MS; const toMsExcl = days[effDayTo - 1].dayMs + DAY_MS;
@@ -263,7 +293,7 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
const ms = parseLocal(slot.tn); const ms = parseLocal(slot.tn);
return Number.isFinite(ms) && ms >= fromMs && ms < toMsExcl; return Number.isFinite(ms) && ms >= fromMs && ms < toMsExcl;
}); });
}, [displaySlots, days, effDayFrom, effDayTo]); }, [displaySlots, days, effDayFrom, effDayTo, currentPass, passKeySet]);
// Счётчики статусов по окну суток (до статус-фильтра) — стабильны при переключении чипов. // Счётчики статусов по окну суток (до статус-фильтра) — стабильны при переключении чипов.
const statusCounts = useMemo<Record<SlotStatusKey, number>>(() => { const statusCounts = useMemo<Record<SlotStatusKey, number>>(() => {
@@ -299,6 +329,33 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
[dayWindowed, statusFilter, satFilter, branchFilter, bookedKeys], [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: иначе // Разметка полос — над СТАБИЛЬНЫМ набором displaySlots (НЕ filteredSlots/sortedSlots: иначе
// фильтр-дырка ложно разорвала или склеила бы полосу). Полоса = один КА + один крен + подряд по // фильтр-дырка ложно разорвала или склеила бы полосу). Полоса = один КА + один крен + подряд по
// времени (виток не граница). idByKey — id полосы по ключу слота; membersOf — члены полосы по времени. // времени (виток не граница). idByKey — id полосы по ключу слота; membersOf — члены полосы по времени.
@@ -908,6 +965,48 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
focusOn({ lat: slot.latitude, lon: slot.longitude }); 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) => { const chooseRequest = useCallback((requestId: string) => {
setSelectedRequestId(requestId); setSelectedRequestId(requestId);
@@ -1266,6 +1365,17 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
Полоса: клик+Shift в таблице · Shift+тянуть на карте Полоса: клик+Shift в таблице · Shift+тянуть на карте
</span> </span>
<div className="tgu-slots-selbar__actions"> <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 <button
className="tgu-button tgu-button--sm" className="tgu-button tgu-button--sm"
type="button" type="button"
@@ -1351,7 +1461,13 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
polosaId != null && polosaId != null &&
polosaId === polosa.idByKey.get(anchorKey); polosaId === polosa.idByKey.get(anchorKey);
const isAnchorRow = anchorKey === key; const isAnchorRow = anchorKey === key;
// Подсветка строк текущего витка (курсор ◀/▶) — оператор видит проход целиком.
const inPass = passKeySet.has(key);
// Конфликт с выбором (тот же КА, пересечение по времени) — гасим строку.
const isConflict = conflictKeys.has(key);
const rowClass = [ const rowClass = [
inPass ? "tgu-slots-row--pass" : "",
isConflict ? "tgu-slots-row--conflict" : "",
isActive ? "is-active" : "", isActive ? "is-active" : "",
inAnchorPolosa ? "tgu-slots-row--strip" : "", inAnchorPolosa ? "tgu-slots-row--strip" : "",
isAnchorRow ? "is-strip-anchor" : "", isAnchorRow ? "is-strip-anchor" : "",
@@ -1571,6 +1687,7 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
</button> </button>
)} )}
{hasRequest && days.length > 0 && ( {hasRequest && days.length > 0 && (
<div className="tgu-slots-map-controls">
<SlotDaySelector <SlotDaySelector
days={days} days={days}
fromIndex={effDayFrom} fromIndex={effDayFrom}
@@ -1578,6 +1695,19 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
onFromChange={setDayFrom} onFromChange={setDayFrom}
onToChange={setDayTo} 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 <Tgu2DMapView
scene={scene} scene={scene}
@@ -1595,6 +1725,7 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
onStripCommit={hasRequest ? handleStripCommit : undefined} onStripCommit={hasRequest ? handleStripCommit : undefined}
selectedObjectId={hasRequest ? activeSlotId : activeHitId} selectedObjectId={hasRequest ? activeSlotId : activeHitId}
markedObjectIds={hasRequest ? markedSlotIds : undefined} markedObjectIds={hasRequest ? markedSlotIds : undefined}
dimmedObjectIds={hasRequest ? dimmedSlotIds : undefined}
focusPoint={focusPoint} focusPoint={focusPoint}
focusToken={focusToken} focusToken={focusToken}
/> />
@@ -0,0 +1,86 @@
import { describe, expect, it } from "vitest";
import { computeConflicts } from "./slotConflicts";
/** Минимальный слот: КА + окно [tn,tk] + крен + ключ. */
type S = { satelliteId: number; n: number; tn: string; tk: string; roll: number; state?: string };
const keyOf = (s: S) => `${s.satelliteId}:${s.n}`;
const pad = (v: number) => String(v).padStart(2, "0");
/** ISO-момент 10:MM:SS от секунд смещения относительно 10:00:00. */
function fmt(sec: number): string {
return `2026-06-17T10:${pad(Math.floor(sec / 60))}:${pad(sec % 60)}`;
}
/** Слот КА на окне [startSec, endSec] (секунды от 10:00:00), с креном roll. */
function slot(satelliteId: number, n: number, startSec: number, endSec: number, roll: number, state = "AVAILABLE"): S {
return { satelliteId, n, tn: fmt(startSec), tk: fmt(endSec), roll, state };
}
const notBlocked = () => false;
// Выбран ровно слот A (sat 1, n 1) — источник занятого интервала во всех кейсах.
const selectedIsA = (s: S) => s.satelliteId === 1 && s.n === 1;
// A: [0,30], крен 10.
const A = slot(1, 1, 0, 30, 10);
describe("computeConflicts (зазор на манёвр)", () => {
it("без выбора — конфликтов нет", () => {
const arr = [A, slot(1, 2, 38, 60, 10)];
expect(computeConflicts(arr, arr, keyOf, () => false, notBlocked).size).toBe(0);
});
it("тот же крен: зазор < 10 с → конфликт, ≥ 10 с → ок", () => {
const close = slot(1, 2, 38, 60, 10); // зазор 8 с → конфликт
const ok = slot(1, 3, 41, 60, 10); // зазор 11 с → ок
const arr = [A, close, ok];
const res = computeConflicts(arr, arr, keyOf, selectedIsA, notBlocked);
expect(res.has("1:2")).toBe(true);
expect(res.has("1:3")).toBe(false);
});
it("смена крена: тот же зазор 25 с конфликтует (нужно 30), при том же крене — нет (хватает 10)", () => {
const diffRoll = slot(1, 4, 55, 80, 20); // зазор 25 с, крен сменился → конфликт
const sameRoll = slot(1, 5, 55, 80, 10); // зазор 25 с, крен тот же → ок
const arr = [A, diffRoll, sameRoll];
const res = computeConflicts(arr, arr, keyOf, selectedIsA, notBlocked);
expect(res.has("1:4")).toBe(true);
expect(res.has("1:5")).toBe(false);
});
it("смена крена: зазор ≥ 30 с → ок", () => {
const ok = slot(1, 6, 61, 90, 20); // зазор 31 с → ок
const arr = [A, ok];
expect(computeConflicts(arr, arr, keyOf, selectedIsA, notBlocked).has("1:6")).toBe(false);
});
it("пересечение по времени → конфликт; сам выбранный не помечается", () => {
const overlap = slot(1, 7, 20, 50, 20); // перекрывает A
const arr = [A, overlap];
const res = computeConflicts(arr, arr, keyOf, selectedIsA, notBlocked);
expect(res.has("1:7")).toBe(true);
expect(res.has("1:1")).toBe(false);
});
it("другой КА не конфликтует", () => {
const other = slot(2, 8, 38, 60, 10); // тот же зазор, но КА 2
const arr = [A, other];
expect(computeConflicts(arr, arr, keyOf, selectedIsA, notBlocked).has("2:8")).toBe(false);
});
it("кандидат ПЕРЕД выбранным тоже ловится (тот же крен, зазор < 10 с)", () => {
const later = slot(1, 10, 100, 130, 10); // выбран, позже по времени
const before = slot(1, 11, 88, 95, 10); // зазор 5 с до later → конфликт
const farBefore = slot(1, 12, 50, 70, 10); // зазор 30 с до later → ок
const arr = [later, before, farBefore];
const res = computeConflicts(arr, arr, keyOf, (s) => s.n === 10, notBlocked);
expect(res.has("1:11")).toBe(true);
expect(res.has("1:12")).toBe(false);
});
it("бронь/BUSY исключены из конфликтов", () => {
const busy = slot(1, 9, 38, 60, 10, "BUSY"); // зазор 8 с, но BUSY
const arr = [A, busy];
const res = computeConflicts(arr, arr, keyOf, selectedIsA, (s) => s.state === "BUSY");
expect(res.has("1:9")).toBe(false);
});
});
@@ -0,0 +1,112 @@
// Приближённая клиентская подсказка «конфликтует с текущим выбором». Слот гаснет, если он того же
// КА, что и какой-то выбранный под бронь слот, и не выдержан зазор на манёвр между ними:
// • крен НЕ меняется → между концом одного и началом другого нужно ≥ 10 с;
// • крен меняется → нужно ≥ 30 с (на перенацеливание дольше).
// Меньший зазор (вплоть до пересечения) → конфликт. «Крен меняется» = расхождение крена > ROLL_EPSILON
// (тот же допуск, по которому слоты кластеризуются в полосы).
//
// Это ВИЗУАЛЬНЫЙ намёк, не проверка выполнимости: реальная физика (профиль slew, maxChainLength,
// maxSurveyDuration) живёт в slots-service по-КА и проверяется бэкенд-эндпоинтом (итерация 2).
// Поэтому конфликтные слоты остаются ВЫБИРАЕМЫМИ — мы лишь гасим их, решение за оператором.
import type { SlotDTO } from "../../model/slotTypes"
import { parseLocal } from "../../utils/localTime"
import { ROLL_EPSILON } from "./slotStrips"
type Conflictable = Pick<SlotDTO, "satelliteId" | "tn" | "tk" | "roll">
/** Требуемый зазор между концом одного и началом следующего слота того же КА (мс). */
export const SAME_ROLL_GAP_MS = 10_000
export const DIFF_ROLL_GAP_MS = 30_000
/** Выбранный интервал слота: окно [start,end) (мс) и крен — для выбора нужного зазора. */
type SelInterval = { start: number; end: number; roll: number }
/** Занятые интервалы выбранных слотов одного КА: отсортированы по началу + макс. длительность. */
type SatSelection = { intervals: SelInterval[]; maxDur: number }
/**
* Конфликтует ли кандидат [cStart,cEnd) с креном cRoll с выбранным интервалом s того же КА:
* упорядочиваем по началу и проверяем зазор (начало позднего конец раннего) против требуемого
* (10 с при том же крене, 30 с при смене крена). Пересечение даёт отрицательный зазор конфликт.
*/
function conflictsPair(cStart: number, cEnd: number, cRoll: number, s: SelInterval): boolean {
const required = Math.abs(cRoll - s.roll) <= ROLL_EPSILON ? SAME_ROLL_GAP_MS : DIFF_ROLL_GAP_MS
const [earlierEnd, laterStart] = cStart <= s.start ? [cEnd, s.start] : [s.end, cStart]
return laterStart - earlierEnd < required
}
/** Первый индекс, у которого intervals[i].start >= target (бинарный поиск по отсортированному). */
function lowerBound(intervals: SelInterval[], target: number): number {
let lo = 0
let hi = intervals.length
while (lo < hi) {
const mid = (lo + hi) >> 1
if (intervals[mid].start < target) lo = mid + 1
else hi = mid
}
return lo
}
/**
* Возвращает множество ключей кандидатов, конфликтующих с текущим выбором (тот же КА + недостаточный
* зазор на манёвр). Выбранные и «заблокированные» (бронь/BUSY) слоты в результат не попадают.
*
* Производительность: занятые интервалы строим из source (полный набор; выбор обычно мал), сортируем
* по началу и для каждого кандидата проверяем не всех выбранных, а лишь окно по времени ( зазора +
* макс. длительности) через бинарный поиск ~O(кандидаты·log(выбор)). За кандидатов берём видимый
* набор, а не весь невидимое всё равно не рисуется.
*
* @param source Полный набор слотов источник занятых интервалов выбранных.
* @param candidates Слоты, которые помечаем (обычно видимые: таблица/карта).
* @param keyOf Аксессор ключа слота.
* @param isSelected Входит ли слот в текущий набор под бронь.
* @param isBlocked Бронь/BUSY такой слот не помечаем как конфликт (у него своя индикация).
*/
export function computeConflicts<T extends Conflictable>(
source: readonly T[],
candidates: readonly T[],
keyOf: (slot: T) => string,
isSelected: (slot: T) => boolean,
isBlocked: (slot: T) => boolean,
): Set<string> {
const bySat = new Map<number, SatSelection>()
for (const slot of source) {
if (!isSelected(slot)) continue
const start = parseLocal(slot.tn)
const end = parseLocal(slot.tk)
if (!Number.isFinite(start) || !Number.isFinite(end)) continue
let bucket = bySat.get(slot.satelliteId)
if (!bucket) {
bucket = { intervals: [], maxDur: 0 }
bySat.set(slot.satelliteId, bucket)
}
bucket.intervals.push({ start, end, roll: slot.roll })
bucket.maxDur = Math.max(bucket.maxDur, end - start)
}
const conflicts = new Set<string>()
if (bySat.size === 0) return conflicts
for (const bucket of bySat.values()) bucket.intervals.sort((a, b) => a.start - b.start)
for (const slot of candidates) {
if (isSelected(slot) || isBlocked(slot)) continue
const bucket = bySat.get(slot.satelliteId)
if (!bucket) continue
const start = parseLocal(slot.tn)
const end = parseLocal(slot.tk)
if (!Number.isFinite(start) || !Number.isFinite(end)) continue
// Конфликтовать может только выбранный интервал в окне по времени: его start в диапазоне
// [start maxGap maxDur, end + maxGap]. Вне окна зазор заведомо больше требуемого.
const ints = bucket.intervals
const highStart = end + DIFF_ROLL_GAP_MS
for (let i = lowerBound(ints, start - DIFF_ROLL_GAP_MS - bucket.maxDur); i < ints.length; i++) {
if (ints[i].start > highStart) break
if (conflictsPair(start, end, slot.roll, ints[i])) {
conflicts.add(keyOf(slot))
break
}
}
}
return conflicts
}
@@ -0,0 +1,90 @@
import { describe, expect, it } from "vitest";
import type { RevolutionSign } from "../../model/slotTypes";
import { groupPasses } from "./slotPasses";
/** Минимальный слот для группировки в витки. */
type S = {
satelliteId: number;
slotNumber: number;
cycle: number;
revolution: number;
revolutionSign: RevolutionSign;
tn: string;
};
const keyOf = (s: S) => `${s.satelliteId}:${s.slotNumber}:${s.cycle}`;
/** Слот на минуту mm от базовой даты; виток задаётся (revolution, sign, cycle). */
function slot(
satelliteId: number,
slotNumber: number,
mm: number,
revolution: number,
revolutionSign: RevolutionSign = "ASC",
cycle = 1,
): S {
return {
satelliteId,
slotNumber,
cycle,
revolution,
revolutionSign,
tn: `2026-06-17T10:${String(mm).padStart(2, "0")}:00`,
};
}
describe("groupPasses", () => {
it("пустой вход → пустой список", () => {
expect(groupPasses([], keyOf)).toEqual([]);
});
it("веер кренов одного оборота → один виток (крен не дробит)", () => {
// Три нацеливания одного прохода КА 1, оборот 100, восх — все в один виток.
const passes = groupPasses(
[slot(1, 1, 0, 100), slot(1, 2, 0, 100), slot(1, 3, 1, 100)],
keyOf,
);
expect(passes).toHaveLength(1);
expect(passes[0].id).toBe("1:1:100:ASC");
expect(passes[0].keys).toEqual(["1:1:1", "1:2:1", "1:3:1"]);
expect(passes[0].satelliteId).toBe(1);
expect(passes[0].revolution).toBe(100);
});
it("разные обороты / ветви / КА → разные витки", () => {
const passes = groupPasses(
[
slot(1, 1, 0, 100, "ASC"),
slot(1, 2, 5, 100, "DESC"), // та же revolution, другая ветвь
slot(1, 3, 10, 101, "ASC"), // другой оборот
slot(2, 4, 2, 100, "ASC"), // другой КА
],
keyOf,
);
expect(passes.map((p) => p.id)).toEqual([
"1:1:100:ASC", // 10:00
"2:1:100:ASC", // 10:02
"1:1:100:DESC", // 10:05
"1:1:101:ASC", // 10:10
]);
});
it("витки упорядочены по началу прохода (хронология по всем КА)", () => {
const passes = groupPasses(
[slot(1, 1, 30, 100), slot(2, 2, 5, 200), slot(1, 3, 15, 101)],
keyOf,
);
expect(passes.map((p) => p.startMs)).toEqual(
[...passes.map((p) => p.startMs)].sort((a, b) => a - b),
);
expect(passes[0].satelliteId).toBe(2); // самый ранний (10:05)
});
it("ключи внутри витка отсортированы по времени", () => {
const passes = groupPasses(
[slot(1, 3, 2, 100), slot(1, 1, 0, 100), slot(1, 2, 1, 100)],
keyOf,
);
expect(passes[0].keys).toEqual(["1:1:1", "1:2:1", "1:3:1"]);
});
});
@@ -0,0 +1,80 @@
// Группировка слотов в «витки» (проходы) для пошаговой навигации курсором ◀/▶.
//
// Виток (проход над районом) = слоты ОДНОГО КА на ОДНОМ обороте: ключ (satelliteId, cycle,
// revolution, revolutionSign) — ровно тот кортеж, что показан в колонке «Виток» таблицы. В отличие
// от полосы (slotStrips.ts: один КА + один крен + непрерывность по времени) виток НЕ дробится по
// крену — внутри одного прохода «веер» нацеливаний на разных кренах принадлежит одному витку.
//
// Это identity-группировка для навигации (встать на проход, шагнуть на следующий), НЕ проверка
// выполнимости брони. Порядок витков — хронологический по началу прохода (по всем КА вперемешку),
// чтобы «от первого к последнему» работало при нескольких КА.
import type { RevolutionSign, SlotDTO } from "../../model/slotTypes"
import { parseLocal } from "../../utils/localTime"
/** Минимальный набор полей слота, нужный для группировки в витки. */
type Passable = Pick<SlotDTO, "satelliteId" | "revolution" | "revolutionSign" | "cycle" | "tn">
/** Один виток (проход): кортеж витка + время начала и ключи слотов (упорядочены по времени). */
export type Pass = {
/** Стабильный id витка: `${sat}:${cycle}:${revolution}:${sign}`. */
id: string
satelliteId: number
revolution: number
revolutionSign: RevolutionSign
cycle: number
/** Начало прохода — минимальный tn среди слотов витка (мс, локально). */
startMs: number
/** Ключи слотов витка, отсортированы по времени начала. */
keys: string[]
}
/** Ключ витка из полей слота. */
function passId(slot: Passable): string {
return `${slot.satelliteId}:${slot.cycle}:${slot.revolution}:${slot.revolutionSign}`
}
/**
* Размечает слоты по виткам и возвращает витки в хронологическом порядке (по началу прохода).
*
* Детерминизм: порядок не зависит от входного массива внутри сортируем ключи витка по времени,
* сами витки по (startMs, id). Слоты без валидного времени игнорируем для startMs (берут + при
* сравнении внутри витка), но в keys попадают, чтобы виток не «потерял» слот.
*
* @param slots Слоты для разметки (любой порядок).
* @param keyOf Аксессор ключа слота (как slotKey в TguSlotsTab) чтобы не дублировать формулу.
*/
export function groupPasses<T extends Passable>(slots: readonly T[], keyOf: (slot: T) => string): Pass[] {
type Bucket = { slot: Passable; entries: { key: string; tnMs: number }[] }
const buckets = new Map<string, Bucket>()
for (const slot of slots) {
const id = passId(slot)
let bucket = buckets.get(id)
if (!bucket) {
bucket = { slot, entries: [] }
buckets.set(id, bucket)
}
bucket.entries.push({ key: keyOf(slot), tnMs: parseLocal(slot.tn) })
}
const passes: Pass[] = []
for (const [id, bucket] of buckets) {
bucket.entries.sort((a, b) => a.tnMs - b.tnMs || (a.key < b.key ? -1 : a.key > b.key ? 1 : 0))
const startMs = bucket.entries.reduce(
(min, e) => (Number.isFinite(e.tnMs) ? Math.min(min, e.tnMs) : min),
Number.POSITIVE_INFINITY,
)
passes.push({
id,
satelliteId: bucket.slot.satelliteId,
revolution: bucket.slot.revolution,
revolutionSign: bucket.slot.revolutionSign,
cycle: bucket.slot.cycle,
startMs,
keys: bucket.entries.map((e) => e.key),
})
}
passes.sort((a, b) => a.startMs - b.startMs || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
return passes
}
@@ -468,6 +468,65 @@
line-height: 1; line-height: 1;
} }
/* Контейнер контролов поверх карты слотов: фильтр «Сутки» + степпер витков столбиком.
Нейтрализуем абсолютное позиционирование вложенных .map-pass-selector стопку держит контейнер. */
.tgu-slots-map-controls {
position: absolute;
top: 0.55rem;
left: 0.55rem;
z-index: 20;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.tgu-slots-map-controls .map-pass-selector {
position: static;
}
/* Подпись прохождения (КА · оборот · ветвь · дата-время) во всю ширину, без обрезки
дата/время чувствительны для оператора, должны быть видны целиком. */
.tgu-pass-stepper__label {
padding: 0 0.65rem;
color: var(--text);
font-family: var(--mono);
font-size: 0.72rem;
line-height: 1.35;
white-space: normal;
overflow-wrap: anywhere;
}
/* Степпер витков: ряд навигации ◀ ▶ под шапкой-подписью. */
.tgu-pass-stepper__nav {
display: flex;
align-items: center;
justify-content: center;
gap: 1.2rem;
padding: 0.1rem 0.65rem 0.5rem;
}
.tgu-pass-stepper__btn {
flex-shrink: 0;
width: 2rem;
height: 1.7rem;
border: 1px solid var(--line);
border-radius: 5px;
background: var(--bg-elevated);
color: var(--text);
font-size: 0.8rem;
cursor: pointer;
}
.tgu-pass-stepper__btn:hover:not(:disabled) {
border-color: var(--accent);
color: var(--accent);
}
.tgu-pass-stepper__btn:disabled {
opacity: 0.4;
cursor: default;
}
.tgu-editor-map-stage .tgu-2d-map { .tgu-editor-map-stage .tgu-2d-map {
min-height: 6rem; min-height: 6rem;
} }
@@ -960,7 +960,19 @@
.tgu-slots-selbar__actions { .tgu-slots-selbar__actions {
display: flex; display: flex;
gap: 0.35rem; align-items: center;
gap: 0.5rem;
}
/* Тумблер подсветки конфликтов — рядом с действиями выбора, в том же контексте, где гаснут слоты. */
.tgu-slots-selbar__toggle {
display: inline-flex;
align-items: center;
gap: 0.3rem;
color: var(--text-dim);
cursor: pointer;
user-select: none;
white-space: nowrap;
} }
/* Панель покрытия над списком (D-D1). Цвет = слой coverage на карте (violet) — единый язык «покрыто». */ /* Панель покрытия над списком (D-D1). Цвет = слой coverage на карте (violet) — единый язык «покрыто». */
@@ -1324,6 +1336,18 @@
background: rgba(104, 195, 189, 0.12); background: rgba(104, 195, 189, 0.12);
} }
/* Подсветка строк текущего витка (курсор / степпера): мягкая заливка прохода целиком, чтобы
оператор видел все слоты одного прохода. Активная строка (is-active) перекрывает её это норма. */
.tgu-slots-table tbody tr.tgu-slots-row--pass {
background: rgba(120, 150, 220, 0.14);
}
/* Конфликт с текущим выбором (тот же КА, пересечение по времени): строка тусклая оператор видит,
что выбор делает её недоступной. Слот остаётся выбираемым (правило приближённое, запрет за бэком). */
.tgu-slots-table tbody tr.tgu-slots-row--conflict td {
opacity: 0.4;
}
.tgu-slots-badge { .tgu-slots-badge {
display: inline-block; display: inline-block;
padding: 0.1rem 0.45rem; padding: 0.1rem 0.45rem;
@@ -1431,6 +1455,14 @@
stroke-dasharray: 6 4; stroke-dasharray: 6 4;
} }
/* Конфликтующие с выбором слоты приглушённый серый «налёт»: слот гаснет, оператор видит, что он
стал недоступен из-за текущего выбора. Отличается и от зелёной брони, и от красного BUSY. */
.tgu-2d-map__overlay polygon.is-dimmed {
fill: rgba(20, 24, 28, 0.55);
stroke: rgba(120, 130, 140, 0.5);
stroke-width: 1;
}
.tgu-2d-map__overlay polyline.is-selected { .tgu-2d-map__overlay polyline.is-selected {
stroke: #f0ad2e; stroke: #f0ad2e;
stroke-width: 7; stroke-width: 7;