b092e50dd8
Замена авто-чипа «полоса ×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 чисто
152 lines
7.6 KiB
TypeScript
152 lines
7.6 KiB
TypeScript
// Группировка слотов в «полосы» (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 };
|
||
}
|