feat(tgu-ui): выбор полосы слотов мышью «от первого к последнему»

Замена авто-чипа «полоса ×N» на ручной жест. Полоса переопределена:
один КА + один крен (ε) + подряд по времени; виток в границу не входит.

- slotStrips.ts: groupStrips → { idByKey, membersOf }; группировка по КА →
  кластеры по крену (ROLL_EPSILON=0.5) → деление по каденции (CADENCE_GAP_FACTOR=1.5)
- slotSelection.ts: selectStripRange вместо toggleStripSelection
- TguSlotsTab.tsx: клик по строке = якорь + выбор; Shift+клик тянет полосу
  между якорем и строкой; выход за полосу запрещён + подсказка; рельс полосы якоря
- tgu-planning.css: убраны стили чипа/hover; user-select:none на строках
- тесты обновлены (125 зелёных), tsc/build чисто
This commit is contained in:
Дмитрий Соловьев
2026-06-17 10:13:11 +03:00
parent 8faaa03af3
commit b092e50dd8
7 changed files with 322 additions and 296 deletions
@@ -38,7 +38,7 @@ import {
type SlotMask
} from "./coverage";
import { applySlotFilters, slotStatusKey, type SlotStatusKey } from "./slotFilters";
import { selectionAfterCheck, toggleStripSelection } from "./slotSelection";
import { selectionAfterCheck, selectStripRange } from "./slotSelection";
import { groupStrips } from "./slotStrips";
import { runChunked } from "./runChunked";
import { ChunkProgress } from "./ChunkProgress";
@@ -140,14 +140,12 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
const wktCacheRef = useRef(new Map<string, GeoPoint[][]>());
// Контроллер отмены текущего расчёта/автоподбора — прерывается при повторе или смене заявки.
const abortRef = useRef<AbortController | null>(null);
// Якорь для выбора диапазона по Shift+клику (индекс в filteredSlots).
const anchorIndexRef = useRef<number | null>(null);
// Якорь жеста «полоса от первого к последнему»: ключ первого кликнутого слота. Состояние (не ref):
// по нему подсвечиваем строки той же полосы, чтобы оператор видел границу выделения.
const [anchorKey, setAnchorKey] = useState<string | null>(null);
// Подсветка строки/контура по клику на карте и центрирование на слоте.
const [activeSlotId, setActiveSlotId] = useState<string>();
// Полоса под курсором — для подсветки всех её членов по StripId (актуально в сортировках
// «крен»/«вклад», где члены полосы не идут подряд). null — курсор вне полос.
const [hoveredStrip, setHoveredStrip] = useState<string | null>(null);
const [focusPoint, setFocusPoint] = useState<GeoPoint>();
const focusTokenRef = useRef(0);
const [focusToken, setFocusToken] = useState(0);
@@ -295,34 +293,15 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
[dayWindowed, statusFilter, satFilter, branchFilter, bookedKeys],
);
// Идентичность полос — над СТАБИЛЬНЫМ набором displaySlots (НЕ filteredSlots/sortedSlots:
// иначе фильтр-дырка ложно разорвала или склеила бы полосу). Map<slotKey, StripId>.
const stripIdByKey = useMemo(() => groupStrips(displaySlots, slotKey), [displaySlots]);
// Разметка полос — над СТАБИЛЬНЫМ набором displaySlots (НЕ filteredSlots/sortedSlots: иначе
// фильтр-дырка ложно разорвала или склеила бы полосу). Полоса = один КА + один крен + подряд по
// времени (виток не граница). idByKey — id полосы по ключу слота; membersOf — члены полосы по времени.
const polosa = useMemo(() => groupStrips(displaySlots, slotKey), [displaySlots]);
// По каждой видимой полосе: число видимых членов (N для чипа) и ключи видимых ВЫБИРАЕМЫХ
// (не-BUSY) членов — цель выбора. Считаем над filteredSlots: выбираем/считаем только видимое.
const stripInfo = useMemo(() => {
const visibleCount = new Map<string, number>();
const selectableKeys = new Map<string, string[]>();
for (const slot of filteredSlots) {
const key = slotKey(slot);
const strip = stripIdByKey.get(key);
if (!strip) continue;
visibleCount.set(strip, (visibleCount.get(strip) ?? 0) + 1);
if (!slotIsBusy(slot)) {
const arr = selectableKeys.get(strip) ?? [];
arr.push(key);
selectableKeys.set(strip, arr);
}
}
return { visibleCount, selectableKeys };
}, [filteredSlots, stripIdByKey]);
// Якорь Shift-выбора — индекс в видимом списке. Любая смена видимого набора (фильтры
// статус/КА/ветвь, окно суток, бронь) делает индекс невалидным → сбрасываем (как при смене
// сортировки). Иначе Shift-диапазон после фильтрации выбрал бы не те строки.
// Якорь жеста привязан к ВИДИМОМУ набору: смена фильтров/окна суток/брони делает прежний якорь
// неактуальным → сбрасываем (как при смене сортировки), иначе Shift выделил бы не ту полосу.
useEffect(() => {
anchorIndexRef.current = null;
setAnchorKey(null);
}, [filteredSlots]);
// Загрузка заявок интервала (на карту); вызывается на входе, при смене диапазона и из фильтра.
@@ -532,7 +511,7 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
useEffect(() => {
abortRef.current?.abort();
wktCacheRef.current.clear();
anchorIndexRef.current = null;
setAnchorKey(null);
setSlots([]);
setSelectedKeys(new Set());
setBookedSlots([]);
@@ -579,7 +558,7 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
abortRef.current?.abort();
const ctrl = new AbortController();
abortRef.current = ctrl;
anchorIndexRef.current = null;
setAnchorKey(null);
if (auto) setAutoFilling(true);
else setCalculating(true);
setError(undefined);
@@ -782,13 +761,33 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
};
const cancelPendingCancel = () => setPendingCancel(false);
// Отметка слота из таблицы. Shift+клик — выбрать диапазон от якоря (предыдущего клика) до
// текущей строки; направление (выбрать/снять) задаётся новым состоянием текущей строки.
// Отметка слота из таблицы.
// • Обычный клик — переключает один слот и ставит его якорем полосы.
// • Shift+клик — жест «полоса от первого к последнему»: выделяет всех членов ОДНОЙ полосы между
// якорем и текущим слотом (включительно). Если текущий слот из другой полосы (другой КА/крен/
// разрыв по времени) — выбор не меняем и подсказываем: выйти за полосу нельзя.
const handleSlotCheck = (index: number, shiftKey: boolean) => {
const targetKey = slotKey(sortedSlots[index]);
if (shiftKey && anchorKey) {
const anchorId = polosa.idByKey.get(anchorKey);
const targetId = polosa.idByKey.get(targetKey);
if (!anchorId || anchorId !== targetId) {
setNotice("Выделить можно только в пределах одной полосы (один КА, один крен, подряд по времени).");
return; // якорь оставляем — оператор может выбрать другой конец той же полосы
}
// Члены полосы по времени, без BUSY (их бронировать нельзя — в выделение не берём).
const members = (polosa.membersOf.get(anchorId) ?? []).filter((k) => {
const slot = slotByKey.get(k);
return !(slot && slotIsBusy(slot));
});
setSelectedKeys((current) => selectStripRange(current, members, anchorKey, targetKey));
setAnchorKey(targetKey);
return;
}
setSelectedKeys((current) =>
selectionAfterCheck(current, (i) => slotKey(sortedSlots[i]), index, shiftKey, anchorIndexRef.current)
selectionAfterCheck(current, (i) => slotKey(sortedSlots[i]), index, false, null)
);
anchorIndexRef.current = index;
setAnchorKey(targetKey);
};
// Чекбокс шапки — выбрать/снять все ВИДИМЫЕ (по окну суток) выбираемые слоты. BUSY (занятые
@@ -803,15 +802,7 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
else for (const slot of selectableVisible) next.add(slotKey(slot));
return next;
});
anchorIndexRef.current = null;
};
// Чип «полоса ×N» — выбрать/снять всю полосу: зеркалит toggleAllVisible, но в пределах одной
// полосы (только видимые не-BUSY члены). Сбрасываем якорь Shift — позиции выбора изменились.
const toggleStrip = (stripId: string) => {
const memberKeys = stripInfo.selectableKeys.get(stripId);
if (!memberKeys || memberKeys.length === 0) return;
setSelectedKeys((current) => toggleStripSelection(current, memberKeys));
anchorIndexRef.current = null;
setAnchorKey(null);
};
// Выбрать все слоты расчёта (по всем суткам, кроме занятых бронью), снять весь выбор.
@@ -819,7 +810,7 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
setSelectedKeys(new Set(displaySlots.filter((slot) => !slotIsBusy(slot)).map(slotKey)));
const clearSelection = () => {
setSelectedKeys(new Set());
anchorIndexRef.current = null;
setAnchorKey(null);
};
// Тогглы фильтров видимости (selected = visible: клик добавляет/убирает значение из множества).
@@ -832,7 +823,7 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
// Смена сортировки меняет позиции строк — якорь Shift-выбора больше не валиден, сбрасываем.
const changeSort = (mode: SlotSortMode) => {
setSortMode(mode);
anchorIndexRef.current = null;
setAnchorKey(null);
};
const toggleStatus = (key: SlotStatusKey) => setStatusFilter((prev) => toggleInSet(prev, key));
const toggleSat = (id: number) => setSatFilter((prev) => toggleInSet(prev, id));
@@ -1213,6 +1204,9 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
</span>
)}
</span>
<span className="tgu-slots-selbar__sub" title="Кликните первый слот, затем Shift+клик по последнему — выделится полоса между ними. Выйти за полосу (другой КА/крен/разрыв по времени) нельзя.">
Полоса: клик + Shift+клик
</span>
<div className="tgu-slots-selbar__actions">
<button
className="tgu-button tgu-button--sm"
@@ -1291,23 +1285,18 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
const id = `slot:${key}`;
const booked = bookedKeys.has(key);
const isActive = activeSlotId === id;
// Полоса слота: N видимых членов, ключи видимых выбираемых членов.
const stripId = stripIdByKey.get(key);
const stripN = stripId ? stripInfo.visibleCount.get(stripId) ?? 0 : 0;
const stripSelectable = stripId
? stripInfo.selectableKeys.get(stripId) ?? []
: [];
const isStrip = stripN >= 2;
const showStripChip = isStrip && stripSelectable.length > 0;
const stripAllSelected =
showStripChip && stripSelectable.every((k) => selectedKeys.has(k));
// Рельс — только в сортировке «время» (там члены полосы идут подряд);
// в «крен»/«вклад» — подсветка по StripId при наведении.
const showRail = isStrip && sortMode === "time";
// Подсветка полосы текущего якоря: оператор видит, что выделит Shift+клик,
// и что за границу полосы (другой КА/крен/разрыв) выйти нельзя.
const polosaId = polosa.idByKey.get(key);
const inAnchorPolosa =
anchorKey != null &&
polosaId != null &&
polosaId === polosa.idByKey.get(anchorKey);
const isAnchorRow = anchorKey === key;
const rowClass = [
isActive ? "is-active" : "",
isStrip && hoveredStrip === stripId ? "is-strip-hover" : "",
showRail ? "tgu-slots-row--strip" : "",
inAnchorPolosa ? "tgu-slots-row--strip" : "",
isAnchorRow ? "is-strip-anchor" : "",
]
.filter(Boolean)
.join(" ");
@@ -1317,14 +1306,18 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
data-slot-id={id}
style={{
height: ROW_HEIGHT,
...(showRail && stripId
? { ["--strip-hue" as string]: String(stripHue(stripId)) }
...(inAnchorPolosa && polosaId
? { ["--strip-hue" as string]: String(stripHue(polosaId)) }
: {}),
}}
className={rowClass || undefined}
onClick={() => selectRow(slot)}
onMouseEnter={isStrip ? () => setHoveredStrip(stripId!) : undefined}
onMouseLeave={isStrip ? () => setHoveredStrip(null) : undefined}
onClick={(event) => {
// Клик по строке = и подсветка на карте, и жест выбора полосы:
// обычный клик выбирает слот + ставит якорь, Shift+клик тянет полосу
// до этой строки. BUSY не выбираем (бронировать нельзя).
selectRow(slot);
if (!slotIsBusy(slot)) handleSlotCheck(vrow.index, event.shiftKey);
}}
>
<td onClick={(event) => event.stopPropagation()}>
<input
@@ -1337,33 +1330,7 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
aria-label="Выбрать слот"
/>
</td>
<td>
<span className="tgu-slots-sat">
{slot.satelliteId}
{showStripChip && (
<button
type="button"
className={
stripAllSelected
? "tgu-slots-strip-chip is-on"
: "tgu-slots-strip-chip"
}
title={
stripAllSelected
? "Снять выбор полосы (слоты одного крена подряд)"
: "Выбрать полосу — слоты одного крена подряд"
}
aria-label="Выбрать полосу"
onClick={(event) => {
event.stopPropagation();
toggleStrip(stripId!);
}}
>
полоса ×{stripN}
</button>
)}
</span>
</td>
<td>{slot.satelliteId}</td>
<td>
{slot.revolution} / {slot.revolutionSign === "DESC" ? "нисх" : "восх"} /{" "}
{slot.cycle}