diff --git a/docs/tasks/TASK_slot-strip-selection.md b/docs/tasks/TASK_slot-strip-selection.md index 440d5e2..c1f7a6a 100644 --- a/docs/tasks/TASK_slot-strip-selection.md +++ b/docs/tasks/TASK_slot-strip-selection.md @@ -90,16 +90,35 @@ | disabled chip | N<2 или 0 выбираемых членов | чипа нет | | booked/occupied | как есть (бейдж) | не меняется; в полосу входит визуально, в выбор — нет | -## Реализовано (it.1, 2026-06-17) -- `slotStrips.ts` (`groupStrips`, `ROLL_EPSILON=0.01`, якорный крен, тотальный порядок) + - `slotStrips.test.ts` (11 кейсов: мультиполоса, два КА, граница ε, дрейф крена, порядок, - tie-break, NaN `tn`, пустой вход). -- `toggleStripSelection` в `slotSelection.ts` (чистый, без мутации) + тесты. -- `TguSlotsTab.tsx`: `stripIdByKey` (memo над `displaySlots`), `stripInfo` (видимые члены / - выбираемые не-BUSY по полосе), чип `полоса ×N` в ячейке «КА» (активен при всех выбранных), - рельс по `StripId` (sortMode `time`), подсветка членов по `StripId` при наведении. -- Смежный баг закрыт: `anchorIndexRef` сбрасывается при смене видимого набора (`filteredSlots`). -- Тесты 123 зелёных, `tsc -b` чисто, `vite build` ок. Бэкенд/DTO не тронуты. +## Реализовано (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), diff --git a/services/pcp-tgu-ui-service/src/features/tgu-slots/TguSlotsTab.tsx b/services/pcp-tgu-ui-service/src/features/tgu-slots/TguSlotsTab.tsx index 8ccf294..1cd97b4 100644 --- a/services/pcp-tgu-ui-service/src/features/tgu-slots/TguSlotsTab.tsx +++ b/services/pcp-tgu-ui-service/src/features/tgu-slots/TguSlotsTab.tsx @@ -38,7 +38,7 @@ import { type SlotMask } from "./coverage"; import { applySlotFilters, slotStatusKey, type SlotStatusKey } from "./slotFilters"; -import { selectionAfterCheck, toggleStripSelection } from "./slotSelection"; +import { selectionAfterCheck, selectStripRange } from "./slotSelection"; import { groupStrips } from "./slotStrips"; import { runChunked } from "./runChunked"; import { ChunkProgress } from "./ChunkProgress"; @@ -140,14 +140,12 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) { const wktCacheRef = useRef(new Map()); // Контроллер отмены текущего расчёта/автоподбора — прерывается при повторе или смене заявки. const abortRef = useRef(null); - // Якорь для выбора диапазона по Shift+клику (индекс в filteredSlots). - const anchorIndexRef = useRef(null); + // Якорь жеста «полоса от первого к последнему»: ключ первого кликнутого слота. Состояние (не ref): + // по нему подсвечиваем строки той же полосы, чтобы оператор видел границу выделения. + const [anchorKey, setAnchorKey] = useState(null); // Подсветка строки/контура по клику на карте и центрирование на слоте. const [activeSlotId, setActiveSlotId] = useState(); - // Полоса под курсором — для подсветки всех её членов по StripId (актуально в сортировках - // «крен»/«вклад», где члены полосы не идут подряд). null — курсор вне полос. - const [hoveredStrip, setHoveredStrip] = useState(null); const [focusPoint, setFocusPoint] = useState(); const focusTokenRef = useRef(0); const [focusToken, setFocusToken] = useState(0); @@ -295,34 +293,15 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) { [dayWindowed, statusFilter, satFilter, branchFilter, bookedKeys], ); - // Идентичность полос — над СТАБИЛЬНЫМ набором displaySlots (НЕ filteredSlots/sortedSlots: - // иначе фильтр-дырка ложно разорвала или склеила бы полосу). Map. - const stripIdByKey = useMemo(() => groupStrips(displaySlots, slotKey), [displaySlots]); + // Разметка полос — над СТАБИЛЬНЫМ набором displaySlots (НЕ filteredSlots/sortedSlots: иначе + // фильтр-дырка ложно разорвала или склеила бы полосу). Полоса = один КА + один крен + подряд по + // времени (виток не граница). idByKey — id полосы по ключу слота; membersOf — члены полосы по времени. + const polosa = useMemo(() => groupStrips(displaySlots, slotKey), [displaySlots]); - // По каждой видимой полосе: число видимых членов (N для чипа) и ключи видимых ВЫБИРАЕМЫХ - // (не-BUSY) членов — цель выбора. Считаем над filteredSlots: выбираем/считаем только видимое. - const stripInfo = useMemo(() => { - const visibleCount = new Map(); - const selectableKeys = new Map(); - 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-диапазон после фильтрации выбрал бы не те строки. + // Якорь жеста привязан к ВИДИМОМУ набору: смена фильтров/окна суток/брони делает прежний якорь + // неактуальным → сбрасываем (как при смене сортировки), иначе Shift выделил бы не ту полосу. useEffect(() => { - anchorIndexRef.current = null; + setAnchorKey(null); }, [filteredSlots]); // Загрузка заявок интервала (на карту); вызывается на входе, при смене диапазона и из фильтра. @@ -532,7 +511,7 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) { useEffect(() => { abortRef.current?.abort(); wktCacheRef.current.clear(); - anchorIndexRef.current = null; + setAnchorKey(null); setSlots([]); setSelectedKeys(new Set()); setBookedSlots([]); @@ -579,7 +558,7 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) { abortRef.current?.abort(); const ctrl = new AbortController(); abortRef.current = ctrl; - anchorIndexRef.current = null; + setAnchorKey(null); if (auto) setAutoFilling(true); else setCalculating(true); setError(undefined); @@ -782,13 +761,33 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) { }; const cancelPendingCancel = () => setPendingCancel(false); - // Отметка слота из таблицы. Shift+клик — выбрать диапазон от якоря (предыдущего клика) до - // текущей строки; направление (выбрать/снять) задаётся новым состоянием текущей строки. + // Отметка слота из таблицы. + // • Обычный клик — переключает один слот и ставит его якорем полосы. + // • Shift+клик — жест «полоса от первого к последнему»: выделяет всех членов ОДНОЙ полосы между + // якорем и текущим слотом (включительно). Если текущий слот из другой полосы (другой КА/крен/ + // разрыв по времени) — выбор не меняем и подсказываем: выйти за полосу нельзя. 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) => - 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 (занятые @@ -803,15 +802,7 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) { else for (const slot of selectableVisible) next.add(slotKey(slot)); return next; }); - anchorIndexRef.current = 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; + setAnchorKey(null); }; // Выбрать все слоты расчёта (по всем суткам, кроме занятых бронью), снять весь выбор. @@ -819,7 +810,7 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) { setSelectedKeys(new Set(displaySlots.filter((slot) => !slotIsBusy(slot)).map(slotKey))); const clearSelection = () => { setSelectedKeys(new Set()); - anchorIndexRef.current = null; + setAnchorKey(null); }; // Тогглы фильтров видимости (selected = visible: клик добавляет/убирает значение из множества). @@ -832,7 +823,7 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) { // Смена сортировки меняет позиции строк — якорь Shift-выбора больше не валиден, сбрасываем. const changeSort = (mode: SlotSortMode) => { setSortMode(mode); - anchorIndexRef.current = null; + setAnchorKey(null); }; const toggleStatus = (key: SlotStatusKey) => setStatusFilter((prev) => toggleInSet(prev, key)); const toggleSat = (id: number) => setSatFilter((prev) => toggleInSet(prev, id)); @@ -1213,6 +1204,9 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) { )} + + Полоса: клик + Shift+клик +
- )} - - + {slot.satelliteId} {slot.revolution} / {slot.revolutionSign === "DESC" ? "нисх" : "восх"} /{" "} {slot.cycle} diff --git a/services/pcp-tgu-ui-service/src/features/tgu-slots/slotSelection.test.ts b/services/pcp-tgu-ui-service/src/features/tgu-slots/slotSelection.test.ts index 0f0ca8f..617f364 100644 --- a/services/pcp-tgu-ui-service/src/features/tgu-slots/slotSelection.test.ts +++ b/services/pcp-tgu-ui-service/src/features/tgu-slots/slotSelection.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { chunk, selectionAfterCheck, toggleStripSelection } from "./slotSelection"; +import { chunk, selectionAfterCheck, selectStripRange } from "./slotSelection"; const keyAt = (i: number) => `k${i}`; @@ -42,26 +42,38 @@ describe("selectionAfterCheck", () => { }); }); -describe("toggleStripSelection", () => { - it("добавляет все члены полосы, если выбраны не все", () => { - const next = toggleStripSelection(new Set(["a"]), ["a", "b", "c"]); - expect([...next].sort()).toEqual(["a", "b", "c"]); +describe("selectStripRange", () => { + const members = ["a", "b", "c", "d"]; // в порядке времени + + it("выделяет членов полосы от первого к последнему включительно", () => { + const next = selectStripRange(new Set(), members, "b", "d"); + expect([...next].sort()).toEqual(["b", "c", "d"]); }); - it("снимает все члены, если все уже выбраны", () => { - const next = toggleStripSelection(new Set(["a", "b", "c", "x"]), ["a", "b", "c"]); - expect([...next]).toEqual(["x"]); + it("работает при обратном порядке кликов (последний → первый)", () => { + const next = selectStripRange(new Set(), members, "d", "b"); + expect([...next].sort()).toEqual(["b", "c", "d"]); }); - it("пустой набор членов — выбор без изменений", () => { - const next = toggleStripSelection(new Set(["x"]), []); + it("один и тот же ключ → один член", () => { + 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"]); }); it("не мутирует исходное множество", () => { - const current = new Set(["a"]); - toggleStripSelection(current, ["a", "b"]); - expect([...current]).toEqual(["a"]); + const current = new Set(["x"]); + selectStripRange(current, members, "a", "b"); + expect([...current]).toEqual(["x"]); }); }); diff --git a/services/pcp-tgu-ui-service/src/features/tgu-slots/slotSelection.ts b/services/pcp-tgu-ui-service/src/features/tgu-slots/slotSelection.ts index a3d62c6..2f3b97c 100644 --- a/services/pcp-tgu-ui-service/src/features/tgu-slots/slotSelection.ts +++ b/services/pcp-tgu-ui-service/src/features/tgu-slots/slotSelection.ts @@ -40,25 +40,30 @@ export function selectionAfterCheck( } /** - * Новое множество выбора после клика по чипу «полоса». Поведение зеркалит «выбрать видимые», - * но в рамках одной полосы: если ВСЕ её видимые выбираемые члены уже выбраны — снимаем их, - * иначе добавляем все. Скрытые (отфильтрованные) и BUSY-члены сюда не передаются — выбираем - * только то, что видно и можно бронировать (доверие счётчику «Выбрано»). Вход не мутируется. + * Новое множество выбора после жеста «полоса от первого к последнему»: добавляет всех членов + * полосы между fromKey и toKey включительно (в порядке времени). Это ВЫДЕЛЕНИЕ, не toggle — + * существующий выбор сохраняется. Жест ограничен одной полосой: вызывающий передаёт members + * именно её (один КА, один крен, подряд по времени); граница полосы — гарантия «нельзя выделить + * не полосу». Если fromKey или toKey не из members — выбор не меняется (страховка). Вход не мутируется. * * @param current Текущее множество выбранных ключей. - * @param memberKeys Ключи видимых выбираемых членов полосы. + * @param members Ключи членов полосы в порядке времени. + * @param fromKey Ключ первого клика (якорь). + * @param toKey Ключ второго клика. */ -export function toggleStripSelection( +export function selectStripRange( current: ReadonlySet, - memberKeys: readonly string[], + members: readonly string[], + fromKey: string, + toKey: string, ): Set { const next = new Set(current) - if (memberKeys.length === 0) return next - const allSelected = memberKeys.every((k) => current.has(k)) - for (const k of memberKeys) { - if (allSelected) next.delete(k) - else next.add(k) - } + const from = members.indexOf(fromKey) + const to = members.indexOf(toKey) + if (from < 0 || to < 0) return next + const lo = Math.min(from, to) + const hi = Math.max(from, to) + for (let i = lo; i <= hi; i++) next.add(members[i]!) return next } diff --git a/services/pcp-tgu-ui-service/src/features/tgu-slots/slotStrips.test.ts b/services/pcp-tgu-ui-service/src/features/tgu-slots/slotStrips.test.ts index ebaab99..f3576d9 100644 --- a/services/pcp-tgu-ui-service/src/features/tgu-slots/slotStrips.test.ts +++ b/services/pcp-tgu-ui-service/src/features/tgu-slots/slotStrips.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { ROLL_EPSILON, groupStrips } from "./slotStrips"; +import { ROLL_EPSILON, groupStrips, type StripGrouping } from "./slotStrips"; /** Минимальный слот для группировки. tn задаём как локальную ISO-строку (как отдаёт бэкенд). */ type S = { @@ -13,17 +13,17 @@ type S = { 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 { const tn = `2026-06-17T10:${String(mm).padStart(2, "0")}:00`; const tk = `2026-06-17T10:${String(mm).padStart(2, "0")}:30`; return { satelliteId, slotNumber, cycle, tn, tk, roll }; } -/** Разбиение на полосы как множество множеств ключей — для сравнения, не зависящего от имён StripId. */ -function partition(map: Map): string[][] { +/** Разбиение на полосы как множество множеств ключей — для сравнения, не зависящего от имён PolosaId. */ +function partition(g: StripGrouping): string[][] { const byStrip = new Map(); - for (const [key, strip] of map) { + for (const [key, strip] of g.idByKey) { const arr = byStrip.get(strip) ?? []; arr.push(key); byStrip.set(strip, arr); @@ -32,62 +32,78 @@ function partition(map: Map): string[][] { } describe("groupStrips", () => { - it("пустой вход → пустая Map", () => { - expect(groupStrips([], keyOf).size).toBe(0); + it("пустой вход → пустая разметка", () => { + const g = groupStrips([], keyOf); + expect(g.idByKey.size).toBe(0); + expect(g.membersOf.size).toBe(0); }); it("один слот → своя полоса", () => { - const map = groupStrips([slot(1, 1, 5, 0)], keyOf); - expect(map.get("1:1:1")).toBe("strip:1:1:1"); + const g = groupStrips([slot(1, 1, 5, 0)], keyOf); + 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 = [ - slot(1, 1, 5, 0), - slot(1, 2, 5, 1), - slot(1, 3, 20, 2), // крен изменился → новая полоса - slot(1, 4, 20, 3), + slot(1, 1, 36.5, 0), + slot(1, 2, 39.5, 0), + slot(1, 3, 36.5, 1), + slot(1, 4, 39.5, 1), ]; expect(partition(groupStrips(slots, keyOf))).toEqual([ - ["1:1:1", "1:2:1"], - ["1:3:1", "1:4:1"], + ["1:1:1", "1:3:1"], // крен 36.5 вдоль времени + ["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 = [ slot(1, 1, 5, 0), - slot(2, 1, 5, 0), 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([ ["1:1:1", "1: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); 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"]]); }); - 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("устойчивость к порядку входа: разбиение одинаково при перемешивании", () => { - const slots = [ - slot(1, 1, 5, 0), - slot(1, 2, 5, 1), - slot(1, 3, 20, 2), - slot(2, 1, 5, 0), - ]; + const slots = [slot(1, 1, 5, 0), slot(1, 2, 5, 1), slot(1, 3, 20, 0), slot(2, 1, 5, 0)]; const a = partition(groupStrips(slots, keyOf)); const b = partition(groupStrips([...slots].reverse(), keyOf)); const c = partition(groupStrips([slots[2]!, slots[0]!, slots[3]!, slots[1]!], keyOf)); @@ -95,28 +111,19 @@ describe("groupStrips", () => { expect(c).toEqual(a); }); - it("StripId стабилен = strip:${ключ первого по времени члена}", () => { - const map = groupStrips([slot(1, 2, 5, 1), slot(1, 1, 5, 0)], keyOf); - // первый по времени — slotNumber 1 (mm=0); оба члена несут его StripId. - expect(map.get("1:1:1")).toBe("strip:1:1:1"); - expect(map.get("1:2:1")).toBe("strip:1:1:1"); + it("PolosaId = polosa:${ключ первого по времени члена}; membersOf — в порядке времени", () => { + const g = groupStrips([slot(1, 2, 5, 1), slot(1, 1, 5, 0)], keyOf); + expect(g.idByKey.get("1:1:1")).toBe("polosa:1:1:1"); + expect(g.idByKey.get("1:2:1")).toBe("polosa:1:1:1"); + expect(g.membersOf.get("polosa:1:1:1")).toEqual(["1:1:1", "1:2:1"]); }); - it("tie-break при равном tn: устойчивая группировка по slotNumber/cycle/ключу", () => { - 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 → синглтон без падения, не клеит соседей", () => { + it("NaN tn → синглтон без падения, не клеит и не рвёт валидных соседей", () => { 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 map = groupStrips(slots, keyOf); - expect(map.get("1:9:1")).toBe("strip:1:9:1"); - // валидные соседи — своя полоса, битый слот их не склеивает и не рвёт между собой. - expect(map.get("1:1:1")).toBe("strip:1:1:1"); - expect(map.get("1:2:1")).toBe("strip:1:1:1"); + const g = groupStrips(slots, keyOf); + 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(g.idByKey.get("1:2:1")).toBe("polosa:1:1:1"); }); }); diff --git a/services/pcp-tgu-ui-service/src/features/tgu-slots/slotStrips.ts b/services/pcp-tgu-ui-service/src/features/tgu-slots/slotStrips.ts index c69474c..8899b10 100644 --- a/services/pcp-tgu-ui-service/src/features/tgu-slots/slotStrips.ts +++ b/services/pcp-tgu-ui-service/src/features/tgu-slots/slotStrips.ts @@ -1,53 +1,94 @@ -// Группировка слотов в «полосы» (strips) — максимальные цепочки идущих подряд по времени -// слотов одного КА с одним креном. Чистая логика под юнит-тесты (см. slotStrips.test.ts, -// образец стиля slotSelection.ts / coverage.ts). Используется UI для жеста «выбрать полосу» -// и визуальной группировки строк таблицы. +// Группировка слотов в «полосы» (strips) для ручного выбора полосы мышью. // -// Это ТОЛЬКО identity-группировка (какой слот к какой полосе относится), НЕ проверка -// выполнимости брони. Проверка манёвренности КА (mmi/maxSurveyDuration/maxChainLength) живёт -// в slots-service по-КА из каталога и будет вынесена в бэкенд validate-эндпоинт (iteration 2), -// чтобы не дублировать физику на клиенте. +// Полоса = слоты ОДНОГО КА + ОДИН крен (с допуском ROLL_EPSILON) + ПОДРЯД по времени (без разрыва +// каденции). Виток/цикл в границу НЕ входят: у экватора номер витка меняется, а слоты остаются в +// одной полосе, поэтому группируем только по (КА, крен, непрерывность по времени). +// +// Это ТОЛЬКО identity-группировка (какой слот к какой полосе относится), НЕ проверка выполнимости +// брони. Проверка манёвренности КА (mmi/maxSurveyDuration/maxChainLength/паузы) живёт в slots-service +// по-КА из каталога и будет вынесена в бэкенд validate-эндпоинт (iteration 2), чтобы не дублировать +// физику на клиенте. import type { SlotDTO } from "../../model/slotTypes"; 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; + membersOf: Map; +}; /** Минимальный набор полей слота, нужный для группировки. */ type Stripable = Pick; -/** Сравнение чисел с детерминированным порядком; NaN — в конец (устойчивость к битому tn). */ -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; -} +type Item = { key: string; sat: number; roll: number; tnMs: number }; function cmpStr(a: string, b: string): number { 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. Полоса — максимальный run - * идущих подряд по времени слотов ОДНОГО КА с креном, отличающимся от якорного крена полосы - * не более чем на ROLL_EPSILON (сравнение с якорем, а не с предыдущим слотом — иначе дрейф - * крена 0.00→0.009→0.018 ошибочно склеил бы всё в одну полосу). Совпадает с бэкендовым - * `sameRoll(roll, chainRoll)`. + * Делит кластер слотов одного крена (одного КА) на полосы по непрерывности времени: сортирует по + * времени и режет там, где разрыв заметно больше типичной каденции (медиана шагов × CADENCE_GAP_FACTOR). + * До двух членов каденцию определить нельзя — считаем их одной полосой. + */ +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 keyOf Аксессор ключа слота (как `keyAt` в selectionAfterCheck) — чтобы не дублировать @@ -56,53 +97,55 @@ function cmpStr(a: string, b: string): number { export function groupStrips( slots: readonly T[], keyOf: (slot: T) => string, -): Map { - const result = new Map(); - if (slots.length === 0) return result; +): StripGrouping { + const idByKey = new Map(); + const membersOf = new Map(); + if (slots.length === 0) return { idByKey, membersOf }; - const items = slots.map((slot) => ({ - slot, - key: keyOf(slot), - tnMs: parseLocal(slot.tn), - tkMs: parseLocal(slot.tk), - })); + const assign = (members: readonly Item[]) => { + 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); + }; - items.sort( - (a, b) => - cmpNum(a.slot.satelliteId, b.slot.satelliteId) || - cmpNum(a.tnMs, b.tnMs) || - cmpNum(a.tkMs, b.tkMs) || - cmpNum(a.slot.slotNumber, b.slot.slotNumber) || - 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 item of items) { - const tnBad = Number.isNaN(item.tnMs); - const continues = - stripId != null && - !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!); - - // Слот с непарсящимся tn — синглтон: следующий слот должен начать свежую полосу, - // а не приклеиться к этому якорю. - if (tnBad) { - stripId = null; - anchorSat = null; + const bySat = new Map(); + for (const slot of slots) { + const item: Item = { + key: keyOf(slot), + sat: slot.satelliteId, + roll: slot.roll, + tnMs: parseLocal(slot.tn), + }; + // Слот без валидного времени не встроить в полосу по непрерывности — отдельная полоса-синглтон. + if (Number.isNaN(item.tnMs)) { + assign([item]); + continue; } + const arr = bySat.get(item.sat) ?? []; + arr.push(item); + bySat.set(item.sat, arr); } - return result; + 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) { + if (cluster.length > 0 && item.roll - prevRoll > ROLL_EPSILON) { + flush(); + cluster = []; + } + cluster.push(item); + prevRoll = item.roll; + } + flush(); + } + + return { idByKey, membersOf }; } diff --git a/services/pcp-tgu-ui-service/src/styles/tgu-planning.css b/services/pcp-tgu-ui-service/src/styles/tgu-planning.css index a5a0b7e..17e8a2e 100644 --- a/services/pcp-tgu-ui-service/src/styles/tgu-planning.css +++ b/services/pcp-tgu-ui-service/src/styles/tgu-planning.css @@ -1305,49 +1305,22 @@ background: rgba(104, 195, 189, 0.36); } -/* Ячейка «КА»: номер КА + чип выбора полосы рядом. */ -.tgu-slots-sat { - display: inline-flex; - align-items: center; - gap: 0.4rem; +/* Строки таблицы — поверхность жеста выбора полосы (клик + Shift+клик), поэтому гасим выделение + текста, чтобы Shift+клик не подсвечивал текст между строками. */ +.tgu-slots-table tbody { + user-select: none; } -/* Чип «полоса ×N» — выбрать всю полосу (слоты одного крена подряд) одним кликом. - Нейтральный по умолчанию; .is-on — когда вся полоса уже выбрана. */ -.tgu-slots-strip-chip { - display: inline-flex; - 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), это норма. */ +/* Рельс полосы текущего якоря: приглушённая цветная полоса слева у всех членов полосы, в которой + стоит якорь жеста «полоса от первого к последнему». Оттенок детерминирован PolosaId (--strip-hue). + Так оператор видит границу полосы — что выделит Shift+клик и за что выйти нельзя. У активной + строки рельс перекрывается акцентным (is-active), это норма. */ .tgu-slots-table tbody tr.tgu-slots-row--strip { 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); }