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