Files
dc-observatio/docs/tasks/TASK_slot-strip-selection.md

209 lines
21 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- /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.
Доработки (2026-06-17):
- **Shift+клик ближе к якорю «сжимает» выделение.** `selectStripRange` теперь задаёт выбор полосы
РОВНО диапазоном [from..to]: члены полосы вне диапазона снимаются, выбор в других полосах сохраняется.
Якорь при Shift+клике не двигается (остаётся первым кликом) — можно тянуть конец туда-обратно.
- **Жест выбора полосы мышью на карте (Shift+drag).** Слоты на карте уже кликабельные полигоны
(`slot:${key}` в spatial-index). Карта на жесте отдаёт наверх ключи слотов под курсором
(`onStripAnchor`/`onStripDrag`/`onStripCommit`), а полосу/диапазон считает `TguSlotsTab`
(`pickStripAnchor` — самый ранний по времени слот под курсором = начало; `pickStripEnd` — самый
поздний член ТОЙ ЖЕ полосы под курсором = конец; за границу полосы выделение не уходит). Обычный
drag = панорама, Shift+drag по слоту = выбор полосы (без отдельной кнопки-режима). Подсветка
диапазона живая — через `markedObjectIds` (SVG-оверлей `is-marked`), отдельный слой превью не нужен.
## Решение — 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 на модель карты |