b092e50dd8
Замена авто-чипа «полоса ×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 чисто
197 lines
20 KiB
Markdown
197 lines
20 KiB
Markdown
<!-- /autoplan restore point: /home/dasolovyev/.gstack/projects/missionis-observatio-terrae/feat-tgu-slots-filters-autoplan-restore-20260616-165024.md -->
|
||
|
||
# Эргономика выбора слотов: выбор «полосами» + правило выполнимости брони
|
||
|
||
## Проблема (как сформулировал оператор)
|
||
Сделана работа по сортировке, подсветке брони, показу забронированных, расчёту %
|
||
покрытия. Но **сам выбор слотов под бронь по-прежнему неудобен**: сейчас оператор
|
||
отмечает слоты галочками поштучно (или 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](../../services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/service/SatelliteCatalogClient.kt#L116),
|
||
[AbstractSatellite.kt:25](../../services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/model/satellite/AbstractSatellite.kt#L25)):
|
||
`mmi`=10с (мин. интервал маневра, тот же крен), `maxSurveyDuration`=300с,
|
||
`maxChainLength`=30 слотов, `ROLL_EPSILON`=0.01.
|
||
- `buildSurveyChains` / `canMergeInTime` / `sameRoll` ([SlotService.kt:2151](../../services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/service/SlotService.kt#L2151))
|
||
— склейка цепочек по-КА (для отрисовки), `OccupiedIntervalPrefilter` — mmi/maxSurveyDuration.
|
||
- **Несоответствия исходного черновика** (закрыты решением «правило за бэком»): фронтовые
|
||
150с ≠ бэкендовые 300с; фронтовый ε=0.1° ≠ бэкендовый 0.01; «30с на смену крена» —
|
||
в бэке отдельной такой константы НЕТ (склейка только при том же крене). Эти числа
|
||
сверяются с каталогом/баллистиками в it.2, НЕ хардкодятся.
|
||
|
||
## Что уже есть (карта на существующий код)
|
||
- Выбор: `selectedKeys: Set<slotKey>` в [TguSlotsTab.tsx](../../services/pcp-tgu-ui-service/src/features/tgu-slots/TguSlotsTab.tsx).
|
||
Поштучный чек, Shift+клик-диапазон ([slotSelection.ts](../../services/pcp-tgu-ui-service/src/features/tgu-slots/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](../../services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/service/SlotService.kt#L2500)). В 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`; 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 на модель карты |
|