Files
dc-observatio/docs/tasks/TASK_slot-strip-selection.md
T
Дмитрий Соловьев b092e50dd8 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 чисто
2026-06-17 10:13:11 +03:00

20 KiB
Raw Blame History

Эргономика выбора слотов: выбор «полосами» + правило выполнимости брони

Проблема (как сформулировал оператор)

Сделана работа по сортировке, подсветке брони, показу забронированных, расчёту % покрытия. Но сам выбор слотов под бронь по-прежнему неудобен: сейчас оператор отмечает слоты галочками поштучно (или Shift+клик диапазоном, или «все видимые / все сутки»). На сотнях-тысячах слотов это утомительно. Плюс выбор «слепой» — он не знает, можно ли вообще физически отснять выбранный набор (манёвренность КА).

Решённые premise (premise-гейт, 2026-06-16, скорректировано после CEO-ревью)

  1. Ограничение брони через смену крена реально существует (агилити КА).
  2. КОРРЕКЦИЯ: модель манёвренности УЖЕ есть в slots-service, по-КА из каталога (mmi, maxSurveyDuration, maxChainLength, ROLL_EPSILON=0.01 — см. ниже). Ручная бронь (bookReq) её не проверяет, но coverage-solver и buildSurveyChains её соблюдают. Поэтому не хардкодим физику на фронте — проверка выполнимости будет бэкенд validate-эндпоинтом, переиспользующим эти по-КА параметры.
  3. 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.ts selectionAfterCheck), «выбрать видимые», «все сутки», «снять выбор».
  • Слот несёт 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: вместо toggleStripSelectionselectStripRange(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; NaN tn → синглтон без падения; пустой вход → пустая 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 на модель карты