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:
@@ -27,6 +27,8 @@ type Tgu2DMapViewProps = {
|
||||
selectedObjectId?: string;
|
||||
/** Отмеченные объекты (постоянная подсветка независимо от выбора) — напр. слоты, отобранные под бронь. */
|
||||
markedObjectIds?: ReadonlySet<string>;
|
||||
/** Приглушённые объекты — напр. слоты, конфликтующие с текущим выбором (подсказка «недоступно»). */
|
||||
dimmedObjectIds?: ReadonlySet<string>;
|
||||
/** Точка, на которую центрировать карту; перецентрирование срабатывает при смене 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({
|
||||
>
|
||||
<canvas className="tgu-2d-map__canvas" ref={canvasRef} />
|
||||
<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) => {
|
||||
const raw = polygon.points.map((point) => projection.project(point));
|
||||
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 { 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}
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user