feat(tgu-ui): выбор полосы слотов мышью «от первого к последнему»

Замена авто-чипа «полоса ×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 чисто
This commit is contained in:
Дмитрий Соловьев
2026-06-17 10:13:11 +03:00
parent 8faaa03af3
commit b092e50dd8
7 changed files with 322 additions and 296 deletions
@@ -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<string, PolosaId>;
membersOf: Map<PolosaId, 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;
}
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<slotKey, StripId>. Полоса — максимальный 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<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;
): StripGrouping {
const idByKey = new Map<string, PolosaId>();
const membersOf = new Map<PolosaId, string[]>();
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<number, Item[]>();
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 };
}