Files
dc-observatio/services/pcp-tgu-ui-service/src/features/tgu-slots/slotStrips.ts
T
Дмитрий Соловьев b092e50dd8 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 чисто
2026-06-17 10:13:11 +03:00

152 lines
7.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Группировка слотов в «полосы» (strips) для ручного выбора полосы мышью.
//
// Полоса = слоты ОДНОГО КА + ОДИН крен (с допуском ROLL_EPSILON) + ПОДРЯД по времени (без разрыва
// каденции). Виток/цикл в границу НЕ входят: у экватора номер витка меняется, а слоты остаются в
// одной полосе, поэтому группируем только по (КА, крен, непрерывность по времени).
//
// Это ТОЛЬКО identity-группировка (какой слот к какой полосе относится), НЕ проверка выполнимости
// брони. Проверка манёвренности КА (mmi/maxSurveyDuration/maxChainLength/паузы) живёт в slots-service
// по-КА из каталога и будет вынесена в бэкенд validate-эндпоинт (iteration 2), чтобы не дублировать
// физику на клиенте.
import type { SlotDTO } from "../../model/slotTypes";
import { parseLocal } from "../../utils/localTime";
/**
* Допуск сравнения крена (градусы): слоты считаются «одним креном», если разрыв по крену между
* соседями кластера ≤ ROLL_EPSILON. Подобран по реальным данным — «веер» нацеливаний идёт с шагом
* ~3°, поэтому 0.5° надёжно разделяет соседние нацеливания и терпит дрейф крена внутри полосы.
*/
export const ROLL_EPSILON = 0.5;
/**
* Во сколько раз разрыв по времени должен превысить типичную каденцию полосы, чтобы считаться
* пропуском и разорвать полосу на две. Полоса — это слоты подряд (через равные шаги по времени);
* заметно больший разрыв означает «другой проход», т.е. другую полосу.
*/
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">;
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;
}
/**
* Делит кластер слотов одного крена (одного КА) на полосы по непрерывности времени: сортирует по
* времени и режет там, где разрыв заметно больше типичной каденции (медиана шагов × 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), идущие подряд по времени (разрыв каденции рвёт полосу). Виток/цикл не
* учитываются — полоса может пересекать смену витка.
*
* Детерминизм: результат не зависит от порядка входного массива — внутри сортируем по крену и времени,
* id полосы = ключ её первого по времени члена.
*
* @param slots Слоты для разметки (любой порядок).
* @param keyOf Аксессор ключа слота (как `keyAt` в selectionAfterCheck) — чтобы не дублировать
* формулу `${satelliteId}:${slotNumber}:${cycle}`.
*/
export function groupStrips<T extends Stripable>(
slots: readonly T[],
keyOf: (slot: T) => string,
): StripGrouping {
const idByKey = new Map<string, PolosaId>();
const membersOf = new Map<PolosaId, string[]>();
if (slots.length === 0) return { idByKey, membersOf };
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);
};
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);
}
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 };
}