feat(tgu-ui): #50 слой поддержки решений по слотам + выбор полосами

Поверх ручного бронирования — информационный слой (достаточность судит оператор).

M1 покрытие: coverage.ts (растровое Monte-Carlo % покрытия заявки, чистые функции
  + coverage.test.ts), CoveragePanel (живой % + бар), слой coverage на карте
  (drawCoverage, union контуров, клип по заявке).
M2 эргономика: сортировка списка по вкладу в покрытие (жадный маргинальный прирост),
  по крену; колонка «Вклад» (+N%).
M3 конфликты: бейдж держателя BUSY-слота (заявка + важность) из holders в SlotDTO.

Выбор полосами (TASK_slot-strip-selection it.1): slotStrips.ts (groupStrips —
  same-roll цепочки по-КА, ε=0.01 с якорным креном, тотальный порядок) +
  slotStrips.test.ts; toggleStripSelection в slotSelection.ts; чип «полоса ×N» в
  строке, рельс по StripId в сортировке «время», подсветка полосы при наведении.
  Физику выполнимости не дублируем на фронте — это backend validate (it.2).

Смежный баг: anchorIndexRef (якорь Shift-выбора) сбрасывается при смене видимого
набора (фильтры/окно суток), не только сортировки.
This commit is contained in:
Дмитрий Соловьев
2026-06-17 09:06:25 +03:00
parent 20d9eb9579
commit 1f44c1a5bb
15 changed files with 1473 additions and 65 deletions
@@ -0,0 +1,108 @@
// Группировка слотов в «полосы» (strips) — максимальные цепочки идущих подряд по времени
// слотов одного КА с одним креном. Чистая логика под юнит-тесты (см. slotStrips.test.ts,
// образец стиля slotSelection.ts / coverage.ts). Используется UI для жеста «выбрать полосу»
// и визуальной группировки строк таблицы.
//
// Это ТОЛЬКО 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 — крупный
* допуск ошибочно склеивал бы разные нацеливания.
*/
export const ROLL_EPSILON = 0.01;
/** Идентификатор полосы. Стабилен и читаем: `strip:${slotKey первого члена}` (не счётчик,
* который зависел бы от порядка обхода). */
export type StripId = string;
/** Минимальный набор полей слота, нужный для группировки. */
type Stripable = Pick<SlotDTO, "satelliteId" | "slotNumber" | "cycle" | "tn" | "tk" | "roll">;
/** Сравнение чисел с детерминированным порядком; 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;
}
function cmpStr(a: string, b: string): number {
return a < b ? -1 : a > b ? 1 : 0;
}
/**
* Размечает слоты по полосам: возвращает Map<slotKey, StripId>. Полоса — максимальный run
* идущих подряд по времени слотов ОДНОГО КА с креном, отличающимся от якорного крена полосы
* не более чем на ROLL_EPSILON (сравнение с якорем, а не с предыдущим слотом — иначе дрейф
* крена 0.00→0.009→0.018 ошибочно склеил бы всё в одну полосу). Совпадает с бэкендовым
* `sameRoll(roll, chainRoll)`.
*
* Детерминизм: результат не зависит от порядка входного массива — внутри тотальная сортировка
* по `(satelliteId, tn, tk, slotNumber, cycle, slotKey)`.
*
* @param slots Слоты для разметки (любой порядок).
* @param keyOf Аксессор ключа слота (как `keyAt` в selectionAfterCheck) — чтобы не дублировать
* формулу `${satelliteId}:${slotNumber}:${cycle}`.
*/
export function groupStrips<T extends Stripable>(
slots: readonly T[],
keyOf: (slot: T) => string,
): Map<string, StripId> {
const result = new Map<string, StripId>();
if (slots.length === 0) return result;
const items = slots.map((slot) => ({
slot,
key: keyOf(slot),
tnMs: parseLocal(slot.tn),
tkMs: parseLocal(slot.tk),
}));
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;
}
}
return result;
}