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:
@@ -90,16 +90,35 @@
|
||||
| disabled chip | N<2 или 0 выбираемых членов | чипа нет |
|
||||
| booked/occupied | как есть (бейдж) | не меняется; в полосу входит визуально, в выбор — нет |
|
||||
|
||||
## Реализовано (it.1, 2026-06-17)
|
||||
- `slotStrips.ts` (`groupStrips`, `ROLL_EPSILON=0.01`, якорный крен, тотальный порядок) +
|
||||
`slotStrips.test.ts` (11 кейсов: мультиполоса, два КА, граница ε, дрейф крена, порядок,
|
||||
tie-break, NaN `tn`, пустой вход).
|
||||
- `toggleStripSelection` в `slotSelection.ts` (чистый, без мутации) + тесты.
|
||||
- `TguSlotsTab.tsx`: `stripIdByKey` (memo над `displaySlots`), `stripInfo` (видимые члены /
|
||||
выбираемые не-BUSY по полосе), чип `полоса ×N` в ячейке «КА» (активен при всех выбранных),
|
||||
рельс по `StripId` (sortMode `time`), подсветка членов по `StripId` при наведении.
|
||||
- Смежный баг закрыт: `anchorIndexRef` сбрасывается при смене видимого набора (`filteredSlots`).
|
||||
- Тесты 123 зелёных, `tsc -b` чисто, `vite build` ок. Бэкенд/DTO не тронуты.
|
||||
## Реализовано (it.1, 2026-06-17 — пересмотрено)
|
||||
|
||||
ВАЖНО: первая версия it.1 (авто-чип «полоса ×N» по равному крену) оказалась не той фичей и
|
||||
переделана. На реальных данных полоса по «подряд по времени + равный крен» не формировалась
|
||||
(в один момент идёт «веер» кренов с шагом ~3°, равный крен повторяется не подряд, а вдоль трассы
|
||||
через каденцию). Оператор хотел не авто-магию, а ручной выбор полосы мышью «от первого к последнему»
|
||||
с запретом выйти за полосу.
|
||||
|
||||
Переопределение полосы (согласовано с оператором):
|
||||
- **полоса = один КА + один крен (допуск `ROLL_EPSILON`) + подряд по времени (без разрыва каденции);**
|
||||
- **виток/цикл в границу НЕ входят** — у экватора номер витка меняется, а слоты остаются в одной полосе.
|
||||
|
||||
Сделано:
|
||||
- `slotStrips.ts`: `groupStrips(slots, keyOf) → { idByKey, membersOf }`. Группировка: по КА →
|
||||
кластеризация по крену (`ROLL_EPSILON=0.5`, разрыв > ε начинает кластер) → внутри кластера деление
|
||||
по непрерывности времени (`CADENCE_GAP_FACTOR=1.5` от медианной каденции). `PolosaId =
|
||||
polosa:${ключ первого по времени члена}`; `membersOf` — члены в порядке времени.
|
||||
- `slotSelection.ts`: вместо `toggleStripSelection` — `selectStripRange(current, members, fromKey,
|
||||
toKey)` (чистая: добавляет членов полосы между двумя кликами включительно, без мутации).
|
||||
- `TguSlotsTab.tsx`: убран чип/hover/`stripInfo`. Жест: обычный клик ставит якорь; Shift+клик по
|
||||
слоту той же полосы выделяет членов между якорем и кликом (BUSY исключаются); Shift+клик по слоту
|
||||
другой полосы/КА — выбор не меняется + подсказка «только в пределах одной полосы». Якорь — состояние
|
||||
(`anchorKey`), строки полосы якоря подсвечены рельсом (оттенок по `PolosaId`); сбрасывается при
|
||||
смене заявки/сортировки/фильтров/расчёта. Подсказка жеста в selbar.
|
||||
- `tgu-planning.css`: удалены стили чипа/hover; рельс `tgu-slots-row--strip` переиспользован под
|
||||
полосу якоря, добавлен `is-strip-anchor`.
|
||||
- Калибровка `ROLL_EPSILON=0.5` / `CADENCE_GAP_FACTOR=1.5` — по скрину данных; при ручном прогоне
|
||||
возможна подстройка по фактическим `roll`/`tn`.
|
||||
- Бэкенд/DTO не тронуты. Правило выполнимости (манёвренность КА) по-прежнему it.2.
|
||||
|
||||
## Решение — Iteration 2 (проверка выполнимости, отдельно)
|
||||
- Бэкенд: read-only `POST /api/slots/booking/validate` (набор слотов → violations),
|
||||
|
||||
@@ -38,7 +38,7 @@ import {
|
||||
type SlotMask
|
||||
} from "./coverage";
|
||||
import { applySlotFilters, slotStatusKey, type SlotStatusKey } from "./slotFilters";
|
||||
import { selectionAfterCheck, toggleStripSelection } from "./slotSelection";
|
||||
import { selectionAfterCheck, selectStripRange } from "./slotSelection";
|
||||
import { groupStrips } from "./slotStrips";
|
||||
import { runChunked } from "./runChunked";
|
||||
import { ChunkProgress } from "./ChunkProgress";
|
||||
@@ -140,14 +140,12 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
|
||||
const wktCacheRef = useRef(new Map<string, GeoPoint[][]>());
|
||||
// Контроллер отмены текущего расчёта/автоподбора — прерывается при повторе или смене заявки.
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
// Якорь для выбора диапазона по Shift+клику (индекс в filteredSlots).
|
||||
const anchorIndexRef = useRef<number | null>(null);
|
||||
// Якорь жеста «полоса от первого к последнему»: ключ первого кликнутого слота. Состояние (не ref):
|
||||
// по нему подсвечиваем строки той же полосы, чтобы оператор видел границу выделения.
|
||||
const [anchorKey, setAnchorKey] = useState<string | null>(null);
|
||||
|
||||
// Подсветка строки/контура по клику на карте и центрирование на слоте.
|
||||
const [activeSlotId, setActiveSlotId] = useState<string>();
|
||||
// Полоса под курсором — для подсветки всех её членов по StripId (актуально в сортировках
|
||||
// «крен»/«вклад», где члены полосы не идут подряд). null — курсор вне полос.
|
||||
const [hoveredStrip, setHoveredStrip] = useState<string | null>(null);
|
||||
const [focusPoint, setFocusPoint] = useState<GeoPoint>();
|
||||
const focusTokenRef = useRef(0);
|
||||
const [focusToken, setFocusToken] = useState(0);
|
||||
@@ -295,34 +293,15 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
|
||||
[dayWindowed, statusFilter, satFilter, branchFilter, bookedKeys],
|
||||
);
|
||||
|
||||
// Идентичность полос — над СТАБИЛЬНЫМ набором displaySlots (НЕ filteredSlots/sortedSlots:
|
||||
// иначе фильтр-дырка ложно разорвала или склеила бы полосу). Map<slotKey, StripId>.
|
||||
const stripIdByKey = useMemo(() => groupStrips(displaySlots, slotKey), [displaySlots]);
|
||||
// Разметка полос — над СТАБИЛЬНЫМ набором displaySlots (НЕ filteredSlots/sortedSlots: иначе
|
||||
// фильтр-дырка ложно разорвала или склеила бы полосу). Полоса = один КА + один крен + подряд по
|
||||
// времени (виток не граница). idByKey — id полосы по ключу слота; membersOf — члены полосы по времени.
|
||||
const polosa = useMemo(() => groupStrips(displaySlots, slotKey), [displaySlots]);
|
||||
|
||||
// По каждой видимой полосе: число видимых членов (N для чипа) и ключи видимых ВЫБИРАЕМЫХ
|
||||
// (не-BUSY) членов — цель выбора. Считаем над filteredSlots: выбираем/считаем только видимое.
|
||||
const stripInfo = useMemo(() => {
|
||||
const visibleCount = new Map<string, number>();
|
||||
const selectableKeys = new Map<string, string[]>();
|
||||
for (const slot of filteredSlots) {
|
||||
const key = slotKey(slot);
|
||||
const strip = stripIdByKey.get(key);
|
||||
if (!strip) continue;
|
||||
visibleCount.set(strip, (visibleCount.get(strip) ?? 0) + 1);
|
||||
if (!slotIsBusy(slot)) {
|
||||
const arr = selectableKeys.get(strip) ?? [];
|
||||
arr.push(key);
|
||||
selectableKeys.set(strip, arr);
|
||||
}
|
||||
}
|
||||
return { visibleCount, selectableKeys };
|
||||
}, [filteredSlots, stripIdByKey]);
|
||||
|
||||
// Якорь Shift-выбора — индекс в видимом списке. Любая смена видимого набора (фильтры
|
||||
// статус/КА/ветвь, окно суток, бронь) делает индекс невалидным → сбрасываем (как при смене
|
||||
// сортировки). Иначе Shift-диапазон после фильтрации выбрал бы не те строки.
|
||||
// Якорь жеста привязан к ВИДИМОМУ набору: смена фильтров/окна суток/брони делает прежний якорь
|
||||
// неактуальным → сбрасываем (как при смене сортировки), иначе Shift выделил бы не ту полосу.
|
||||
useEffect(() => {
|
||||
anchorIndexRef.current = null;
|
||||
setAnchorKey(null);
|
||||
}, [filteredSlots]);
|
||||
|
||||
// Загрузка заявок интервала (на карту); вызывается на входе, при смене диапазона и из фильтра.
|
||||
@@ -532,7 +511,7 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
|
||||
useEffect(() => {
|
||||
abortRef.current?.abort();
|
||||
wktCacheRef.current.clear();
|
||||
anchorIndexRef.current = null;
|
||||
setAnchorKey(null);
|
||||
setSlots([]);
|
||||
setSelectedKeys(new Set());
|
||||
setBookedSlots([]);
|
||||
@@ -579,7 +558,7 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
|
||||
abortRef.current?.abort();
|
||||
const ctrl = new AbortController();
|
||||
abortRef.current = ctrl;
|
||||
anchorIndexRef.current = null;
|
||||
setAnchorKey(null);
|
||||
if (auto) setAutoFilling(true);
|
||||
else setCalculating(true);
|
||||
setError(undefined);
|
||||
@@ -782,13 +761,33 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
|
||||
};
|
||||
const cancelPendingCancel = () => setPendingCancel(false);
|
||||
|
||||
// Отметка слота из таблицы. Shift+клик — выбрать диапазон от якоря (предыдущего клика) до
|
||||
// текущей строки; направление (выбрать/снять) задаётся новым состоянием текущей строки.
|
||||
// Отметка слота из таблицы.
|
||||
// • Обычный клик — переключает один слот и ставит его якорем полосы.
|
||||
// • Shift+клик — жест «полоса от первого к последнему»: выделяет всех членов ОДНОЙ полосы между
|
||||
// якорем и текущим слотом (включительно). Если текущий слот из другой полосы (другой КА/крен/
|
||||
// разрыв по времени) — выбор не меняем и подсказываем: выйти за полосу нельзя.
|
||||
const handleSlotCheck = (index: number, shiftKey: boolean) => {
|
||||
const targetKey = slotKey(sortedSlots[index]);
|
||||
if (shiftKey && anchorKey) {
|
||||
const anchorId = polosa.idByKey.get(anchorKey);
|
||||
const targetId = polosa.idByKey.get(targetKey);
|
||||
if (!anchorId || anchorId !== targetId) {
|
||||
setNotice("Выделить можно только в пределах одной полосы (один КА, один крен, подряд по времени).");
|
||||
return; // якорь оставляем — оператор может выбрать другой конец той же полосы
|
||||
}
|
||||
// Члены полосы по времени, без BUSY (их бронировать нельзя — в выделение не берём).
|
||||
const members = (polosa.membersOf.get(anchorId) ?? []).filter((k) => {
|
||||
const slot = slotByKey.get(k);
|
||||
return !(slot && slotIsBusy(slot));
|
||||
});
|
||||
setSelectedKeys((current) => selectStripRange(current, members, anchorKey, targetKey));
|
||||
setAnchorKey(targetKey);
|
||||
return;
|
||||
}
|
||||
setSelectedKeys((current) =>
|
||||
selectionAfterCheck(current, (i) => slotKey(sortedSlots[i]), index, shiftKey, anchorIndexRef.current)
|
||||
selectionAfterCheck(current, (i) => slotKey(sortedSlots[i]), index, false, null)
|
||||
);
|
||||
anchorIndexRef.current = index;
|
||||
setAnchorKey(targetKey);
|
||||
};
|
||||
|
||||
// Чекбокс шапки — выбрать/снять все ВИДИМЫЕ (по окну суток) выбираемые слоты. BUSY (занятые
|
||||
@@ -803,15 +802,7 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
|
||||
else for (const slot of selectableVisible) next.add(slotKey(slot));
|
||||
return next;
|
||||
});
|
||||
anchorIndexRef.current = null;
|
||||
};
|
||||
// Чип «полоса ×N» — выбрать/снять всю полосу: зеркалит toggleAllVisible, но в пределах одной
|
||||
// полосы (только видимые не-BUSY члены). Сбрасываем якорь Shift — позиции выбора изменились.
|
||||
const toggleStrip = (stripId: string) => {
|
||||
const memberKeys = stripInfo.selectableKeys.get(stripId);
|
||||
if (!memberKeys || memberKeys.length === 0) return;
|
||||
setSelectedKeys((current) => toggleStripSelection(current, memberKeys));
|
||||
anchorIndexRef.current = null;
|
||||
setAnchorKey(null);
|
||||
};
|
||||
|
||||
// Выбрать все слоты расчёта (по всем суткам, кроме занятых бронью), снять весь выбор.
|
||||
@@ -819,7 +810,7 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
|
||||
setSelectedKeys(new Set(displaySlots.filter((slot) => !slotIsBusy(slot)).map(slotKey)));
|
||||
const clearSelection = () => {
|
||||
setSelectedKeys(new Set());
|
||||
anchorIndexRef.current = null;
|
||||
setAnchorKey(null);
|
||||
};
|
||||
|
||||
// Тогглы фильтров видимости (selected = visible: клик добавляет/убирает значение из множества).
|
||||
@@ -832,7 +823,7 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
|
||||
// Смена сортировки меняет позиции строк — якорь Shift-выбора больше не валиден, сбрасываем.
|
||||
const changeSort = (mode: SlotSortMode) => {
|
||||
setSortMode(mode);
|
||||
anchorIndexRef.current = null;
|
||||
setAnchorKey(null);
|
||||
};
|
||||
const toggleStatus = (key: SlotStatusKey) => setStatusFilter((prev) => toggleInSet(prev, key));
|
||||
const toggleSat = (id: number) => setSatFilter((prev) => toggleInSet(prev, id));
|
||||
@@ -1213,6 +1204,9 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="tgu-slots-selbar__sub" title="Кликните первый слот, затем Shift+клик по последнему — выделится полоса между ними. Выйти за полосу (другой КА/крен/разрыв по времени) нельзя.">
|
||||
Полоса: клик + Shift+клик
|
||||
</span>
|
||||
<div className="tgu-slots-selbar__actions">
|
||||
<button
|
||||
className="tgu-button tgu-button--sm"
|
||||
@@ -1291,23 +1285,18 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
|
||||
const id = `slot:${key}`;
|
||||
const booked = bookedKeys.has(key);
|
||||
const isActive = activeSlotId === id;
|
||||
// Полоса слота: N видимых членов, ключи видимых выбираемых членов.
|
||||
const stripId = stripIdByKey.get(key);
|
||||
const stripN = stripId ? stripInfo.visibleCount.get(stripId) ?? 0 : 0;
|
||||
const stripSelectable = stripId
|
||||
? stripInfo.selectableKeys.get(stripId) ?? []
|
||||
: [];
|
||||
const isStrip = stripN >= 2;
|
||||
const showStripChip = isStrip && stripSelectable.length > 0;
|
||||
const stripAllSelected =
|
||||
showStripChip && stripSelectable.every((k) => selectedKeys.has(k));
|
||||
// Рельс — только в сортировке «время» (там члены полосы идут подряд);
|
||||
// в «крен»/«вклад» — подсветка по StripId при наведении.
|
||||
const showRail = isStrip && sortMode === "time";
|
||||
// Подсветка полосы текущего якоря: оператор видит, что выделит Shift+клик,
|
||||
// и что за границу полосы (другой КА/крен/разрыв) выйти нельзя.
|
||||
const polosaId = polosa.idByKey.get(key);
|
||||
const inAnchorPolosa =
|
||||
anchorKey != null &&
|
||||
polosaId != null &&
|
||||
polosaId === polosa.idByKey.get(anchorKey);
|
||||
const isAnchorRow = anchorKey === key;
|
||||
const rowClass = [
|
||||
isActive ? "is-active" : "",
|
||||
isStrip && hoveredStrip === stripId ? "is-strip-hover" : "",
|
||||
showRail ? "tgu-slots-row--strip" : "",
|
||||
inAnchorPolosa ? "tgu-slots-row--strip" : "",
|
||||
isAnchorRow ? "is-strip-anchor" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
@@ -1317,14 +1306,18 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
|
||||
data-slot-id={id}
|
||||
style={{
|
||||
height: ROW_HEIGHT,
|
||||
...(showRail && stripId
|
||||
? { ["--strip-hue" as string]: String(stripHue(stripId)) }
|
||||
...(inAnchorPolosa && polosaId
|
||||
? { ["--strip-hue" as string]: String(stripHue(polosaId)) }
|
||||
: {}),
|
||||
}}
|
||||
className={rowClass || undefined}
|
||||
onClick={() => selectRow(slot)}
|
||||
onMouseEnter={isStrip ? () => setHoveredStrip(stripId!) : undefined}
|
||||
onMouseLeave={isStrip ? () => setHoveredStrip(null) : undefined}
|
||||
onClick={(event) => {
|
||||
// Клик по строке = и подсветка на карте, и жест выбора полосы:
|
||||
// обычный клик выбирает слот + ставит якорь, Shift+клик тянет полосу
|
||||
// до этой строки. BUSY не выбираем (бронировать нельзя).
|
||||
selectRow(slot);
|
||||
if (!slotIsBusy(slot)) handleSlotCheck(vrow.index, event.shiftKey);
|
||||
}}
|
||||
>
|
||||
<td onClick={(event) => event.stopPropagation()}>
|
||||
<input
|
||||
@@ -1337,33 +1330,7 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
|
||||
aria-label="Выбрать слот"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<span className="tgu-slots-sat">
|
||||
{slot.satelliteId}
|
||||
{showStripChip && (
|
||||
<button
|
||||
type="button"
|
||||
className={
|
||||
stripAllSelected
|
||||
? "tgu-slots-strip-chip is-on"
|
||||
: "tgu-slots-strip-chip"
|
||||
}
|
||||
title={
|
||||
stripAllSelected
|
||||
? "Снять выбор полосы (слоты одного крена подряд)"
|
||||
: "Выбрать полосу — слоты одного крена подряд"
|
||||
}
|
||||
aria-label="Выбрать полосу"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
toggleStrip(stripId!);
|
||||
}}
|
||||
>
|
||||
полоса ×{stripN}
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
<td>{slot.satelliteId}</td>
|
||||
<td>
|
||||
{slot.revolution} / {slot.revolutionSign === "DESC" ? "нисх" : "восх"} /{" "}
|
||||
{slot.cycle}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { chunk, selectionAfterCheck, toggleStripSelection } from "./slotSelection";
|
||||
import { chunk, selectionAfterCheck, selectStripRange } from "./slotSelection";
|
||||
|
||||
const keyAt = (i: number) => `k${i}`;
|
||||
|
||||
@@ -42,26 +42,38 @@ describe("selectionAfterCheck", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("toggleStripSelection", () => {
|
||||
it("добавляет все члены полосы, если выбраны не все", () => {
|
||||
const next = toggleStripSelection(new Set(["a"]), ["a", "b", "c"]);
|
||||
expect([...next].sort()).toEqual(["a", "b", "c"]);
|
||||
describe("selectStripRange", () => {
|
||||
const members = ["a", "b", "c", "d"]; // в порядке времени
|
||||
|
||||
it("выделяет членов полосы от первого к последнему включительно", () => {
|
||||
const next = selectStripRange(new Set(), members, "b", "d");
|
||||
expect([...next].sort()).toEqual(["b", "c", "d"]);
|
||||
});
|
||||
|
||||
it("снимает все члены, если все уже выбраны", () => {
|
||||
const next = toggleStripSelection(new Set(["a", "b", "c", "x"]), ["a", "b", "c"]);
|
||||
expect([...next]).toEqual(["x"]);
|
||||
it("работает при обратном порядке кликов (последний → первый)", () => {
|
||||
const next = selectStripRange(new Set(), members, "d", "b");
|
||||
expect([...next].sort()).toEqual(["b", "c", "d"]);
|
||||
});
|
||||
|
||||
it("пустой набор членов — выбор без изменений", () => {
|
||||
const next = toggleStripSelection(new Set(["x"]), []);
|
||||
it("один и тот же ключ → один член", () => {
|
||||
const next = selectStripRange(new Set(), members, "c", "c");
|
||||
expect([...next]).toEqual(["c"]);
|
||||
});
|
||||
|
||||
it("сохраняет уже выбранное за пределами полосы", () => {
|
||||
const next = selectStripRange(new Set(["x"]), members, "a", "b");
|
||||
expect([...next].sort()).toEqual(["a", "b", "x"]);
|
||||
});
|
||||
|
||||
it("ключ вне полосы → выбор без изменений", () => {
|
||||
const next = selectStripRange(new Set(["x"]), members, "a", "zzz");
|
||||
expect([...next]).toEqual(["x"]);
|
||||
});
|
||||
|
||||
it("не мутирует исходное множество", () => {
|
||||
const current = new Set(["a"]);
|
||||
toggleStripSelection(current, ["a", "b"]);
|
||||
expect([...current]).toEqual(["a"]);
|
||||
const current = new Set(["x"]);
|
||||
selectStripRange(current, members, "a", "b");
|
||||
expect([...current]).toEqual(["x"]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -40,25 +40,30 @@ export function selectionAfterCheck(
|
||||
}
|
||||
|
||||
/**
|
||||
* Новое множество выбора после клика по чипу «полоса». Поведение зеркалит «выбрать видимые»,
|
||||
* но в рамках одной полосы: если ВСЕ её видимые выбираемые члены уже выбраны — снимаем их,
|
||||
* иначе добавляем все. Скрытые (отфильтрованные) и BUSY-члены сюда не передаются — выбираем
|
||||
* только то, что видно и можно бронировать (доверие счётчику «Выбрано»). Вход не мутируется.
|
||||
* Новое множество выбора после жеста «полоса от первого к последнему»: добавляет всех членов
|
||||
* полосы между fromKey и toKey включительно (в порядке времени). Это ВЫДЕЛЕНИЕ, не toggle —
|
||||
* существующий выбор сохраняется. Жест ограничен одной полосой: вызывающий передаёт members
|
||||
* именно её (один КА, один крен, подряд по времени); граница полосы — гарантия «нельзя выделить
|
||||
* не полосу». Если fromKey или toKey не из members — выбор не меняется (страховка). Вход не мутируется.
|
||||
*
|
||||
* @param current Текущее множество выбранных ключей.
|
||||
* @param memberKeys Ключи видимых выбираемых членов полосы.
|
||||
* @param members Ключи членов полосы в порядке времени.
|
||||
* @param fromKey Ключ первого клика (якорь).
|
||||
* @param toKey Ключ второго клика.
|
||||
*/
|
||||
export function toggleStripSelection(
|
||||
export function selectStripRange(
|
||||
current: ReadonlySet<string>,
|
||||
memberKeys: readonly string[],
|
||||
members: readonly string[],
|
||||
fromKey: string,
|
||||
toKey: string,
|
||||
): Set<string> {
|
||||
const next = new Set(current)
|
||||
if (memberKeys.length === 0) return next
|
||||
const allSelected = memberKeys.every((k) => current.has(k))
|
||||
for (const k of memberKeys) {
|
||||
if (allSelected) next.delete(k)
|
||||
else next.add(k)
|
||||
}
|
||||
const from = members.indexOf(fromKey)
|
||||
const to = members.indexOf(toKey)
|
||||
if (from < 0 || to < 0) return next
|
||||
const lo = Math.min(from, to)
|
||||
const hi = Math.max(from, to)
|
||||
for (let i = lo; i <= hi; i++) next.add(members[i]!)
|
||||
return next
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -1305,49 +1305,22 @@
|
||||
background: rgba(104, 195, 189, 0.36);
|
||||
}
|
||||
|
||||
/* Ячейка «КА»: номер КА + чип выбора полосы рядом. */
|
||||
.tgu-slots-sat {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
/* Строки таблицы — поверхность жеста выбора полосы (клик + Shift+клик), поэтому гасим выделение
|
||||
текста, чтобы Shift+клик не подсвечивал текст между строками. */
|
||||
.tgu-slots-table tbody {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Чип «полоса ×N» — выбрать всю полосу (слоты одного крена подряд) одним кликом.
|
||||
Нейтральный по умолчанию; .is-on — когда вся полоса уже выбрана. */
|
||||
.tgu-slots-strip-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.02rem 0.4rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--line);
|
||||
background: #101214;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.68rem;
|
||||
line-height: 1.4;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tgu-slots-strip-chip:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.tgu-slots-strip-chip.is-on {
|
||||
border-color: rgba(104, 195, 189, 0.58);
|
||||
background: rgba(104, 195, 189, 0.16);
|
||||
color: var(--accent-strong);
|
||||
}
|
||||
|
||||
/* Рельс полосы (сортировка «время», члены идут подряд): приглушённая цветная полоса слева,
|
||||
оттенок детерминирован StripId (--strip-hue) — соседние полосы визуально различаются.
|
||||
У активной строки рельс перекрывается акцентным (is-active), это норма. */
|
||||
/* Рельс полосы текущего якоря: приглушённая цветная полоса слева у всех членов полосы, в которой
|
||||
стоит якорь жеста «полоса от первого к последнему». Оттенок детерминирован PolosaId (--strip-hue).
|
||||
Так оператор видит границу полосы — что выделит Shift+клик и за что выйти нельзя. У активной
|
||||
строки рельс перекрывается акцентным (is-active), это норма. */
|
||||
.tgu-slots-table tbody tr.tgu-slots-row--strip {
|
||||
box-shadow: inset 3px 0 0 hsl(var(--strip-hue, 0) 45% 55% / 0.55);
|
||||
}
|
||||
|
||||
/* Подсветка всех членов полосы под курсором (актуально в «крен»/«вклад», где они не подряд). */
|
||||
.tgu-slots-table tbody tr.is-strip-hover {
|
||||
/* Строка-якорь полосы (первый клик жеста) — чуть заметнее остальных членов полосы. */
|
||||
.tgu-slots-table tbody tr.is-strip-anchor {
|
||||
background: rgba(104, 195, 189, 0.12);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user