feat(tgu-ui): запрет выбора конфликтных слотов + прохождение без подсветки

- конфликтные слоты нельзя добавить в выбор под бронь (клик по строке,
  Shift-диапазон, выбор полосы на карте, «выбрать все») — жёсткая блокировка;
  снятие выбора не блокируется (конфликт считается только для невыбранных)
- слоты одной полосы между собой не конфликтуют (groupOf) — иначе нельзя было
  бы набрать полосу поштучным кликом
- чекбокс «Конфликты» теперь и гасит, и блокирует выбор; выкл. — выбирать любые
- переход на прохождение (◀/▶) больше не подсвечивает никакой слот: активная
  подсветка строки — только по явному клику; добавлена прокрутка к началу прохода
- повторный Shift+клик по тому же концу снимает выделение (таблица и карта)
This commit is contained in:
Дмитрий Соловьев
2026-06-17 14:07:03 +03:00
parent c402f376c5
commit 354730fb6c
7 changed files with 220 additions and 63 deletions
@@ -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 {