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,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ROLL_EPSILON, groupStrips } from "./slotStrips";
|
||||
import { ROLL_EPSILON, groupStrips, type StripGrouping } from "./slotStrips";
|
||||
|
||||
/** Минимальный слот для группировки. tn задаём как локальную ISO-строку (как отдаёт бэкенд). */
|
||||
type S = {
|
||||
@@ -13,17 +13,17 @@ type S = {
|
||||
|
||||
const keyOf = (s: S) => `${s.satelliteId}:${s.slotNumber}:${s.cycle}`;
|
||||
|
||||
/** Слот на минуту mm (от базовой даты) с длительностью 30 c. */
|
||||
/** Слот на минуту mm (от базовой даты) с длительностью 30 c. cycle ≈ «виток» (в границу не входит). */
|
||||
function slot(satelliteId: number, slotNumber: number, roll: number, mm: number, cycle = 1): S {
|
||||
const tn = `2026-06-17T10:${String(mm).padStart(2, "0")}:00`;
|
||||
const tk = `2026-06-17T10:${String(mm).padStart(2, "0")}:30`;
|
||||
return { satelliteId, slotNumber, cycle, tn, tk, roll };
|
||||
}
|
||||
|
||||
/** Разбиение на полосы как множество множеств ключей — для сравнения, не зависящего от имён StripId. */
|
||||
function partition(map: Map<string, string>): string[][] {
|
||||
/** Разбиение на полосы как множество множеств ключей — для сравнения, не зависящего от имён PolosaId. */
|
||||
function partition(g: StripGrouping): string[][] {
|
||||
const byStrip = new Map<string, string[]>();
|
||||
for (const [key, strip] of map) {
|
||||
for (const [key, strip] of g.idByKey) {
|
||||
const arr = byStrip.get(strip) ?? [];
|
||||
arr.push(key);
|
||||
byStrip.set(strip, arr);
|
||||
@@ -32,62 +32,78 @@ function partition(map: Map<string, string>): string[][] {
|
||||
}
|
||||
|
||||
describe("groupStrips", () => {
|
||||
it("пустой вход → пустая Map", () => {
|
||||
expect(groupStrips([], keyOf).size).toBe(0);
|
||||
it("пустой вход → пустая разметка", () => {
|
||||
const g = groupStrips([], keyOf);
|
||||
expect(g.idByKey.size).toBe(0);
|
||||
expect(g.membersOf.size).toBe(0);
|
||||
});
|
||||
|
||||
it("один слот → своя полоса", () => {
|
||||
const map = groupStrips([slot(1, 1, 5, 0)], keyOf);
|
||||
expect(map.get("1:1:1")).toBe("strip:1:1:1");
|
||||
const g = groupStrips([slot(1, 1, 5, 0)], keyOf);
|
||||
expect(g.idByKey.get("1:1:1")).toBe("polosa:1:1:1");
|
||||
expect(g.membersOf.get("polosa:1:1:1")).toEqual(["1:1:1"]);
|
||||
});
|
||||
|
||||
it("мультиполоса в одном КА: смена крена начинает новую полосу", () => {
|
||||
it("веер кренов в один момент → разные полосы; равный крен вдоль времени → одна полоса", () => {
|
||||
// КА 1: два нацеливания (крен 36.5 и 39.5, шаг 3° > ε), каждое снято в моменты mm=0 и mm=1.
|
||||
const slots = [
|
||||
slot(1, 1, 5, 0),
|
||||
slot(1, 2, 5, 1),
|
||||
slot(1, 3, 20, 2), // крен изменился → новая полоса
|
||||
slot(1, 4, 20, 3),
|
||||
slot(1, 1, 36.5, 0),
|
||||
slot(1, 2, 39.5, 0),
|
||||
slot(1, 3, 36.5, 1),
|
||||
slot(1, 4, 39.5, 1),
|
||||
];
|
||||
expect(partition(groupStrips(slots, keyOf))).toEqual([
|
||||
["1:1:1", "1:2:1"],
|
||||
["1:3:1", "1:4:1"],
|
||||
["1:1:1", "1:3:1"], // крен 36.5 вдоль времени
|
||||
["1:2:1", "1:4:1"], // крен 39.5 вдоль времени
|
||||
]);
|
||||
});
|
||||
|
||||
it("два КА вперемешку по времени не склеиваются (даже при одном крене)", () => {
|
||||
it("смена витка (cycle) внутри одного крена и каденции НЕ рвёт полосу", () => {
|
||||
const slots = [
|
||||
slot(1, 1, 5, 0, 1),
|
||||
slot(1, 2, 5, 1, 1),
|
||||
slot(1, 3, 5, 2, 2), // виток сменился у экватора — но это та же полоса
|
||||
slot(1, 4, 5, 3, 2),
|
||||
];
|
||||
expect(partition(groupStrips(slots, keyOf))).toEqual([
|
||||
["1:1:1", "1:2:1", "1:3:2", "1:4:2"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("большой разрыв по времени рвёт полосу на две (другой проход)", () => {
|
||||
const slots = [
|
||||
slot(1, 1, 5, 0),
|
||||
slot(2, 1, 5, 0),
|
||||
slot(1, 2, 5, 1),
|
||||
slot(2, 2, 5, 1),
|
||||
slot(1, 3, 5, 2),
|
||||
slot(1, 4, 5, 3),
|
||||
slot(1, 5, 5, 30), // разрыв ≫ каденции (1 мин) → новый проход
|
||||
slot(1, 6, 5, 31),
|
||||
slot(1, 7, 5, 32),
|
||||
];
|
||||
expect(partition(groupStrips(slots, keyOf))).toEqual([
|
||||
["1:1:1", "1:2:1", "1:3:1", "1:4:1"],
|
||||
["1:5:1", "1:6:1", "1:7:1"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("разные КА — разные полосы (даже при одном крене и времени)", () => {
|
||||
const slots = [slot(1, 1, 5, 0), slot(2, 1, 5, 0), slot(1, 2, 5, 1), slot(2, 2, 5, 1)];
|
||||
expect(partition(groupStrips(slots, keyOf))).toEqual([
|
||||
["1:1:1", "1:2:1"],
|
||||
["2:1:1", "2:2:1"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("граница ε: diff == 0.01 склеивает, 0.0101 рвёт", () => {
|
||||
it("граница ε по крену: разрыв == 0.5 склеивает, 0.6 рвёт", () => {
|
||||
const same = groupStrips([slot(1, 1, 5.0, 0), slot(1, 2, 5 + ROLL_EPSILON, 1)], keyOf);
|
||||
expect(partition(same)).toEqual([["1:1:1", "1:2:1"]]);
|
||||
|
||||
const broken = groupStrips([slot(1, 1, 5.0, 0), slot(1, 2, 5 + ROLL_EPSILON + 0.0001, 1)], keyOf);
|
||||
const broken = groupStrips([slot(1, 1, 5.0, 0), slot(1, 2, 5 + ROLL_EPSILON + 0.1, 1)], keyOf);
|
||||
expect(partition(broken)).toEqual([["1:1:1"], ["1:2:1"]]);
|
||||
});
|
||||
|
||||
it("дрейф крена 0.00/0.009/0.018 (сравнение с якорем) → две полосы, не одна", () => {
|
||||
const slots = [slot(1, 1, 0.0, 0), slot(1, 2, 0.009, 1), slot(1, 3, 0.018, 2)];
|
||||
// 0.009−0.0 ≤ ε → в полосе; 0.018−0.0 > ε → новая полоса (а не приклеивание к 0.009).
|
||||
expect(partition(groupStrips(slots, keyOf))).toEqual([["1:1:1", "1:2:1"], ["1:3:1"]]);
|
||||
});
|
||||
|
||||
it("устойчивость к порядку входа: разбиение одинаково при перемешивании", () => {
|
||||
const slots = [
|
||||
slot(1, 1, 5, 0),
|
||||
slot(1, 2, 5, 1),
|
||||
slot(1, 3, 20, 2),
|
||||
slot(2, 1, 5, 0),
|
||||
];
|
||||
const slots = [slot(1, 1, 5, 0), slot(1, 2, 5, 1), slot(1, 3, 20, 0), slot(2, 1, 5, 0)];
|
||||
const a = partition(groupStrips(slots, keyOf));
|
||||
const b = partition(groupStrips([...slots].reverse(), keyOf));
|
||||
const c = partition(groupStrips([slots[2]!, slots[0]!, slots[3]!, slots[1]!], keyOf));
|
||||
@@ -95,28 +111,19 @@ describe("groupStrips", () => {
|
||||
expect(c).toEqual(a);
|
||||
});
|
||||
|
||||
it("StripId стабилен = strip:${ключ первого по времени члена}", () => {
|
||||
const map = groupStrips([slot(1, 2, 5, 1), slot(1, 1, 5, 0)], keyOf);
|
||||
// первый по времени — slotNumber 1 (mm=0); оба члена несут его StripId.
|
||||
expect(map.get("1:1:1")).toBe("strip:1:1:1");
|
||||
expect(map.get("1:2:1")).toBe("strip:1:1:1");
|
||||
it("PolosaId = polosa:${ключ первого по времени члена}; membersOf — в порядке времени", () => {
|
||||
const g = groupStrips([slot(1, 2, 5, 1), slot(1, 1, 5, 0)], keyOf);
|
||||
expect(g.idByKey.get("1:1:1")).toBe("polosa:1:1:1");
|
||||
expect(g.idByKey.get("1:2:1")).toBe("polosa:1:1:1");
|
||||
expect(g.membersOf.get("polosa:1:1:1")).toEqual(["1:1:1", "1:2:1"]);
|
||||
});
|
||||
|
||||
it("tie-break при равном tn: устойчивая группировка по slotNumber/cycle/ключу", () => {
|
||||
const a = slot(1, 1, 5, 0);
|
||||
const b = slot(1, 2, 5, 0); // тот же tn/tk
|
||||
const map = groupStrips([b, a], keyOf);
|
||||
expect(partition(map)).toEqual([["1:1:1", "1:2:1"]]);
|
||||
expect(map.get("1:1:1")).toBe("strip:1:1:1"); // меньший ключ — якорь
|
||||
});
|
||||
|
||||
it("NaN tn → синглтон без падения, не клеит соседей", () => {
|
||||
it("NaN tn → синглтон без падения, не клеит и не рвёт валидных соседей", () => {
|
||||
const broken: S = { satelliteId: 1, slotNumber: 9, cycle: 1, tn: "битый", tk: "битый", roll: 5 };
|
||||
const slots = [slot(1, 1, 5, 0), broken, slot(1, 2, 5, 1)];
|
||||
const map = groupStrips(slots, keyOf);
|
||||
expect(map.get("1:9:1")).toBe("strip:1:9:1");
|
||||
// валидные соседи — своя полоса, битый слот их не склеивает и не рвёт между собой.
|
||||
expect(map.get("1:1:1")).toBe("strip:1:1:1");
|
||||
expect(map.get("1:2:1")).toBe("strip:1:1:1");
|
||||
const g = groupStrips(slots, keyOf);
|
||||
expect(g.idByKey.get("1:9:1")).toBe("polosa:1:9:1");
|
||||
expect(g.idByKey.get("1:1:1")).toBe("polosa:1:1:1");
|
||||
expect(g.idByKey.get("1:2:1")).toBe("polosa:1:1:1");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user