Замена авто-чипа «полоса ×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 чисто
20 KiB
Эргономика выбора слотов: выбор «полосами» + правило выполнимости брони
Проблема (как сформулировал оператор)
Сделана работа по сортировке, подсветке брони, показу забронированных, расчёту % покрытия. Но сам выбор слотов под бронь по-прежнему неудобен: сейчас оператор отмечает слоты галочками поштучно (или Shift+клик диапазоном, или «все видимые / все сутки»). На сотнях-тысячах слотов это утомительно. Плюс выбор «слепой» — он не знает, можно ли вообще физически отснять выбранный набор (манёвренность КА).
Решённые premise (premise-гейт, 2026-06-16, скорректировано после CEO-ревью)
- Ограничение брони через смену крена реально существует (агилити КА).
- КОРРЕКЦИЯ: модель манёвренности УЖЕ есть в slots-service, по-КА из каталога
(
mmi,maxSurveyDuration,maxChainLength,ROLL_EPSILON=0.01— см. ниже). Ручная бронь (bookReq) её не проверяет, но coverage-solver иbuildSurveyChainsеё соблюдают. Поэтому не хардкодим физику на фронте — проверка выполнимости будет бэкенд validate-эндпоинтом, переиспользующим эти по-КА параметры. - Iteration 1 = только UX выбора полосами (frontend, без правила). Проверка выполнимости — iteration 2 (бэкенд validate-эндпоинт + проводка на фронте).
Что уже есть в бэкенде (источник правды для it.2)
- Параметры по-КА из каталога (SatelliteCatalogClient.kt:116,
AbstractSatellite.kt:25):
mmi=10с (мин. интервал маневра, тот же крен),maxSurveyDuration=300с,maxChainLength=30 слотов,ROLL_EPSILON=0.01. buildSurveyChains/canMergeInTime/sameRoll(SlotService.kt:2151) — склейка цепочек по-КА (для отрисовки),OccupiedIntervalPrefilter— mmi/maxSurveyDuration.- Несоответствия исходного черновика (закрыты решением «правило за бэком»): фронтовые 150с ≠ бэкендовые 300с; фронтовый ε=0.1° ≠ бэкендовый 0.01; «30с на смену крена» — в бэке отдельной такой константы НЕТ (склейка только при том же крене). Эти числа сверяются с каталогом/баллистиками в it.2, НЕ хардкодятся.
Что уже есть (карта на существующий код)
- Выбор:
selectedKeys: Set<slotKey>в TguSlotsTab.tsx. Поштучный чек, Shift+клик-диапазон (slotSelection.tsselectionAfterCheck), «выбрать видимые», «все сутки», «снять выбор». - Слот несёт
roll,revolution,revolutionSign,cycle,satelliteId,slotNumber,tn,tk,state. - Чистые модули с юнит-тестами: slotFilters.ts, slotSelection.ts, coverage.ts (+ .test.ts) — образец стиля.
- Бронь:
book()→ подтверждение →performBook(чанк 200), снятие аналогично. - Бэкенд бронирует без валидации манёвренности (SlotService.kt:2500). В it.1 не трогаем.
Решение — Iteration 1 (только UX выбора полосами, frontend) — ФИНАЛ после ревью
Модуль 1: slotStrips.ts (чистая логика + юнит-тесты)
groupStrips(slots, keyOf): Map<slotKey, StripId>— максимальные same-roll цепочки по-КА.keyOf— аксессор ключа слота (какkeyAtвselectionAfterCheck), чтобы не дублировать формулу${satelliteId}:${slotNumber}:${cycle}.- Детерминизм: внутри функции тотальный порядок — сортировка по
(satelliteId, parseLocal(tn), parseLocal(tk), slotNumber, cycle, slotKey). Результат не зависит от порядка входного массива. - ε крена =
ROLL_EPSILON = 0.01(именованная константа, как в бэкенде; НЕ 0.1). Сравнение — с якорным креном полосы (abs(roll − anchorRoll) ≤ 0.01), НЕ с предыдущим слотом (иначе дрейф крена 0.00→0.009→0.018 ошибочно склеит). Совпадает с бэкендовымsameRoll(roll, chainRoll). - StripId стабилен и читаем:
strip:${slotKey первого члена}(не счётчик — счётчик зависит от порядка обхода КА). - Краевые: один слот → своя полоса; два КА вперемешку по времени НЕ склеиваются;
tnне парсится (NaN) → синглтон без падения; ровно на границе ε (==0.01) → та же полоса.
Модуль 2: выбор «полосами» (UX в TguSlotsTab) — решения ревью
- Жест: чип в строке
полоса ×N(N = число видимых членов полосы). НЕ Alt+клик (необнаружим, конфликт с чек/Shift), НЕ клик по карте как основной. Чип появляется только при N≥2 и если есть хоть один выбираемый (не-BUSY) член. - Identity ≠ selection target:
stripIdByKey = useMemo(groupStrips(displaySlots, slotKey), [displaySlots])— id по СТАБИЛЬНОМУ набору (не filteredSlots/sortedSlots — иначе фильтр-дырка ложно склеит/разорвёт).- Видимые выбираемые члены полосы берём из
filteredSlots∩!slotIsBusy(по образцуselectableVisible).
- Поведение чипа =
toggleAllVisible, но в рамках одной полосы: если ВСЕ видимые выбираемые члены уже выбраны → снять их; иначе добавить. После —anchorIndexRef.current = null. Без нового state (selectedStripsне заводим — рассинхрон сselectedKeys). - Группировка визуально по StripId, sort-aware: в сортировке «время» — левый рельс
(border-left, цвет по
StripId, приглушённый) на смежных строках полосы. В «крен»/«вклад» слоты полосы НЕ подряд → рельс НЕ рисуем; подсветка членов полосы по StripId при наведении. Фон строки оставляем за состоянием (active/selected); статус-ячейки не перекрашиваем. - Карта it.1: оставляем существующую подсветку выбранных слотов (
markedObjectIdsуже ведётся отselectedKeys). Отдельную раскраску полос на карте и «выбрать полосу изSlotPickMenu» — НЕ делаем в it.1 (расширяет scope на модель/рисовалку карты). - CSS/таблица: обновить
colCount; выравнивание ячеек — классами, не сдвигомnth-child; чип держать в пределахROW_HEIGHT = 34.
Матрица состояний строки (зафиксировать перед реализацией)
| Состояние | Источник | Визуал |
|---|---|---|
| active (фокус по карте) | activeSlotId |
подсветка строки (как есть) |
| selected | selectedKeys.has(key) |
чек отмечен |
| strip-selected | все видимые выбираемые члены полосы выбраны | чип в активном виде |
| partial | часть членов полосы выбрана | чип в нейтральном виде |
| disabled chip | N<2 или 0 выбираемых членов | чипа нет |
| booked/occupied | как есть (бейдж) | не меняется; в полосу входит визуально, в выбор — нет |
Реализовано (it.1, 2026-06-17 — пересмотрено)
ВАЖНО: первая версия it.1 (авто-чип «полоса ×N» по равному крену) оказалась не той фичей и переделана. На реальных данных полоса по «подряд по времени + равный крен» не формировалась (в один момент идёт «веер» кренов с шагом ~3°, равный крен повторяется не подряд, а вдоль трассы через каденцию). Оператор хотел не авто-магию, а ручной выбор полосы мышью «от первого к последнему» с запретом выйти за полосу.
Переопределение полосы (согласовано с оператором):
- полоса = один КА + один крен (допуск
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 (проверка выполнимости, отдельно)
- Бэкенд: read-only
POST /api/slots/booking/validate(набор слотов → violations), переиспользует по-КАmmi/maxSurveyDuration/maxChainLength/ROLL_EPSILONиз каталога. Сверить точную семантику (300 vs «150 на полосу», нужна ли отдельная 30с-граница) с баллистиками. Опционально позже — валидация и в самомbookReq. - Фронт: гонять validate при выборе/перед бронью, показывать violations, решить блокировать кнопку или предупреждать.
NOT in scope (it.1)
- Любая физика выполнимости на фронте (перенесено в it.2, за бэкенд).
- Переработка автоподбора покрытия (GREEDY/CONTINUOUS).
- Изменение модели хранения броней.
Тест-план (it.1) — финал
slotStrips.test.ts(groupStrips): мультиполоса в одном КА; два КА вперемешку по времени (не склеиваются); один слот → своя полоса; граница ε (diff==0.01склеивает,0.0101рвёт); дрейф крена 0.00/0.009/0.018 (с якорным сравнением → две полосы); устойчивость к порядку входа (равенство РАЗБИЕНИЯ при перемешивании); tie-break по равномуtn; NaNtn→ синглтон без падения; пустой вход → пустая Map.- Чистый хелпер выбора полосы (выделить из компонента, как slotSelection): скрытые члены
не выбираются; BUSY исключён; «все видимые выбраны → снять, иначе добавить»; нет мутации
входного
selectedKeys. - Ручная проверка в vite dev: чип
полоса ×N, рельс в сортировке «время», подсветка по StripId в «крен»/«вклад», согласованность с Shift+клик/чек/«все сутки», счётчик «Выбрано».
Смежная находка (вне scope, флаг оператору)
anchorIndexRef (якорь Shift-выбора) сбрасывается при смене сортировки, но НЕ при смене
фильтров (статус/КА/ветвь/сутки), хотя они тоже меняют порядок строк → Shift-диапазон после
фильтрации может выбрать не то. Существующий латентный баг, рядом с этой задачей. Дёшево
починить (сбрасывать якорь и при смене фильтров) — включить в it.1 или отдельным фиксом.
Review artifacts (/autoplan, 2026-06-16)
CEO consensus
| # | Вопрос | Claude | Codex | Консенсус |
|---|---|---|---|---|
| 1 | Та ли проблема | да (UX выбора) | да | CONFIRMED |
| 2 | Фронт-физика — регрет? | да, критично | да, критично | CONFIRMED → правило за бэк |
| 3 | Бэкенд уже имеет модель? | да (нашёл chain-логику) | — | подтверждено в коде |
| 4 | Калибровка scope | разбить | разбить | CONFIRMED → it.1 только UX |
Design consensus
| Тема | Claude | Codex | Консенсус |
|---|---|---|---|
| Жест | чип в строке | чип в строке | CONFIRMED |
| Grouping vs sort | sort-aware по StripId | sort-aware по StripId | CONFIRMED |
| Скрытые члены | только видимые | только видимые | CONFIRMED |
| Карта | вторично/отложить | вторично/отложить | CONFIRMED |
| Состояния | матрица | матрица | CONFIRMED |
Eng consensus
| Тема | Claude | Codex | Консенсус |
|---|---|---|---|
| StripId над displaySlots | да | да | CONFIRMED |
| Identity ≠ selection target | да | да | CONFIRMED |
| Memo на [displaySlots] | да | да | CONFIRMED |
| ε: якорь vs предыдущий | оставил открытым | якорь (анти-дрейф) | DISAGREE → решено: якорь |
| Бэкенд-изменения в it.1 | нет | нет | CONFIRMED |
| Сброс якоря на фильтрах | — | да (смежный баг) | флаг оператору |
Decision Audit Trail
| # | Фаза | Решение | Класс | Принцип | Обоснование |
|---|---|---|---|---|---|
| 1 | premise | Правило выполнимости — за бэкенд (не фронт-хардкод) | User Challenge | — | оба ревьюера + бэкенд уже имеет по-КА модель из каталога |
| 2 | premise | it.1 = только UX выбора; правило → it.2 | User Challenge | P2/P3 | развязать безопасный win от спорной физики |
| 3 | design | Жест = чип полоса ×N |
Taste | P5 (явное) | обнаружим, не конфликтует с чек/Shift |
| 4 | design | Grouping sort-aware (рельс в «время», подсветка иначе) | Mechanical | P1 | зебра по строкам врёт вне time-сортировки |
| 5 | design | Чип выбирает только видимые не-BUSY члены | Mechanical | P1 | не выбирать невидимое (доверие счётчику) |
| 6 | eng | StripId над displaySlots, memo на [displaySlots] |
Mechanical | P3 | стабильность id + перф на тысячах строк |
| 7 | eng | ε-сравнение с якорным креном полосы | Mechanical | P1 | анти-дрейф, совпадает с бэкендовым sameRoll |
| 8 | eng | Без бэкенд/DTO-изменений в it.1 | Mechanical | P4 | всё нужное уже в SlotDTO |
| 9 | eng | Карта — без раскраски полос в it.1 | Mechanical | P3/P5 | не расширять scope на модель карты |