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;
|
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;
|
||||||
|
|||||||
Reference in New Issue
Block a user