// Группировка слотов в «полосы» (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; membersOf: Map; }; /** Минимальный набор полей слота, нужный для группировки. */ type Stripable = Pick; 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( slots: readonly T[], keyOf: (slot: T) => string, ): StripGrouping { const idByKey = new Map(); const membersOf = new Map(); 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(); 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 }; }