feat(tgu-ui): запрет выбора конфликтных слотов + прохождение без подсветки
- конфликтные слоты нельзя добавить в выбор под бронь (клик по строке, Shift-диапазон, выбор полосы на карте, «выбрать все») — жёсткая блокировка; снятие выбора не блокируется (конфликт считается только для невыбранных) - слоты одной полосы между собой не конфликтуют (groupOf) — иначе нельзя было бы набрать полосу поштучным кликом - чекбокс «Конфликты» теперь и гасит, и блокирует выбор; выкл. — выбирать любые - переход на прохождение (◀/▶) больше не подсвечивает никакой слот: активная подсветка строки — только по явному клику; добавлена прокрутка к началу прохода - повторный Shift+клик по тому же концу снимает выделение (таблица и карта)
This commit is contained in:
@@ -12,6 +12,9 @@ type CoveragePanelProps = {
|
||||
/** Тумблер следа покрытия на карте (D4 — локальный, вкл. по умолчанию). */
|
||||
coverageOn: boolean;
|
||||
onToggleCoverage: (on: boolean) => void;
|
||||
/** Тумблер подсветки конфликтов выбора (тусклый слой) — рядом со следом на карте. */
|
||||
showConflicts: boolean;
|
||||
onToggleConflicts: (on: boolean) => void;
|
||||
};
|
||||
|
||||
// Панель покрытия над списком слотов (D-D1): «забронировано NN% · с выбором MM%» + двухсегментный
|
||||
@@ -23,6 +26,8 @@ export function CoveragePanel({
|
||||
available,
|
||||
coverageOn,
|
||||
onToggleCoverage,
|
||||
showConflicts,
|
||||
onToggleConflicts,
|
||||
}: CoveragePanelProps) {
|
||||
const bookedPct = booked?.percent ?? 0;
|
||||
const withPct = withSelection?.percent ?? bookedPct;
|
||||
@@ -34,6 +39,7 @@ export function CoveragePanel({
|
||||
<div className="tgu-coverage">
|
||||
<div className="tgu-coverage__head">
|
||||
<span className="tgu-coverage__title">Покрытие заявки</span>
|
||||
<div className="tgu-coverage__toggles">
|
||||
<label className="tgu-coverage__toggle" title="Показывать след покрытия на карте">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -42,6 +48,18 @@ export function CoveragePanel({
|
||||
/>
|
||||
След на карте
|
||||
</label>
|
||||
<label
|
||||
className="tgu-coverage__toggle"
|
||||
title="Гасить и блокировать выбор слотов, недоступных из-за текущего выбора (тот же КА, не хватает времени на манёвр). Выкл. — выбирать можно любые."
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showConflicts}
|
||||
onChange={(event) => onToggleConflicts(event.target.checked)}
|
||||
/>
|
||||
Конфликты
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!available ? (
|
||||
|
||||
@@ -41,7 +41,7 @@ import {
|
||||
} from "./coverage";
|
||||
import { applySlotFilters, slotStatusKey, type SlotStatusKey } from "./slotFilters";
|
||||
import { computeConflicts } from "./slotConflicts";
|
||||
import { selectionAfterCheck, selectStripRange } from "./slotSelection";
|
||||
import { deselectStripRange, selectionAfterCheck, selectStripRange } from "./slotSelection";
|
||||
import { groupStrips, pickStripAnchor, pickStripEnd, type PolosaId } from "./slotStrips";
|
||||
import { runChunked } from "./runChunked";
|
||||
import { ChunkProgress } from "./ChunkProgress";
|
||||
@@ -153,6 +153,9 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
|
||||
// Якорь жеста «полоса от первого к последнему»: ключ первого кликнутого слота. Состояние (не ref):
|
||||
// по нему подсвечиваем строки той же полосы, чтобы оператор видел границу выделения.
|
||||
const [anchorKey, setAnchorKey] = useState<string | null>(null);
|
||||
// Последний конец Shift-диапазона в таблице: повторный Shift+клик по тому же концу снимает диапазон
|
||||
// (toggle). Ref — для логики жеста, перерисовка не нужна; сбрасывается при смене якоря/набора.
|
||||
const lastShiftEndRef = useRef<string | null>(null);
|
||||
|
||||
// Подсветка строки/контура по клику на карте и центрирование на слоте.
|
||||
const [activeSlotId, setActiveSlotId] = useState<string>();
|
||||
@@ -336,6 +339,12 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
|
||||
// Производительность: выбор берём через useDeferredValue — во время Shift+drag (selectedKeys меняется
|
||||
// на каждый pointermove) пересчёт идёт фоном/после паузы, не блокируя жест. Кандидаты — только видимый
|
||||
// filteredSlots; источник занятых интервалов — полный displaySlots (выбор может быть вне окна суток).
|
||||
// Разметка полос — над СТАБИЛЬНЫМ набором displaySlots (НЕ filteredSlots/sortedSlots: иначе
|
||||
// фильтр-дырка ложно разорвала или склеила бы полосу). Полоса = один КА + один крен + подряд по
|
||||
// времени (виток не граница). idByKey — id полосы по ключу слота; membersOf — члены полосы по времени.
|
||||
// Нужна и для конфликтов ниже: слоты одной полосы между собой не конфликтуют.
|
||||
const polosa = useMemo(() => groupStrips(displaySlots, slotKey), [displaySlots]);
|
||||
|
||||
const deferredSelectedKeys = useDeferredValue(selectedKeys);
|
||||
const conflictKeys = useMemo(
|
||||
() =>
|
||||
@@ -346,9 +355,10 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
|
||||
slotKey,
|
||||
(slot) => deferredSelectedKeys.has(slotKey(slot)),
|
||||
(slot) => bookedKeys.has(slotKey(slot)) || slotIsBusy(slot),
|
||||
(slot) => polosa.idByKey.get(slotKey(slot)),
|
||||
)
|
||||
: new Set<string>(),
|
||||
[showConflicts, displaySlots, filteredSlots, deferredSelectedKeys, bookedKeys]
|
||||
[showConflicts, displaySlots, filteredSlots, deferredSelectedKeys, bookedKeys, polosa]
|
||||
);
|
||||
// id полигонов конфликтных слотов — приглушение на карте (параллель markedSlotIds).
|
||||
const dimmedSlotIds = useMemo(
|
||||
@@ -356,14 +366,26 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
|
||||
[conflictKeys]
|
||||
);
|
||||
|
||||
// Разметка полос — над СТАБИЛЬНЫМ набором displaySlots (НЕ filteredSlots/sortedSlots: иначе
|
||||
// фильтр-дырка ложно разорвала или склеила бы полосу). Полоса = один КА + один крен + подряд по
|
||||
// времени (виток не граница). idByKey — id полосы по ключу слота; membersOf — члены полосы по времени.
|
||||
const polosa = useMemo(() => groupStrips(displaySlots, slotKey), [displaySlots]);
|
||||
// Конфликтные слоты нельзя добавлять в выбор под бронь (жёсткая блокировка). Держим текущий набор в
|
||||
// ref, чтобы обработчики жестов (useCallback) читали его без пересоздания на каждый ререндер выбора.
|
||||
const conflictKeysRef = useRef<ReadonlySet<string>>(conflictKeys);
|
||||
conflictKeysRef.current = conflictKeys;
|
||||
// Убирает заблокированные (конфликтные) ключи из нового набора выбора. Уже выбранные слоты в
|
||||
// conflictKeys не попадают, поэтому снятие выбора и прежний выбор не затрагиваются — режутся только
|
||||
// конфликтные слоты, которые жест попытался добавить. Мутирует переданный (свежий) Set.
|
||||
const dropConflicts = useCallback((next: Set<string>): Set<string> => {
|
||||
const blocked = conflictKeysRef.current;
|
||||
if (blocked.size > 0) for (const key of blocked) next.delete(key);
|
||||
return next;
|
||||
}, []);
|
||||
|
||||
// Состояние жеста «выбор полосы мышью на карте» (Shift+drag). Держим в ref: правки идут на каждом
|
||||
// движении указателя, перерисовка не нужна. members — члены полосы якоря без BUSY (их не бронируем).
|
||||
const mapStripRef = useRef<{ anchorKey: string; anchorId: PolosaId; members: string[] } | null>(null);
|
||||
// движении указателя, перерисовка не нужна. members — члены полосы якоря без BUSY (их не бронируем);
|
||||
// endKey — текущий конец (на старте = якорь; меняется в drag) — нужен commit, чтобы отличить
|
||||
// одиночный клик (end === anchor) от протяжки.
|
||||
const mapStripRef = useRef<{ anchorKey: string; anchorId: PolosaId; members: string[]; endKey: string } | null>(null);
|
||||
// Последний конец одиночного Shift-клика на карте: повтор по тому же слоту снимает его (toggle).
|
||||
const lastMapEndRef = useRef<string | null>(null);
|
||||
|
||||
// Shift+нажатие по слотам на карте: якорь полосы = самый ранний по времени выбираемый слот под
|
||||
// курсором. Выбор этой полосы сбрасываем к одному якорю (далее drag растянет), как и в таблице.
|
||||
@@ -373,9 +395,11 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
|
||||
const slot = slotByKey.get(key);
|
||||
return slot ? parseLocal(slot.tn) : Number.NaN;
|
||||
};
|
||||
// Конфликтные слоты в выбор не берём — на них нельзя ни встать якорем, ни попасть в полосу.
|
||||
const blocked = conflictKeysRef.current;
|
||||
const selectable = keys.filter((key) => {
|
||||
const slot = slotByKey.get(key);
|
||||
return slot && !slotIsBusy(slot);
|
||||
return slot && !slotIsBusy(slot) && !blocked.has(key);
|
||||
});
|
||||
const anchor = pickStripAnchor(selectable, tnMsOf);
|
||||
const anchorId = anchor ? polosa.idByKey.get(anchor) : undefined;
|
||||
@@ -385,10 +409,11 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
|
||||
}
|
||||
const members = (polosa.membersOf.get(anchorId) ?? []).filter((key) => {
|
||||
const slot = slotByKey.get(key);
|
||||
return !(slot && slotIsBusy(slot));
|
||||
return !(slot && slotIsBusy(slot)) && !blocked.has(key);
|
||||
});
|
||||
mapStripRef.current = { anchorKey: anchor, anchorId, members };
|
||||
mapStripRef.current = { anchorKey: anchor, anchorId, members, endKey: anchor };
|
||||
setAnchorKey(anchor);
|
||||
lastShiftEndRef.current = null;
|
||||
setSelectedKeys((current) => selectStripRange(current, members, anchor, anchor));
|
||||
},
|
||||
[polosa, slotByKey]
|
||||
@@ -402,13 +427,29 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
|
||||
if (!gesture) return;
|
||||
const end = pickStripEnd(polosa, gesture.anchorId, keys);
|
||||
if (!end) return;
|
||||
gesture.endKey = end;
|
||||
setSelectedKeys((current) => selectStripRange(current, gesture.members, gesture.anchorKey, end));
|
||||
},
|
||||
[polosa]
|
||||
);
|
||||
|
||||
const handleStripCommit = useCallback(() => {
|
||||
const gesture = mapStripRef.current;
|
||||
mapStripRef.current = null;
|
||||
if (!gesture) return;
|
||||
// Одиночный Shift+клик (без протяжки): end === anchor. Повтор по тому же слоту снимает его
|
||||
// (anchor на pointerdown уже переотметил его — на commit гасим). Протяжку (диапазон) не тогглим.
|
||||
const isClick = gesture.endKey === gesture.anchorKey;
|
||||
if (isClick && lastMapEndRef.current === gesture.anchorKey) {
|
||||
setSelectedKeys((current) => {
|
||||
const next = new Set(current);
|
||||
next.delete(gesture.anchorKey);
|
||||
return next;
|
||||
});
|
||||
lastMapEndRef.current = null;
|
||||
} else {
|
||||
lastMapEndRef.current = isClick ? gesture.anchorKey : null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Якорь жеста привязан к ВИДИМОМУ набору: смена фильтров/окна суток/брони делает прежний якорь
|
||||
@@ -416,6 +457,8 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
|
||||
useEffect(() => {
|
||||
setAnchorKey(null);
|
||||
mapStripRef.current = null;
|
||||
lastShiftEndRef.current = null;
|
||||
lastMapEndRef.current = null;
|
||||
}, [filteredSlots]);
|
||||
|
||||
// Загрузка заявок интервала (на карту); вызывается на входе, при смене диапазона и из фильтра.
|
||||
@@ -609,6 +652,12 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
|
||||
if (index >= 0) rowVirtualizer.scrollToIndex(index, { align: "center" });
|
||||
}, [activeSlotId, sortedSlots, rowVirtualizer]);
|
||||
|
||||
// Переход на прохождение прокручивает таблицу к началу (без подсветки строки): оператор видит
|
||||
// первый слот изолированного прохождения. Только при выбранном проходе — иначе скролл не трогаем.
|
||||
useEffect(() => {
|
||||
if (effPassCursor >= 0) rowVirtualizer.scrollToIndex(0, { align: "start" });
|
||||
}, [effPassCursor, rowVirtualizer]);
|
||||
|
||||
// Загружает брони выбранной заявки (для бейджей и слоя карты).
|
||||
const loadBooked = useCallback(async (requestId: string) => {
|
||||
try {
|
||||
@@ -894,15 +943,31 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
|
||||
const slot = slotByKey.get(k);
|
||||
return !(slot && slotIsBusy(slot));
|
||||
});
|
||||
setSelectedKeys((current) => selectStripRange(current, members, anchorKey, targetKey));
|
||||
if (lastShiftEndRef.current === targetKey) {
|
||||
// Повторный Shift+клик по тому же концу — снимаем выделенный диапазон [якорь..конец].
|
||||
setSelectedKeys((current) => deselectStripRange(current, members, anchorKey, targetKey));
|
||||
lastShiftEndRef.current = null;
|
||||
} else {
|
||||
// Конфликтные члены полосы в выбор не берём (dropConflicts) — их выбрать нельзя.
|
||||
setSelectedKeys((current) => dropConflicts(selectStripRange(current, members, anchorKey, targetKey)));
|
||||
lastShiftEndRef.current = targetKey;
|
||||
}
|
||||
// Якорь НЕ двигаем: остаётся первым кликом, чтобы повторный Shift+клик задавал диапазон
|
||||
// от того же якоря — ближе к нему «сжимал» выделение, дальше — расширял.
|
||||
return;
|
||||
}
|
||||
// Обычный клик по конфликтному слоту — выбрать нельзя (снять выбор не блокируем: конфликтные
|
||||
// слоты в выбор не попадают, значит targetKey тут заведомо не выбран).
|
||||
if (conflictKeysRef.current.has(targetKey)) {
|
||||
setNotice("Слот конфликтует с выбором (не хватает времени на манёвр того же КА) — выбрать нельзя.");
|
||||
return;
|
||||
}
|
||||
setSelectedKeys((current) =>
|
||||
selectionAfterCheck(current, (i) => slotKey(sortedSlots[i]), index, false, null)
|
||||
);
|
||||
setAnchorKey(targetKey);
|
||||
lastShiftEndRef.current = null;
|
||||
lastMapEndRef.current = null;
|
||||
};
|
||||
|
||||
// Чекбокс шапки — выбрать/снять все ВИДИМЫЕ (по окну суток) выбираемые слоты. BUSY (занятые
|
||||
@@ -913,16 +978,22 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
|
||||
const toggleAllVisible = () => {
|
||||
setSelectedKeys((current) => {
|
||||
const next = new Set(current);
|
||||
if (allVisibleSelected) for (const slot of selectableVisible) next.delete(slotKey(slot));
|
||||
else for (const slot of selectableVisible) next.add(slotKey(slot));
|
||||
if (allVisibleSelected) {
|
||||
for (const slot of selectableVisible) next.delete(slotKey(slot));
|
||||
} else {
|
||||
for (const slot of selectableVisible) next.add(slotKey(slot));
|
||||
dropConflicts(next); // конфликтные видимые слоты не добавляем
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setAnchorKey(null);
|
||||
};
|
||||
|
||||
// Выбрать все слоты расчёта (по всем суткам, кроме занятых бронью), снять весь выбор.
|
||||
// Выбрать все слоты расчёта (по всем суткам, кроме занятых бронью и конфликтных видимых), снять весь
|
||||
// выбор. Конфликты считаются по видимому набору, поэтому режем известные (dropConflicts) — бэкенд
|
||||
// остаётся финальным арбитром выполнимости.
|
||||
const selectAllDays = () =>
|
||||
setSelectedKeys(new Set(displaySlots.filter((slot) => !slotIsBusy(slot)).map(slotKey)));
|
||||
setSelectedKeys(dropConflicts(new Set(displaySlots.filter((slot) => !slotIsBusy(slot)).map(slotKey))));
|
||||
const clearSelection = () => {
|
||||
setSelectedKeys(new Set());
|
||||
setAnchorKey(null);
|
||||
@@ -965,9 +1036,10 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
|
||||
focusOn({ lat: slot.latitude, lon: slot.longitude });
|
||||
};
|
||||
|
||||
// Переход на прохождение по индексу: ставит курсор, подтягивает окно суток к проходу (сохраняя
|
||||
// ширину), скроллит таблицу к первой строке и подсвечивает проход. Карту НЕ двигаем — оператор
|
||||
// листает прохождения, не теряя текущий обзор/зум; центрирование осталось только на клике по слоту.
|
||||
// Переход на прохождение по индексу: ставит курсор и подтягивает окно суток к проходу (сохраняя
|
||||
// ширину). Никакой слот НЕ подсвечиваем — само прохождение изолирует таблицу, а активная подсветка
|
||||
// одной строки появляется только по явному клику оператора. Карту НЕ двигаем — оператор листает
|
||||
// прохождения, не теряя текущий обзор/зум; центрирование осталось только на клике по слоту.
|
||||
const goToPass = useCallback(
|
||||
(target: number) => {
|
||||
if (passes.length === 0) return;
|
||||
@@ -989,12 +1061,11 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Скролл таблицы к проходу — через тот же activeSlotId-эффект, что и клик по слоту, но без
|
||||
// focusOn: карту оставляем на месте (правка «карта не прыгает при работе с фильтром»).
|
||||
const firstKey = pass.keys[0];
|
||||
if (firstKey && slotByKey.has(firstKey)) setActiveSlotId(`slot:${firstKey}`);
|
||||
// Снимаем активную подсветку строки с прошлого прохождения: при переходе не должен светиться
|
||||
// никакой слот (прокрутку к началу делает отдельный эффект по effPassCursor, без подсветки).
|
||||
setActiveSlotId(undefined);
|
||||
},
|
||||
[passes, days, effDayFrom, effDayTo, slotByKey]
|
||||
[passes, days, effDayFrom, effDayTo]
|
||||
);
|
||||
|
||||
// ◀ из «не выбран» входит с конца, ▶ — с начала; дальше клампим в [0, last].
|
||||
@@ -1349,6 +1420,8 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
|
||||
available={coverageGrid !== null}
|
||||
coverageOn={coverageOn}
|
||||
onToggleCoverage={setCoverageOn}
|
||||
showConflicts={showConflicts}
|
||||
onToggleConflicts={setShowConflicts}
|
||||
/>
|
||||
)}
|
||||
<div className="tgu-slots-selbar">
|
||||
@@ -1365,17 +1438,6 @@ 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"
|
||||
|
||||
@@ -83,4 +83,21 @@ describe("computeConflicts (зазор на манёвр)", () => {
|
||||
const res = computeConflicts(arr, arr, keyOf, selectedIsA, (s) => s.state === "BUSY");
|
||||
expect(res.has("1:9")).toBe(false);
|
||||
});
|
||||
|
||||
it("слоты ОДНОЙ полосы между собой не конфликтуют (groupOf)", () => {
|
||||
const sameStrip = slot(1, 2, 38, 60, 10); // зазор 8 с, тот же крен — конфликт по умолчанию
|
||||
const arr = [A, sameStrip];
|
||||
// Без groupOf — конфликт.
|
||||
expect(computeConflicts(arr, arr, keyOf, selectedIsA, notBlocked).has("1:2")).toBe(true);
|
||||
// A и sameStrip в одной полосе — конфликт снимается.
|
||||
const sameGroup = () => "polosa:1";
|
||||
expect(computeConflicts(arr, arr, keyOf, selectedIsA, notBlocked, sameGroup).has("1:2")).toBe(false);
|
||||
});
|
||||
|
||||
it("groupOf: разные полосы того же КА по-прежнему конфликтуют", () => {
|
||||
const other = slot(1, 2, 38, 60, 10); // зазор 8 с, тот же крен
|
||||
const arr = [A, other];
|
||||
const groupOf = (s: S) => (s.n === 1 ? "polosa:a" : "polosa:b");
|
||||
expect(computeConflicts(arr, arr, keyOf, selectedIsA, notBlocked, groupOf).has("1:2")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,9 +5,13 @@
|
||||
// Меньший зазор (вплоть до пересечения) → конфликт. «Крен меняется» = расхождение крена > ROLL_EPSILON
|
||||
// (тот же допуск, по которому слоты кластеризуются в полосы).
|
||||
//
|
||||
// Это ВИЗУАЛЬНЫЙ намёк, не проверка выполнимости: реальная физика (профиль slew, maxChainLength,
|
||||
// maxSurveyDuration) живёт в slots-service по-КА и проверяется бэкенд-эндпоинтом (итерация 2).
|
||||
// Поэтому конфликтные слоты остаются ВЫБИРАЕМЫМИ — мы лишь гасим их, решение за оператором.
|
||||
// Это ПРИБЛИЖЁННАЯ клиентская оценка, не полная проверка выполнимости: реальная физика (профиль slew,
|
||||
// maxChainLength, maxSurveyDuration) живёт в slots-service по-КА и проверяется бэкенд-эндпоинтом
|
||||
// (итерация 2). Слоты ОДНОЙ полосы (одна непрерывная съёмка) между собой не конфликтуют — см. groupOf.
|
||||
//
|
||||
// Конфликтные слоты не только гаснут, но и НЕ ДОБАВЛЯЮТСЯ в выбор под бронь (жёсткая блокировка на
|
||||
// стороне UI). Уже выбранные слоты не трогаем (конфликт считается только для НЕвыбранных кандидатов),
|
||||
// поэтому снять выбор можно всегда. Окончательный арбитр выполнимости — бэкенд.
|
||||
|
||||
import type { SlotDTO } from "../../model/slotTypes"
|
||||
import { parseLocal } from "../../utils/localTime"
|
||||
@@ -19,8 +23,8 @@ 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 }
|
||||
/** Выбранный интервал слота: окно [start,end) (мс), крен и id полосы — для выбора нужного зазора. */
|
||||
type SelInterval = { start: number; end: number; roll: number; group?: string }
|
||||
|
||||
/** Занятые интервалы выбранных слотов одного КА: отсортированы по началу + макс. длительность. */
|
||||
type SatSelection = { intervals: SelInterval[]; maxDur: number }
|
||||
@@ -62,6 +66,9 @@ function lowerBound(intervals: SelInterval[], target: number): number {
|
||||
* @param keyOf Аксессор ключа слота.
|
||||
* @param isSelected Входит ли слот в текущий набор под бронь.
|
||||
* @param isBlocked Бронь/BUSY — такой слот не помечаем как конфликт (у него своя индикация).
|
||||
* @param groupOf Id полосы слота (одна непрерывная съёмка): слоты ОДНОЙ полосы между собой не
|
||||
* конфликтуют (это один манёвр, без перенацеливания), поэтому такую пару пропускаем. Без аксессора
|
||||
* полосы не учитываются.
|
||||
*/
|
||||
export function computeConflicts<T extends Conflictable>(
|
||||
source: readonly T[],
|
||||
@@ -69,6 +76,7 @@ export function computeConflicts<T extends Conflictable>(
|
||||
keyOf: (slot: T) => string,
|
||||
isSelected: (slot: T) => boolean,
|
||||
isBlocked: (slot: T) => boolean,
|
||||
groupOf?: (slot: T) => string | undefined,
|
||||
): Set<string> {
|
||||
const bySat = new Map<number, SatSelection>()
|
||||
for (const slot of source) {
|
||||
@@ -81,7 +89,7 @@ export function computeConflicts<T extends Conflictable>(
|
||||
bucket = { intervals: [], maxDur: 0 }
|
||||
bySat.set(slot.satelliteId, bucket)
|
||||
}
|
||||
bucket.intervals.push({ start, end, roll: slot.roll })
|
||||
bucket.intervals.push({ start, end, roll: slot.roll, group: groupOf?.(slot) })
|
||||
bucket.maxDur = Math.max(bucket.maxDur, end - start)
|
||||
}
|
||||
|
||||
@@ -96,12 +104,15 @@ export function computeConflicts<T extends Conflictable>(
|
||||
const start = parseLocal(slot.tn)
|
||||
const end = parseLocal(slot.tk)
|
||||
if (!Number.isFinite(start) || !Number.isFinite(end)) continue
|
||||
const group = groupOf?.(slot)
|
||||
// Конфликтовать может только выбранный интервал в окне по времени: его 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 (group !== undefined && ints[i].group === group) continue
|
||||
if (conflictsPair(start, end, slot.roll, ints[i])) {
|
||||
conflicts.add(keyOf(slot))
|
||||
break
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { chunk, selectionAfterCheck, selectStripRange } from "./slotSelection";
|
||||
import { chunk, deselectStripRange, selectionAfterCheck, selectStripRange } from "./slotSelection";
|
||||
|
||||
const keyAt = (i: number) => `k${i}`;
|
||||
|
||||
@@ -91,6 +91,34 @@ describe("selectStripRange", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("deselectStripRange", () => {
|
||||
const members = ["a", "b", "c", "d"]; // в порядке времени
|
||||
|
||||
it("снимает ровно члены диапазона, остальной выбор не трогает", () => {
|
||||
const current = new Set(["a", "b", "c", "d", "x"]);
|
||||
const next = deselectStripRange(current, members, "b", "d");
|
||||
expect([...next].sort()).toEqual(["a", "x"]);
|
||||
});
|
||||
|
||||
it("повторный Shift+клик по тому же концу снимает то, что выделил selectStripRange", () => {
|
||||
const selected = selectStripRange(new Set(["x"]), members, "a", "c"); // a,b,c,x
|
||||
const cleared = deselectStripRange(selected, members, "a", "c");
|
||||
expect([...cleared].sort()).toEqual(["x"]);
|
||||
});
|
||||
|
||||
it("работает при обратном порядке (toKey раньше fromKey)", () => {
|
||||
const next = deselectStripRange(new Set(["a", "b", "c", "d"]), members, "d", "b");
|
||||
expect([...next].sort()).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("ключ вне полосы → выбор без изменений; вход не мутируется", () => {
|
||||
const current = new Set(["a", "b"]);
|
||||
const next = deselectStripRange(current, members, "a", "zzz");
|
||||
expect([...next].sort()).toEqual(["a", "b"]);
|
||||
expect([...current].sort()).toEqual(["a", "b"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("chunk", () => {
|
||||
it("бьёт массив на части заданного размера, последняя — остаток", () => {
|
||||
expect(chunk([1, 2, 3, 4, 5], 2)).toEqual([[1, 2], [3, 4], [5]]);
|
||||
|
||||
@@ -72,6 +72,32 @@ export function selectStripRange(
|
||||
return next
|
||||
}
|
||||
|
||||
/**
|
||||
* Снимает выбор с диапазона полосы [fromKey..toKey] (в порядке времени). Зеркало selectStripRange:
|
||||
* убирает ровно члены диапазона, не трогая остальной выбор. Нужно для повторного Shift+клика по тому
|
||||
* же концу — он снимает выделенный диапазон. Если fromKey/toKey не из members — выбор не меняется.
|
||||
*
|
||||
* @param current Текущее множество выбранных ключей.
|
||||
* @param members Ключи членов полосы в порядке времени.
|
||||
* @param fromKey Ключ якоря (первый клик).
|
||||
* @param toKey Ключ второго клика.
|
||||
*/
|
||||
export function deselectStripRange(
|
||||
current: ReadonlySet<string>,
|
||||
members: readonly string[],
|
||||
fromKey: string,
|
||||
toKey: string,
|
||||
): Set<string> {
|
||||
const next = new Set(current)
|
||||
const from = members.indexOf(fromKey)
|
||||
const to = members.indexOf(toKey)
|
||||
if (from < 0 || to < 0) return next
|
||||
const lo = Math.min(from, to)
|
||||
const hi = Math.max(from, to)
|
||||
for (let i = lo; i <= hi; i++) next.delete(members[i])
|
||||
return next
|
||||
}
|
||||
|
||||
/**
|
||||
* Разбивает массив на чанки заданного размера (для чанкованной брони крупных наборов).
|
||||
* @param items Исходный массив.
|
||||
|
||||
@@ -960,19 +960,7 @@
|
||||
|
||||
.tgu-slots-selbar__actions {
|
||||
display: flex;
|
||||
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;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
/* Панель покрытия над списком (D-D1). Цвет = слой coverage на карте (violet) — единый язык «покрыто». */
|
||||
@@ -996,6 +984,12 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tgu-coverage__toggles {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.tgu-coverage__toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -1003,6 +997,7 @@
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tgu-coverage__toggle input {
|
||||
|
||||
Reference in New Issue
Block a user