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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user