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
+29 -10
View File
@@ -90,16 +90,35 @@
| disabled chip | N<2 или 0 выбираемых членов | чипа нет | | disabled chip | N<2 или 0 выбираемых членов | чипа нет |
| booked/occupied | как есть (бейдж) | не меняется; в полосу входит визуально, в выбор — нет | | booked/occupied | как есть (бейдж) | не меняется; в полосу входит визуально, в выбор — нет |
## Реализовано (it.1, 2026-06-17) ## Реализовано (it.1, 2026-06-17 — пересмотрено)
- `slotStrips.ts` (`groupStrips`, `ROLL_EPSILON=0.01`, якорный крен, тотальный порядок) +
`slotStrips.test.ts` (11 кейсов: мультиполоса, два КА, граница ε, дрейф крена, порядок, ВАЖНО: первая версия it.1 (авто-чип «полоса ×N» по равному крену) оказалась не той фичей и
tie-break, NaN `tn`, пустой вход). переделана. На реальных данных полоса по «подряд по времени + равный крен» не формировалась
- `toggleStripSelection` в `slotSelection.ts` (чистый, без мутации) + тесты. (в один момент идёт «веер» кренов с шагом ~3°, равный крен повторяется не подряд, а вдоль трассы
- `TguSlotsTab.tsx`: `stripIdByKey` (memo над `displaySlots`), `stripInfo` (видимые члены / через каденцию). Оператор хотел не авто-магию, а ручной выбор полосы мышью «от первого к последнему»
выбираемые не-BUSY по полосе), чип `полоса ×N` в ячейке «КА» (активен при всех выбранных), с запретом выйти за полосу.
рельс по `StripId` (sortMode `time`), подсветка членов по `StripId` при наведении.
- Смежный баг закрыт: `anchorIndexRef` сбрасывается при смене видимого набора (`filteredSlots`). Переопределение полосы (согласовано с оператором):
- Тесты 123 зелёных, `tsc -b` чисто, `vite build` ок. Бэкенд/DTO не тронуты. - **полоса = один КА + один крен (допуск `ROLL_EPSILON`) + подряд по времени (без разрыва каденции);**
- **виток/цикл в границу НЕ входят** — у экватора номер витка меняется, а слоты остаются в одной полосе.
Сделано:
- `slotStrips.ts`: `groupStrips(slots, keyOf) → { idByKey, membersOf }`. Группировка: по КА →
кластеризация по крену (`ROLL_EPSILON=0.5`, разрыв > ε начинает кластер) → внутри кластера деление
по непрерывности времени (`CADENCE_GAP_FACTOR=1.5` от медианной каденции). `PolosaId =
polosa:${ключ первого по времени члена}`; `membersOf` — члены в порядке времени.
- `slotSelection.ts`: вместо `toggleStripSelection` — `selectStripRange(current, members, fromKey,
toKey)` (чистая: добавляет членов полосы между двумя кликами включительно, без мутации).
- `TguSlotsTab.tsx`: убран чип/hover/`stripInfo`. Жест: обычный клик ставит якорь; Shift+клик по
слоту той же полосы выделяет членов между якорем и кликом (BUSY исключаются); Shift+клик по слоту
другой полосы/КА — выбор не меняется + подсказка «только в пределах одной полосы». Якорь — состояние
(`anchorKey`), строки полосы якоря подсвечены рельсом (оттенок по `PolosaId`); сбрасывается при
смене заявки/сортировки/фильтров/расчёта. Подсказка жеста в selbar.
- `tgu-planning.css`: удалены стили чипа/hover; рельс `tgu-slots-row--strip` переиспользован под
полосу якоря, добавлен `is-strip-anchor`.
- Калибровка `ROLL_EPSILON=0.5` / `CADENCE_GAP_FACTOR=1.5` — по скрину данных; при ручном прогоне
возможна подстройка по фактическим `roll`/`tn`.
- Бэкенд/DTO не тронуты. Правило выполнимости (манёвренность КА) по-прежнему it.2.
## Решение — Iteration 2 (проверка выполнимости, отдельно) ## Решение — Iteration 2 (проверка выполнимости, отдельно)
- Бэкенд: read-only `POST /api/slots/booking/validate` (набор слотов → violations), - Бэкенд: read-only `POST /api/slots/booking/validate` (набор слотов → violations),
@@ -38,7 +38,7 @@ import {
type SlotMask type SlotMask
} from "./coverage"; } from "./coverage";
import { applySlotFilters, slotStatusKey, type SlotStatusKey } from "./slotFilters"; import { applySlotFilters, slotStatusKey, type SlotStatusKey } from "./slotFilters";
import { selectionAfterCheck, toggleStripSelection } from "./slotSelection"; import { selectionAfterCheck, selectStripRange } from "./slotSelection";
import { groupStrips } from "./slotStrips"; import { groupStrips } from "./slotStrips";
import { runChunked } from "./runChunked"; import { runChunked } from "./runChunked";
import { ChunkProgress } from "./ChunkProgress"; import { ChunkProgress } from "./ChunkProgress";
@@ -140,14 +140,12 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
const wktCacheRef = useRef(new Map<string, GeoPoint[][]>()); const wktCacheRef = useRef(new Map<string, GeoPoint[][]>());
// Контроллер отмены текущего расчёта/автоподбора — прерывается при повторе или смене заявки. // Контроллер отмены текущего расчёта/автоподбора — прерывается при повторе или смене заявки.
const abortRef = useRef<AbortController | null>(null); const abortRef = useRef<AbortController | null>(null);
// Якорь для выбора диапазона по Shift+клику (индекс в filteredSlots). // Якорь жеста «полоса от первого к последнему»: ключ первого кликнутого слота. Состояние (не ref):
const anchorIndexRef = useRef<number | null>(null); // по нему подсвечиваем строки той же полосы, чтобы оператор видел границу выделения.
const [anchorKey, setAnchorKey] = useState<string | null>(null);
// Подсветка строки/контура по клику на карте и центрирование на слоте. // Подсветка строки/контура по клику на карте и центрирование на слоте.
const [activeSlotId, setActiveSlotId] = useState<string>(); const [activeSlotId, setActiveSlotId] = useState<string>();
// Полоса под курсором — для подсветки всех её членов по StripId (актуально в сортировках
// «крен»/«вклад», где члены полосы не идут подряд). null — курсор вне полос.
const [hoveredStrip, setHoveredStrip] = useState<string | null>(null);
const [focusPoint, setFocusPoint] = useState<GeoPoint>(); const [focusPoint, setFocusPoint] = useState<GeoPoint>();
const focusTokenRef = useRef(0); const focusTokenRef = useRef(0);
const [focusToken, setFocusToken] = useState(0); const [focusToken, setFocusToken] = useState(0);
@@ -295,34 +293,15 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
[dayWindowed, statusFilter, satFilter, branchFilter, bookedKeys], [dayWindowed, statusFilter, satFilter, branchFilter, bookedKeys],
); );
// Идентичность полос — над СТАБИЛЬНЫМ набором displaySlots (НЕ filteredSlots/sortedSlots: // Разметка полос — над СТАБИЛЬНЫМ набором displaySlots (НЕ filteredSlots/sortedSlots: иначе
// иначе фильтр-дырка ложно разорвала или склеила бы полосу). Map<slotKey, StripId>. // фильтр-дырка ложно разорвала или склеила бы полосу). Полоса = один КА + один крен + подряд по
const stripIdByKey = useMemo(() => groupStrips(displaySlots, slotKey), [displaySlots]); // времени (виток не граница). idByKey — id полосы по ключу слота; membersOf — члены полосы по времени.
const polosa = useMemo(() => groupStrips(displaySlots, slotKey), [displaySlots]);
// По каждой видимой полосе: число видимых членов (N для чипа) и ключи видимых ВЫБИРАЕМЫХ // Якорь жеста привязан к ВИДИМОМУ набору: смена фильтров/окна суток/брони делает прежний якорь
// (не-BUSY) членов — цель выбора. Считаем над filteredSlots: выбираем/считаем только видимое. // неактуальным → сбрасываем (как при смене сортировки), иначе Shift выделил бы не ту полосу.
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-диапазон после фильтрации выбрал бы не те строки.
useEffect(() => { useEffect(() => {
anchorIndexRef.current = null; setAnchorKey(null);
}, [filteredSlots]); }, [filteredSlots]);
// Загрузка заявок интервала (на карту); вызывается на входе, при смене диапазона и из фильтра. // Загрузка заявок интервала (на карту); вызывается на входе, при смене диапазона и из фильтра.
@@ -532,7 +511,7 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
useEffect(() => { useEffect(() => {
abortRef.current?.abort(); abortRef.current?.abort();
wktCacheRef.current.clear(); wktCacheRef.current.clear();
anchorIndexRef.current = null; setAnchorKey(null);
setSlots([]); setSlots([]);
setSelectedKeys(new Set()); setSelectedKeys(new Set());
setBookedSlots([]); setBookedSlots([]);
@@ -579,7 +558,7 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
abortRef.current?.abort(); abortRef.current?.abort();
const ctrl = new AbortController(); const ctrl = new AbortController();
abortRef.current = ctrl; abortRef.current = ctrl;
anchorIndexRef.current = null; setAnchorKey(null);
if (auto) setAutoFilling(true); if (auto) setAutoFilling(true);
else setCalculating(true); else setCalculating(true);
setError(undefined); setError(undefined);
@@ -782,13 +761,33 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
}; };
const cancelPendingCancel = () => setPendingCancel(false); const cancelPendingCancel = () => setPendingCancel(false);
// Отметка слота из таблицы. Shift+клик — выбрать диапазон от якоря (предыдущего клика) до // Отметка слота из таблицы.
// текущей строки; направление (выбрать/снять) задаётся новым состоянием текущей строки. // • Обычный клик — переключает один слот и ставит его якорем полосы.
// • Shift+клик — жест «полоса от первого к последнему»: выделяет всех членов ОДНОЙ полосы между
// якорем и текущим слотом (включительно). Если текущий слот из другой полосы (другой КА/крен/
// разрыв по времени) — выбор не меняем и подсказываем: выйти за полосу нельзя.
const handleSlotCheck = (index: number, shiftKey: boolean) => { 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) => 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 (занятые // Чекбокс шапки — выбрать/снять все ВИДИМЫЕ (по окну суток) выбираемые слоты. BUSY (занятые
@@ -803,15 +802,7 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
else for (const slot of selectableVisible) next.add(slotKey(slot)); else for (const slot of selectableVisible) next.add(slotKey(slot));
return next; return next;
}); });
anchorIndexRef.current = null; setAnchorKey(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;
}; };
// Выбрать все слоты расчёта (по всем суткам, кроме занятых бронью), снять весь выбор. // Выбрать все слоты расчёта (по всем суткам, кроме занятых бронью), снять весь выбор.
@@ -819,7 +810,7 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
setSelectedKeys(new Set(displaySlots.filter((slot) => !slotIsBusy(slot)).map(slotKey))); setSelectedKeys(new Set(displaySlots.filter((slot) => !slotIsBusy(slot)).map(slotKey)));
const clearSelection = () => { const clearSelection = () => {
setSelectedKeys(new Set()); setSelectedKeys(new Set());
anchorIndexRef.current = null; setAnchorKey(null);
}; };
// Тогглы фильтров видимости (selected = visible: клик добавляет/убирает значение из множества). // Тогглы фильтров видимости (selected = visible: клик добавляет/убирает значение из множества).
@@ -832,7 +823,7 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
// Смена сортировки меняет позиции строк — якорь Shift-выбора больше не валиден, сбрасываем. // Смена сортировки меняет позиции строк — якорь Shift-выбора больше не валиден, сбрасываем.
const changeSort = (mode: SlotSortMode) => { const changeSort = (mode: SlotSortMode) => {
setSortMode(mode); setSortMode(mode);
anchorIndexRef.current = null; setAnchorKey(null);
}; };
const toggleStatus = (key: SlotStatusKey) => setStatusFilter((prev) => toggleInSet(prev, key)); const toggleStatus = (key: SlotStatusKey) => setStatusFilter((prev) => toggleInSet(prev, key));
const toggleSat = (id: number) => setSatFilter((prev) => toggleInSet(prev, id)); const toggleSat = (id: number) => setSatFilter((prev) => toggleInSet(prev, id));
@@ -1213,6 +1204,9 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
</span> </span>
)} )}
</span> </span>
<span className="tgu-slots-selbar__sub" title="Кликните первый слот, затем Shift+клик по последнему — выделится полоса между ними. Выйти за полосу (другой КА/крен/разрыв по времени) нельзя.">
Полоса: клик + Shift+клик
</span>
<div className="tgu-slots-selbar__actions"> <div className="tgu-slots-selbar__actions">
<button <button
className="tgu-button tgu-button--sm" className="tgu-button tgu-button--sm"
@@ -1291,23 +1285,18 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
const id = `slot:${key}`; const id = `slot:${key}`;
const booked = bookedKeys.has(key); const booked = bookedKeys.has(key);
const isActive = activeSlotId === id; const isActive = activeSlotId === id;
// Полоса слота: N видимых членов, ключи видимых выбираемых членов. // Подсветка полосы текущего якоря: оператор видит, что выделит Shift+клик,
const stripId = stripIdByKey.get(key); // и что за границу полосы (другой КА/крен/разрыв) выйти нельзя.
const stripN = stripId ? stripInfo.visibleCount.get(stripId) ?? 0 : 0; const polosaId = polosa.idByKey.get(key);
const stripSelectable = stripId const inAnchorPolosa =
? stripInfo.selectableKeys.get(stripId) ?? [] anchorKey != null &&
: []; polosaId != null &&
const isStrip = stripN >= 2; polosaId === polosa.idByKey.get(anchorKey);
const showStripChip = isStrip && stripSelectable.length > 0; const isAnchorRow = anchorKey === key;
const stripAllSelected =
showStripChip && stripSelectable.every((k) => selectedKeys.has(k));
// Рельс — только в сортировке «время» (там члены полосы идут подряд);
// в «крен»/«вклад» — подсветка по StripId при наведении.
const showRail = isStrip && sortMode === "time";
const rowClass = [ const rowClass = [
isActive ? "is-active" : "", isActive ? "is-active" : "",
isStrip && hoveredStrip === stripId ? "is-strip-hover" : "", inAnchorPolosa ? "tgu-slots-row--strip" : "",
showRail ? "tgu-slots-row--strip" : "", isAnchorRow ? "is-strip-anchor" : "",
] ]
.filter(Boolean) .filter(Boolean)
.join(" "); .join(" ");
@@ -1317,14 +1306,18 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
data-slot-id={id} data-slot-id={id}
style={{ style={{
height: ROW_HEIGHT, height: ROW_HEIGHT,
...(showRail && stripId ...(inAnchorPolosa && polosaId
? { ["--strip-hue" as string]: String(stripHue(stripId)) } ? { ["--strip-hue" as string]: String(stripHue(polosaId)) }
: {}), : {}),
}} }}
className={rowClass || undefined} className={rowClass || undefined}
onClick={() => selectRow(slot)} onClick={(event) => {
onMouseEnter={isStrip ? () => setHoveredStrip(stripId!) : undefined} // Клик по строке = и подсветка на карте, и жест выбора полосы:
onMouseLeave={isStrip ? () => setHoveredStrip(null) : undefined} // обычный клик выбирает слот + ставит якорь, Shift+клик тянет полосу
// до этой строки. BUSY не выбираем (бронировать нельзя).
selectRow(slot);
if (!slotIsBusy(slot)) handleSlotCheck(vrow.index, event.shiftKey);
}}
> >
<td onClick={(event) => event.stopPropagation()}> <td onClick={(event) => event.stopPropagation()}>
<input <input
@@ -1337,33 +1330,7 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
aria-label="Выбрать слот" aria-label="Выбрать слот"
/> />
</td> </td>
<td> <td>{slot.satelliteId}</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> <td>
{slot.revolution} / {slot.revolutionSign === "DESC" ? "нисх" : "восх"} /{" "} {slot.revolution} / {slot.revolutionSign === "DESC" ? "нисх" : "восх"} /{" "}
{slot.cycle} {slot.cycle}
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { chunk, selectionAfterCheck, toggleStripSelection } from "./slotSelection"; import { chunk, selectionAfterCheck, selectStripRange } from "./slotSelection";
const keyAt = (i: number) => `k${i}`; const keyAt = (i: number) => `k${i}`;
@@ -42,26 +42,38 @@ describe("selectionAfterCheck", () => {
}); });
}); });
describe("toggleStripSelection", () => { describe("selectStripRange", () => {
it("добавляет все члены полосы, если выбраны не все", () => { const members = ["a", "b", "c", "d"]; // в порядке времени
const next = toggleStripSelection(new Set(["a"]), ["a", "b", "c"]);
expect([...next].sort()).toEqual(["a", "b", "c"]); it("выделяет членов полосы от первого к последнему включительно", () => {
const next = selectStripRange(new Set(), members, "b", "d");
expect([...next].sort()).toEqual(["b", "c", "d"]);
}); });
it("снимает все члены, если все уже выбраны", () => { it("работает при обратном порядке кликов (последний → первый)", () => {
const next = toggleStripSelection(new Set(["a", "b", "c", "x"]), ["a", "b", "c"]); const next = selectStripRange(new Set(), members, "d", "b");
expect([...next]).toEqual(["x"]); expect([...next].sort()).toEqual(["b", "c", "d"]);
}); });
it("пустой набор членов — выбор без изменений", () => { it("один и тот же ключ → один член", () => {
const next = toggleStripSelection(new Set(["x"]), []); const next = selectStripRange(new Set(), members, "c", "c");
expect([...next]).toEqual(["c"]);
});
it("сохраняет уже выбранное за пределами полосы", () => {
const next = selectStripRange(new Set(["x"]), members, "a", "b");
expect([...next].sort()).toEqual(["a", "b", "x"]);
});
it("ключ вне полосы → выбор без изменений", () => {
const next = selectStripRange(new Set(["x"]), members, "a", "zzz");
expect([...next]).toEqual(["x"]); expect([...next]).toEqual(["x"]);
}); });
it("не мутирует исходное множество", () => { it("не мутирует исходное множество", () => {
const current = new Set(["a"]); const current = new Set(["x"]);
toggleStripSelection(current, ["a", "b"]); selectStripRange(current, members, "a", "b");
expect([...current]).toEqual(["a"]); expect([...current]).toEqual(["x"]);
}); });
}); });
@@ -40,25 +40,30 @@ export function selectionAfterCheck(
} }
/** /**
* Новое множество выбора после клика по чипу «полоса». Поведение зеркалит «выбрать видимые», * Новое множество выбора после жеста «полоса от первого к последнему»: добавляет всех членов
* но в рамках одной полосы: если ВСЕ её видимые выбираемые члены уже выбраны — снимаем их, * полосы между fromKey и toKey включительно (в порядке времени). Это ВЫДЕЛЕНИЕ, не toggle —
* иначе добавляем все. Скрытые (отфильтрованные) и BUSY-члены сюда не передаются — выбираем * существующий выбор сохраняется. Жест ограничен одной полосой: вызывающий передаёт members
* только то, что видно и можно бронировать (доверие счётчику «Выбрано»). Вход не мутируется. * именно её (один КА, один крен, подряд по времени); граница полосы — гарантия «нельзя выделить
* не полосу». Если fromKey или toKey не из members — выбор не меняется (страховка). Вход не мутируется.
* *
* @param current Текущее множество выбранных ключей. * @param current Текущее множество выбранных ключей.
* @param memberKeys Ключи видимых выбираемых членов полосы. * @param members Ключи членов полосы в порядке времени.
* @param fromKey Ключ первого клика (якорь).
* @param toKey Ключ второго клика.
*/ */
export function toggleStripSelection( export function selectStripRange(
current: ReadonlySet<string>, current: ReadonlySet<string>,
memberKeys: readonly string[], members: readonly string[],
fromKey: string,
toKey: string,
): Set<string> { ): Set<string> {
const next = new Set(current) const next = new Set(current)
if (memberKeys.length === 0) return next const from = members.indexOf(fromKey)
const allSelected = memberKeys.every((k) => current.has(k)) const to = members.indexOf(toKey)
for (const k of memberKeys) { if (from < 0 || to < 0) return next
if (allSelected) next.delete(k) const lo = Math.min(from, to)
else next.add(k) const hi = Math.max(from, to)
} for (let i = lo; i <= hi; i++) next.add(members[i]!)
return next return next
} }
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { ROLL_EPSILON, groupStrips } from "./slotStrips"; import { ROLL_EPSILON, groupStrips, type StripGrouping } from "./slotStrips";
/** Минимальный слот для группировки. tn задаём как локальную ISO-строку (как отдаёт бэкенд). */ /** Минимальный слот для группировки. tn задаём как локальную ISO-строку (как отдаёт бэкенд). */
type S = { type S = {
@@ -13,17 +13,17 @@ type S = {
const keyOf = (s: S) => `${s.satelliteId}:${s.slotNumber}:${s.cycle}`; const keyOf = (s: S) => `${s.satelliteId}:${s.slotNumber}:${s.cycle}`;
/** Слот на минуту mm (от базовой даты) с длительностью 30 c. */ /** Слот на минуту mm (от базовой даты) с длительностью 30 c. cycle ≈ «виток» (в границу не входит). */
function slot(satelliteId: number, slotNumber: number, roll: number, mm: number, cycle = 1): S { function slot(satelliteId: number, slotNumber: number, roll: number, mm: number, cycle = 1): S {
const tn = `2026-06-17T10:${String(mm).padStart(2, "0")}:00`; const tn = `2026-06-17T10:${String(mm).padStart(2, "0")}:00`;
const tk = `2026-06-17T10:${String(mm).padStart(2, "0")}:30`; const tk = `2026-06-17T10:${String(mm).padStart(2, "0")}:30`;
return { satelliteId, slotNumber, cycle, tn, tk, roll }; return { satelliteId, slotNumber, cycle, tn, tk, roll };
} }
/** Разбиение на полосы как множество множеств ключей — для сравнения, не зависящего от имён StripId. */ /** Разбиение на полосы как множество множеств ключей — для сравнения, не зависящего от имён PolosaId. */
function partition(map: Map<string, string>): string[][] { function partition(g: StripGrouping): string[][] {
const byStrip = new Map<string, string[]>(); const byStrip = new Map<string, string[]>();
for (const [key, strip] of map) { for (const [key, strip] of g.idByKey) {
const arr = byStrip.get(strip) ?? []; const arr = byStrip.get(strip) ?? [];
arr.push(key); arr.push(key);
byStrip.set(strip, arr); byStrip.set(strip, arr);
@@ -32,62 +32,78 @@ function partition(map: Map<string, string>): string[][] {
} }
describe("groupStrips", () => { describe("groupStrips", () => {
it("пустой вход → пустая Map", () => { it("пустой вход → пустая разметка", () => {
expect(groupStrips([], keyOf).size).toBe(0); const g = groupStrips([], keyOf);
expect(g.idByKey.size).toBe(0);
expect(g.membersOf.size).toBe(0);
}); });
it("один слот → своя полоса", () => { it("один слот → своя полоса", () => {
const map = groupStrips([slot(1, 1, 5, 0)], keyOf); const g = groupStrips([slot(1, 1, 5, 0)], keyOf);
expect(map.get("1:1:1")).toBe("strip:1:1:1"); expect(g.idByKey.get("1:1:1")).toBe("polosa:1:1:1");
expect(g.membersOf.get("polosa:1:1:1")).toEqual(["1:1:1"]);
}); });
it("мультиполоса в одном КА: смена крена начинает новую полосу", () => { it("веер кренов в один момент → разные полосы; равный крен вдоль времени → одна полоса", () => {
// КА 1: два нацеливания (крен 36.5 и 39.5, шаг 3° > ε), каждое снято в моменты mm=0 и mm=1.
const slots = [ const slots = [
slot(1, 1, 5, 0), slot(1, 1, 36.5, 0),
slot(1, 2, 5, 1), slot(1, 2, 39.5, 0),
slot(1, 3, 20, 2), // крен изменился → новая полоса slot(1, 3, 36.5, 1),
slot(1, 4, 20, 3), slot(1, 4, 39.5, 1),
]; ];
expect(partition(groupStrips(slots, keyOf))).toEqual([ expect(partition(groupStrips(slots, keyOf))).toEqual([
["1:1:1", "1:2:1"], ["1:1:1", "1:3:1"], // крен 36.5 вдоль времени
["1:3:1", "1:4:1"], ["1:2:1", "1:4:1"], // крен 39.5 вдоль времени
]); ]);
}); });
it("два КА вперемешку по времени не склеиваются (даже при одном крене)", () => { it("смена витка (cycle) внутри одного крена и каденции НЕ рвёт полосу", () => {
const slots = [
slot(1, 1, 5, 0, 1),
slot(1, 2, 5, 1, 1),
slot(1, 3, 5, 2, 2), // виток сменился у экватора — но это та же полоса
slot(1, 4, 5, 3, 2),
];
expect(partition(groupStrips(slots, keyOf))).toEqual([
["1:1:1", "1:2:1", "1:3:2", "1:4:2"],
]);
});
it("большой разрыв по времени рвёт полосу на две (другой проход)", () => {
const slots = [ const slots = [
slot(1, 1, 5, 0), slot(1, 1, 5, 0),
slot(2, 1, 5, 0),
slot(1, 2, 5, 1), slot(1, 2, 5, 1),
slot(2, 2, 5, 1), slot(1, 3, 5, 2),
slot(1, 4, 5, 3),
slot(1, 5, 5, 30), // разрыв ≫ каденции (1 мин) → новый проход
slot(1, 6, 5, 31),
slot(1, 7, 5, 32),
]; ];
expect(partition(groupStrips(slots, keyOf))).toEqual([
["1:1:1", "1:2:1", "1:3:1", "1:4:1"],
["1:5:1", "1:6:1", "1:7:1"],
]);
});
it("разные КА — разные полосы (даже при одном крене и времени)", () => {
const slots = [slot(1, 1, 5, 0), slot(2, 1, 5, 0), slot(1, 2, 5, 1), slot(2, 2, 5, 1)];
expect(partition(groupStrips(slots, keyOf))).toEqual([ expect(partition(groupStrips(slots, keyOf))).toEqual([
["1:1:1", "1:2:1"], ["1:1:1", "1:2:1"],
["2:1:1", "2:2:1"], ["2:1:1", "2:2:1"],
]); ]);
}); });
it("граница ε: diff == 0.01 склеивает, 0.0101 рвёт", () => { it("граница ε по крену: разрыв == 0.5 склеивает, 0.6 рвёт", () => {
const same = groupStrips([slot(1, 1, 5.0, 0), slot(1, 2, 5 + ROLL_EPSILON, 1)], keyOf); const same = groupStrips([slot(1, 1, 5.0, 0), slot(1, 2, 5 + ROLL_EPSILON, 1)], keyOf);
expect(partition(same)).toEqual([["1:1:1", "1:2:1"]]); expect(partition(same)).toEqual([["1:1:1", "1:2:1"]]);
const broken = groupStrips([slot(1, 1, 5.0, 0), slot(1, 2, 5 + ROLL_EPSILON + 0.0001, 1)], keyOf); const broken = groupStrips([slot(1, 1, 5.0, 0), slot(1, 2, 5 + ROLL_EPSILON + 0.1, 1)], keyOf);
expect(partition(broken)).toEqual([["1:1:1"], ["1:2:1"]]); expect(partition(broken)).toEqual([["1:1:1"], ["1:2:1"]]);
}); });
it("дрейф крена 0.00/0.009/0.018 (сравнение с якорем) → две полосы, не одна", () => {
const slots = [slot(1, 1, 0.0, 0), slot(1, 2, 0.009, 1), slot(1, 3, 0.018, 2)];
// 0.009−0.0 ≤ ε → в полосе; 0.018−0.0 > ε → новая полоса (а не приклеивание к 0.009).
expect(partition(groupStrips(slots, keyOf))).toEqual([["1:1:1", "1:2:1"], ["1:3:1"]]);
});
it("устойчивость к порядку входа: разбиение одинаково при перемешивании", () => { it("устойчивость к порядку входа: разбиение одинаково при перемешивании", () => {
const slots = [ const slots = [slot(1, 1, 5, 0), slot(1, 2, 5, 1), slot(1, 3, 20, 0), slot(2, 1, 5, 0)];
slot(1, 1, 5, 0),
slot(1, 2, 5, 1),
slot(1, 3, 20, 2),
slot(2, 1, 5, 0),
];
const a = partition(groupStrips(slots, keyOf)); const a = partition(groupStrips(slots, keyOf));
const b = partition(groupStrips([...slots].reverse(), keyOf)); const b = partition(groupStrips([...slots].reverse(), keyOf));
const c = partition(groupStrips([slots[2]!, slots[0]!, slots[3]!, slots[1]!], keyOf)); const c = partition(groupStrips([slots[2]!, slots[0]!, slots[3]!, slots[1]!], keyOf));
@@ -95,28 +111,19 @@ describe("groupStrips", () => {
expect(c).toEqual(a); expect(c).toEqual(a);
}); });
it("StripId стабилен = strip:${ключ первого по времени члена}", () => { it("PolosaId = polosa:${ключ первого по времени члена}; membersOf — в порядке времени", () => {
const map = groupStrips([slot(1, 2, 5, 1), slot(1, 1, 5, 0)], keyOf); const g = groupStrips([slot(1, 2, 5, 1), slot(1, 1, 5, 0)], keyOf);
// первый по времени — slotNumber 1 (mm=0); оба члена несут его StripId. expect(g.idByKey.get("1:1:1")).toBe("polosa:1:1:1");
expect(map.get("1:1:1")).toBe("strip:1:1:1"); expect(g.idByKey.get("1:2:1")).toBe("polosa:1:1:1");
expect(map.get("1:2:1")).toBe("strip:1:1:1"); expect(g.membersOf.get("polosa:1:1:1")).toEqual(["1:1:1", "1:2:1"]);
}); });
it("tie-break при равном tn: устойчивая группировка по slotNumber/cycle/ключу", () => { it("NaN tn → синглтон без падения, не клеит и не рвёт валидных соседей", () => {
const a = slot(1, 1, 5, 0);
const b = slot(1, 2, 5, 0); // тот же tn/tk
const map = groupStrips([b, a], keyOf);
expect(partition(map)).toEqual([["1:1:1", "1:2:1"]]);
expect(map.get("1:1:1")).toBe("strip:1:1:1"); // меньший ключ — якорь
});
it("NaN tn → синглтон без падения, не клеит соседей", () => {
const broken: S = { satelliteId: 1, slotNumber: 9, cycle: 1, tn: "битый", tk: "битый", roll: 5 }; const broken: S = { satelliteId: 1, slotNumber: 9, cycle: 1, tn: "битый", tk: "битый", roll: 5 };
const slots = [slot(1, 1, 5, 0), broken, slot(1, 2, 5, 1)]; const slots = [slot(1, 1, 5, 0), broken, slot(1, 2, 5, 1)];
const map = groupStrips(slots, keyOf); const g = groupStrips(slots, keyOf);
expect(map.get("1:9:1")).toBe("strip:1:9:1"); expect(g.idByKey.get("1:9:1")).toBe("polosa:1:9:1");
// валидные соседи — своя полоса, битый слот их не склеивает и не рвёт между собой. expect(g.idByKey.get("1:1:1")).toBe("polosa:1:1:1");
expect(map.get("1:1:1")).toBe("strip:1:1:1"); expect(g.idByKey.get("1:2:1")).toBe("polosa:1:1:1");
expect(map.get("1:2:1")).toBe("strip:1:1:1");
}); });
}); });
@@ -1,53 +1,94 @@
// Группировка слотов в «полосы» (strips) — максимальные цепочки идущих подряд по времени // Группировка слотов в «полосы» (strips) для ручного выбора полосы мышью.
// слотов одного КА с одним креном. Чистая логика под юнит-тесты (см. slotStrips.test.ts,
// образец стиля slotSelection.ts / coverage.ts). Используется UI для жеста «выбрать полосу»
// и визуальной группировки строк таблицы.
// //
// Это ТОЛЬКО identity-группировка (какой слот к какой полосе относится), НЕ проверка // Полоса = слоты ОДНОГО КА + ОДИН крен (с допуском ROLL_EPSILON) + ПОДРЯД по времени (без разрыва
// выполнимости брони. Проверка манёвренности КА (mmi/maxSurveyDuration/maxChainLength) живёт // каденции). Виток/цикл в границу НЕ входят: у экватора номер витка меняется, а слоты остаются в
// в slots-service по-КА из каталога и будет вынесена в бэкенд validate-эндпоинт (iteration 2), // одной полосе, поэтому группируем только по (КА, крен, непрерывность по времени).
// чтобы не дублировать физику на клиенте. //
// Это ТОЛЬКО identity-группировка (какой слот к какой полосе относится), НЕ проверка выполнимости
// брони. Проверка манёвренности КА (mmi/maxSurveyDuration/maxChainLength/паузы) живёт в slots-service
// по-КА из каталога и будет вынесена в бэкенд validate-эндпоинт (iteration 2), чтобы не дублировать
// физику на клиенте.
import type { SlotDTO } from "../../model/slotTypes"; import type { SlotDTO } from "../../model/slotTypes";
import { parseLocal } from "../../utils/localTime"; import { parseLocal } from "../../utils/localTime";
/** /**
* Допуск сравнения крена (градусы). Совпадает с бэкендовым `ROLL_EPSILON` в slots-service: * Допуск сравнения крена (градусы): слоты считаются «одним креном», если разрыв по крену между
* слоты считаются «одним креном», если |roll anchorRoll| ≤ ROLL_EPSILON. НЕ 0.1 — крупный * соседями кластера ≤ ROLL_EPSILON. Подобран по реальным данным — «веер» нацеливаний идёт с шагом
* допуск ошибочно склеивал бы разные нацеливания. * ~3°, поэтому 0.5° надёжно разделяет соседние нацеливания и терпит дрейф крена внутри полосы.
*/ */
export const ROLL_EPSILON = 0.01; export const ROLL_EPSILON = 0.5;
/** Идентификатор полосы. Стабилен и читаем: `strip:${slotKey первого члена}` (не счётчик, /**
* который зависел бы от порядка обхода). */ * Во сколько раз разрыв по времени должен превысить типичную каденцию полосы, чтобы считаться
export type StripId = string; * пропуском и разорвать полосу на две. Полоса — это слоты подряд (через равные шаги по времени);
* заметно больший разрыв означает «другой проход», т.е. другую полосу.
*/
export const CADENCE_GAP_FACTOR = 1.5;
/** Идентификатор полосы. Стабилен и читаем: `polosa:${slotKey первого по времени члена}`. */
export type PolosaId = string;
/**
* Результат разметки: id полосы по ключу слота и упорядоченные по времени члены каждой полосы
* (членение нужно жесту «выбрать от первого к последнему» — взять срез между двумя кликами).
*/
export type StripGrouping = {
idByKey: Map<string, PolosaId>;
membersOf: Map<PolosaId, string[]>;
};
/** Минимальный набор полей слота, нужный для группировки. */ /** Минимальный набор полей слота, нужный для группировки. */
type Stripable = Pick<SlotDTO, "satelliteId" | "slotNumber" | "cycle" | "tn" | "tk" | "roll">; type Stripable = Pick<SlotDTO, "satelliteId" | "slotNumber" | "cycle" | "tn" | "tk" | "roll">;
/** Сравнение чисел с детерминированным порядком; NaN — в конец (устойчивость к битому tn). */ type Item = { key: string; sat: number; roll: number; tnMs: number };
function cmpNum(a: number, b: number): number {
const an = Number.isNaN(a);
const bn = Number.isNaN(b);
if (an && bn) return 0;
if (an) return 1;
if (bn) return -1;
return a < b ? -1 : a > b ? 1 : 0;
}
function cmpStr(a: string, b: string): number { function cmpStr(a: string, b: string): number {
return a < b ? -1 : a > b ? 1 : 0; return a < b ? -1 : a > b ? 1 : 0;
} }
/** Медиана массива (для типичной каденции полосы). Пустой массив → 0. */
function median(values: readonly number[]): number {
if (values.length === 0) return 0;
const s = [...values].sort((a, b) => a - b);
const mid = Math.floor(s.length / 2);
return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2;
}
/** /**
* Размечает слоты по полосам: возвращает Map<slotKey, StripId>. Полоса — максимальный run * Делит кластер слотов одного крена (одного КА) на полосы по непрерывности времени: сортирует по
* идущих подряд по времени слотов ОДНОГО КА с креном, отличающимся от якорного крена полосы * времени и режет там, где разрыв заметно больше типичной каденции (медиана шагов × CADENCE_GAP_FACTOR).
* не более чем на ROLL_EPSILON (сравнение с якорем, а не с предыдущим слотом — иначе дрейф * До двух членов каденцию определить нельзя — считаем их одной полосой.
* крена 0.00→0.009→0.018 ошибочно склеил бы всё в одну полосу). Совпадает с бэкендовым */
* `sameRoll(roll, chainRoll)`. function splitByCadence(cluster: readonly Item[]): Item[][] {
const sorted = [...cluster].sort((a, b) => a.tnMs - b.tnMs || cmpStr(a.key, b.key));
if (sorted.length <= 2) return [sorted];
const gaps: number[] = [];
for (let i = 1; i < sorted.length; i++) gaps.push(sorted[i].tnMs - sorted[i - 1].tnMs);
const cadence = median(gaps.filter((g) => g > 0));
const threshold = cadence > 0 ? cadence * CADENCE_GAP_FACTOR : Infinity;
const runs: Item[][] = [];
let run: Item[] = [sorted[0]];
for (let i = 1; i < sorted.length; i++) {
if (sorted[i].tnMs - sorted[i - 1].tnMs > threshold) {
runs.push(run);
run = [];
}
run.push(sorted[i]);
}
runs.push(run);
return runs;
}
/**
* Размечает слоты по полосам. Полоса — слоты одного КА с одним креном (кластеризация по крену с
* допуском ROLL_EPSILON), идущие подряд по времени (разрыв каденции рвёт полосу). Виток/цикл не
* учитываются — полоса может пересекать смену витка.
* *
* Детерминизм: результат не зависит от порядка входного массива — внутри тотальная сортировка * Детерминизм: результат не зависит от порядка входного массива — внутри сортируем по крену и времени,
* по `(satelliteId, tn, tk, slotNumber, cycle, slotKey)`. * id полосы = ключ её первого по времени члена.
* *
* @param slots Слоты для разметки (любой порядок). * @param slots Слоты для разметки (любой порядок).
* @param keyOf Аксессор ключа слота (как `keyAt` в selectionAfterCheck) — чтобы не дублировать * @param keyOf Аксессор ключа слота (как `keyAt` в selectionAfterCheck) — чтобы не дублировать
@@ -56,53 +97,55 @@ function cmpStr(a: string, b: string): number {
export function groupStrips<T extends Stripable>( export function groupStrips<T extends Stripable>(
slots: readonly T[], slots: readonly T[],
keyOf: (slot: T) => string, keyOf: (slot: T) => string,
): Map<string, StripId> { ): StripGrouping {
const result = new Map<string, StripId>(); const idByKey = new Map<string, PolosaId>();
if (slots.length === 0) return result; const membersOf = new Map<PolosaId, string[]>();
if (slots.length === 0) return { idByKey, membersOf };
const items = slots.map((slot) => ({ const assign = (members: readonly Item[]) => {
slot, const keys = members.map((m) => m.key);
const id: PolosaId = `polosa:${keys[0]}`;
membersOf.set(id, keys);
for (const k of keys) idByKey.set(k, id);
};
const bySat = new Map<number, Item[]>();
for (const slot of slots) {
const item: Item = {
key: keyOf(slot), key: keyOf(slot),
sat: slot.satelliteId,
roll: slot.roll,
tnMs: parseLocal(slot.tn), tnMs: parseLocal(slot.tn),
tkMs: parseLocal(slot.tk), };
})); // Слот без валидного времени не встроить в полосу по непрерывности — отдельная полоса-синглтон.
if (Number.isNaN(item.tnMs)) {
items.sort( assign([item]);
(a, b) => continue;
cmpNum(a.slot.satelliteId, b.slot.satelliteId) || }
cmpNum(a.tnMs, b.tnMs) || const arr = bySat.get(item.sat) ?? [];
cmpNum(a.tkMs, b.tkMs) || arr.push(item);
cmpNum(a.slot.slotNumber, b.slot.slotNumber) || bySat.set(item.sat, arr);
cmpNum(a.slot.cycle, b.slot.cycle) || }
cmpStr(a.key, b.key),
);
let stripId: StripId | null = null;
let anchorRoll = 0;
let anchorSat: number | null = null;
for (const sat of Array.from(bySat.keys()).sort((a, b) => a - b)) {
const items = bySat.get(sat)!;
// Кластеризация по крену: сортируем по roll, начинаем новый кластер при разрыве > ROLL_EPSILON.
items.sort((a, b) => a.roll - b.roll || a.tnMs - b.tnMs || cmpStr(a.key, b.key));
let cluster: Item[] = [];
let prevRoll = Number.NaN;
const flush = () => {
if (cluster.length > 0) for (const run of splitByCadence(cluster)) assign(run);
};
for (const item of items) { for (const item of items) {
const tnBad = Number.isNaN(item.tnMs); if (cluster.length > 0 && item.roll - prevRoll > ROLL_EPSILON) {
const continues = flush();
stripId != null && cluster = [];
!tnBad &&
anchorSat === item.slot.satelliteId &&
Math.abs(item.slot.roll - anchorRoll) <= ROLL_EPSILON;
if (!continues) {
stripId = `strip:${item.key}`;
anchorRoll = item.slot.roll;
anchorSat = item.slot.satelliteId;
} }
result.set(item.key, stripId!); cluster.push(item);
prevRoll = item.roll;
// Слот с непарсящимся tn — синглтон: следующий слот должен начать свежую полосу,
// а не приклеиться к этому якорю.
if (tnBad) {
stripId = null;
anchorSat = null;
} }
flush();
} }
return result; return { idByKey, membersOf };
} }
@@ -1305,49 +1305,22 @@
background: rgba(104, 195, 189, 0.36); background: rgba(104, 195, 189, 0.36);
} }
/* Ячейка «КА»: номер КА + чип выбора полосы рядом. */ /* Строки таблицы — поверхность жеста выбора полосы (клик + Shift+клик), поэтому гасим выделение
.tgu-slots-sat { текста, чтобы Shift+клик не подсвечивал текст между строками. */
display: inline-flex; .tgu-slots-table tbody {
align-items: center; user-select: none;
gap: 0.4rem;
} }
/* Чип «полоса ×N» — выбрать всю полосу (слоты одного крена подряд) одним кликом. /* Рельс полосы текущего якоря: приглушённая цветная полоса слева у всех членов полосы, в которой
Нейтральный по умолчанию; .is-on — когда вся полоса уже выбрана. */ стоит якорь жеста «полоса от первого к последнему». Оттенок детерминирован PolosaId (--strip-hue).
.tgu-slots-strip-chip { Так оператор видит границу полосы — что выделит Shift+клик и за что выйти нельзя. У активной
display: inline-flex; строки рельс перекрывается акцентным (is-active), это норма. */
align-items: center;
padding: 0.02rem 0.4rem;
border-radius: 999px;
border: 1px solid var(--line);
background: #101214;
color: var(--text-muted);
font-size: 0.68rem;
line-height: 1.4;
cursor: pointer;
white-space: nowrap;
}
.tgu-slots-strip-chip:hover {
border-color: var(--accent);
color: var(--text);
}
.tgu-slots-strip-chip.is-on {
border-color: rgba(104, 195, 189, 0.58);
background: rgba(104, 195, 189, 0.16);
color: var(--accent-strong);
}
/* Рельс полосы (сортировка «время», члены идут подряд): приглушённая цветная полоса слева,
оттенок детерминирован StripId (--strip-hue) — соседние полосы визуально различаются.
У активной строки рельс перекрывается акцентным (is-active), это норма. */
.tgu-slots-table tbody tr.tgu-slots-row--strip { .tgu-slots-table tbody tr.tgu-slots-row--strip {
box-shadow: inset 3px 0 0 hsl(var(--strip-hue, 0) 45% 55% / 0.55); box-shadow: inset 3px 0 0 hsl(var(--strip-hue, 0) 45% 55% / 0.55);
} }
/* Подсветка всех членов полосы под курсором (актуально в «крен»/«вклад», где они не подряд). */ /* Строка-якорь полосы (первый клик жеста) — чуть заметнее остальных членов полосы. */
.tgu-slots-table tbody tr.is-strip-hover { .tgu-slots-table tbody tr.is-strip-anchor {
background: rgba(104, 195, 189, 0.12); background: rgba(104, 195, 189, 0.12);
} }