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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user