feat(tgu-ui): запрет выбора конфликтных слотов + прохождение без подсветки
- конфликтные слоты нельзя добавить в выбор под бронь (клик по строке, Shift-диапазон, выбор полосы на карте, «выбрать все») — жёсткая блокировка; снятие выбора не блокируется (конфликт считается только для невыбранных) - слоты одной полосы между собой не конфликтуют (groupOf) — иначе нельзя было бы набрать полосу поштучным кликом - чекбокс «Конфликты» теперь и гасит, и блокирует выбор; выкл. — выбирать любые - переход на прохождение (◀/▶) больше не подсвечивает никакой слот: активная подсветка строки — только по явному клику; добавлена прокрутка к началу прохода - повторный Shift+клик по тому же концу снимает выделение (таблица и карта)
This commit is contained in:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user