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 выбираемых членов | чипа нет |
|
| disabled chip | N<2 или 0 выбираемых членов | чипа нет |
|
||||||
| booked/occupied | как есть (бейдж) | не меняется; в полосу входит визуально, в выбор — нет |
|
| booked/occupied | как есть (бейдж) | не меняется; в полосу входит визуально, в выбор — нет |
|
||||||
|
|
||||||
## Реализовано (it.1, 2026-06-17)
|
## Реализовано (it.1, 2026-06-17 — пересмотрено)
|
||||||
- `slotStrips.ts` (`groupStrips`, `ROLL_EPSILON=0.01`, якорный крен, тотальный порядок) +
|
|
||||||
`slotStrips.test.ts` (11 кейсов: мультиполоса, два КА, граница ε, дрейф крена, порядок,
|
ВАЖНО: первая версия it.1 (авто-чип «полоса ×N» по равному крену) оказалась не той фичей и
|
||||||
tie-break, NaN `tn`, пустой вход).
|
переделана. На реальных данных полоса по «подряд по времени + равный крен» не формировалась
|
||||||
- `toggleStripSelection` в `slotSelection.ts` (чистый, без мутации) + тесты.
|
(в один момент идёт «веер» кренов с шагом ~3°, равный крен повторяется не подряд, а вдоль трассы
|
||||||
- `TguSlotsTab.tsx`: `stripIdByKey` (memo над `displaySlots`), `stripInfo` (видимые члены /
|
через каденцию). Оператор хотел не авто-магию, а ручной выбор полосы мышью «от первого к последнему»
|
||||||
выбираемые не-BUSY по полосе), чип `полоса ×N` в ячейке «КА» (активен при всех выбранных),
|
с запретом выйти за полосу.
|
||||||
рельс по `StripId` (sortMode `time`), подсветка членов по `StripId` при наведении.
|
|
||||||
- Смежный баг закрыт: `anchorIndexRef` сбрасывается при смене видимого набора (`filteredSlots`).
|
Переопределение полосы (согласовано с оператором):
|
||||||
- Тесты 123 зелёных, `tsc -b` чисто, `vite build` ок. Бэкенд/DTO не тронуты.
|
- **полоса = один КА + один крен (допуск `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 (проверка выполнимости, отдельно)
|
## Решение — Iteration 2 (проверка выполнимости, отдельно)
|
||||||
- Бэкенд: read-only `POST /api/slots/booking/validate` (набор слотов → violations),
|
- Бэкенд: read-only `POST /api/slots/booking/validate` (набор слотов → violations),
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ import {
|
|||||||
type SlotMask
|
type SlotMask
|
||||||
} from "./coverage";
|
} from "./coverage";
|
||||||
import { applySlotFilters, slotStatusKey, type SlotStatusKey } from "./slotFilters";
|
import { applySlotFilters, slotStatusKey, type SlotStatusKey } from "./slotFilters";
|
||||||
import { selectionAfterCheck, toggleStripSelection } from "./slotSelection";
|
import { selectionAfterCheck, selectStripRange } from "./slotSelection";
|
||||||
import { groupStrips } from "./slotStrips";
|
import { groupStrips } from "./slotStrips";
|
||||||
import { runChunked } from "./runChunked";
|
import { runChunked } from "./runChunked";
|
||||||
import { ChunkProgress } from "./ChunkProgress";
|
import { ChunkProgress } from "./ChunkProgress";
|
||||||
@@ -140,14 +140,12 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
|
|||||||
const wktCacheRef = useRef(new Map<string, GeoPoint[][]>());
|
const wktCacheRef = useRef(new Map<string, GeoPoint[][]>());
|
||||||
// Контроллер отмены текущего расчёта/автоподбора — прерывается при повторе или смене заявки.
|
// Контроллер отмены текущего расчёта/автоподбора — прерывается при повторе или смене заявки.
|
||||||
const abortRef = useRef<AbortController | null>(null);
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
// Якорь для выбора диапазона по Shift+клику (индекс в filteredSlots).
|
// Якорь жеста «полоса от первого к последнему»: ключ первого кликнутого слота. Состояние (не ref):
|
||||||
const anchorIndexRef = useRef<number | null>(null);
|
// по нему подсвечиваем строки той же полосы, чтобы оператор видел границу выделения.
|
||||||
|
const [anchorKey, setAnchorKey] = useState<string | null>(null);
|
||||||
|
|
||||||
// Подсветка строки/контура по клику на карте и центрирование на слоте.
|
// Подсветка строки/контура по клику на карте и центрирование на слоте.
|
||||||
const [activeSlotId, setActiveSlotId] = useState<string>();
|
const [activeSlotId, setActiveSlotId] = useState<string>();
|
||||||
// Полоса под курсором — для подсветки всех её членов по StripId (актуально в сортировках
|
|
||||||
// «крен»/«вклад», где члены полосы не идут подряд). null — курсор вне полос.
|
|
||||||
const [hoveredStrip, setHoveredStrip] = useState<string | null>(null);
|
|
||||||
const [focusPoint, setFocusPoint] = useState<GeoPoint>();
|
const [focusPoint, setFocusPoint] = useState<GeoPoint>();
|
||||||
const focusTokenRef = useRef(0);
|
const focusTokenRef = useRef(0);
|
||||||
const [focusToken, setFocusToken] = useState(0);
|
const [focusToken, setFocusToken] = useState(0);
|
||||||
@@ -295,34 +293,15 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
|
|||||||
[dayWindowed, statusFilter, satFilter, branchFilter, bookedKeys],
|
[dayWindowed, statusFilter, satFilter, branchFilter, bookedKeys],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Идентичность полос — над СТАБИЛЬНЫМ набором displaySlots (НЕ filteredSlots/sortedSlots:
|
// Разметка полос — над СТАБИЛЬНЫМ набором displaySlots (НЕ filteredSlots/sortedSlots: иначе
|
||||||
// иначе фильтр-дырка ложно разорвала или склеила бы полосу). Map<slotKey, StripId>.
|
// фильтр-дырка ложно разорвала или склеила бы полосу). Полоса = один КА + один крен + подряд по
|
||||||
const stripIdByKey = useMemo(() => groupStrips(displaySlots, slotKey), [displaySlots]);
|
// времени (виток не граница). idByKey — id полосы по ключу слота; membersOf — члены полосы по времени.
|
||||||
|
const polosa = useMemo(() => groupStrips(displaySlots, slotKey), [displaySlots]);
|
||||||
|
|
||||||
// По каждой видимой полосе: число видимых членов (N для чипа) и ключи видимых ВЫБИРАЕМЫХ
|
// Якорь жеста привязан к ВИДИМОМУ набору: смена фильтров/окна суток/брони делает прежний якорь
|
||||||
// (не-BUSY) членов — цель выбора. Считаем над filteredSlots: выбираем/считаем только видимое.
|
// неактуальным → сбрасываем (как при смене сортировки), иначе Shift выделил бы не ту полосу.
|
||||||
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-диапазон после фильтрации выбрал бы не те строки.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
anchorIndexRef.current = null;
|
setAnchorKey(null);
|
||||||
}, [filteredSlots]);
|
}, [filteredSlots]);
|
||||||
|
|
||||||
// Загрузка заявок интервала (на карту); вызывается на входе, при смене диапазона и из фильтра.
|
// Загрузка заявок интервала (на карту); вызывается на входе, при смене диапазона и из фильтра.
|
||||||
@@ -532,7 +511,7 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
abortRef.current?.abort();
|
abortRef.current?.abort();
|
||||||
wktCacheRef.current.clear();
|
wktCacheRef.current.clear();
|
||||||
anchorIndexRef.current = null;
|
setAnchorKey(null);
|
||||||
setSlots([]);
|
setSlots([]);
|
||||||
setSelectedKeys(new Set());
|
setSelectedKeys(new Set());
|
||||||
setBookedSlots([]);
|
setBookedSlots([]);
|
||||||
@@ -579,7 +558,7 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
|
|||||||
abortRef.current?.abort();
|
abortRef.current?.abort();
|
||||||
const ctrl = new AbortController();
|
const ctrl = new AbortController();
|
||||||
abortRef.current = ctrl;
|
abortRef.current = ctrl;
|
||||||
anchorIndexRef.current = null;
|
setAnchorKey(null);
|
||||||
if (auto) setAutoFilling(true);
|
if (auto) setAutoFilling(true);
|
||||||
else setCalculating(true);
|
else setCalculating(true);
|
||||||
setError(undefined);
|
setError(undefined);
|
||||||
@@ -782,13 +761,33 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
|
|||||||
};
|
};
|
||||||
const cancelPendingCancel = () => setPendingCancel(false);
|
const cancelPendingCancel = () => setPendingCancel(false);
|
||||||
|
|
||||||
// Отметка слота из таблицы. Shift+клик — выбрать диапазон от якоря (предыдущего клика) до
|
// Отметка слота из таблицы.
|
||||||
// текущей строки; направление (выбрать/снять) задаётся новым состоянием текущей строки.
|
// • Обычный клик — переключает один слот и ставит его якорем полосы.
|
||||||
|
// • Shift+клик — жест «полоса от первого к последнему»: выделяет всех членов ОДНОЙ полосы между
|
||||||
|
// якорем и текущим слотом (включительно). Если текущий слот из другой полосы (другой КА/крен/
|
||||||
|
// разрыв по времени) — выбор не меняем и подсказываем: выйти за полосу нельзя.
|
||||||
const handleSlotCheck = (index: number, shiftKey: boolean) => {
|
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) =>
|
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 (занятые
|
// Чекбокс шапки — выбрать/снять все ВИДИМЫЕ (по окну суток) выбираемые слоты. BUSY (занятые
|
||||||
@@ -803,15 +802,7 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
|
|||||||
else for (const slot of selectableVisible) next.add(slotKey(slot));
|
else for (const slot of selectableVisible) next.add(slotKey(slot));
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
anchorIndexRef.current = null;
|
setAnchorKey(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;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Выбрать все слоты расчёта (по всем суткам, кроме занятых бронью), снять весь выбор.
|
// Выбрать все слоты расчёта (по всем суткам, кроме занятых бронью), снять весь выбор.
|
||||||
@@ -819,7 +810,7 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
|
|||||||
setSelectedKeys(new Set(displaySlots.filter((slot) => !slotIsBusy(slot)).map(slotKey)));
|
setSelectedKeys(new Set(displaySlots.filter((slot) => !slotIsBusy(slot)).map(slotKey)));
|
||||||
const clearSelection = () => {
|
const clearSelection = () => {
|
||||||
setSelectedKeys(new Set());
|
setSelectedKeys(new Set());
|
||||||
anchorIndexRef.current = null;
|
setAnchorKey(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Тогглы фильтров видимости (selected = visible: клик добавляет/убирает значение из множества).
|
// Тогглы фильтров видимости (selected = visible: клик добавляет/убирает значение из множества).
|
||||||
@@ -832,7 +823,7 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
|
|||||||
// Смена сортировки меняет позиции строк — якорь Shift-выбора больше не валиден, сбрасываем.
|
// Смена сортировки меняет позиции строк — якорь Shift-выбора больше не валиден, сбрасываем.
|
||||||
const changeSort = (mode: SlotSortMode) => {
|
const changeSort = (mode: SlotSortMode) => {
|
||||||
setSortMode(mode);
|
setSortMode(mode);
|
||||||
anchorIndexRef.current = null;
|
setAnchorKey(null);
|
||||||
};
|
};
|
||||||
const toggleStatus = (key: SlotStatusKey) => setStatusFilter((prev) => toggleInSet(prev, key));
|
const toggleStatus = (key: SlotStatusKey) => setStatusFilter((prev) => toggleInSet(prev, key));
|
||||||
const toggleSat = (id: number) => setSatFilter((prev) => toggleInSet(prev, id));
|
const toggleSat = (id: number) => setSatFilter((prev) => toggleInSet(prev, id));
|
||||||
@@ -1213,6 +1204,9 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
|
<span className="tgu-slots-selbar__sub" title="Кликните первый слот, затем Shift+клик по последнему — выделится полоса между ними. Выйти за полосу (другой КА/крен/разрыв по времени) нельзя.">
|
||||||
|
Полоса: клик + Shift+клик
|
||||||
|
</span>
|
||||||
<div className="tgu-slots-selbar__actions">
|
<div className="tgu-slots-selbar__actions">
|
||||||
<button
|
<button
|
||||||
className="tgu-button tgu-button--sm"
|
className="tgu-button tgu-button--sm"
|
||||||
@@ -1291,23 +1285,18 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
|
|||||||
const id = `slot:${key}`;
|
const id = `slot:${key}`;
|
||||||
const booked = bookedKeys.has(key);
|
const booked = bookedKeys.has(key);
|
||||||
const isActive = activeSlotId === id;
|
const isActive = activeSlotId === id;
|
||||||
// Полоса слота: N видимых членов, ключи видимых выбираемых членов.
|
// Подсветка полосы текущего якоря: оператор видит, что выделит Shift+клик,
|
||||||
const stripId = stripIdByKey.get(key);
|
// и что за границу полосы (другой КА/крен/разрыв) выйти нельзя.
|
||||||
const stripN = stripId ? stripInfo.visibleCount.get(stripId) ?? 0 : 0;
|
const polosaId = polosa.idByKey.get(key);
|
||||||
const stripSelectable = stripId
|
const inAnchorPolosa =
|
||||||
? stripInfo.selectableKeys.get(stripId) ?? []
|
anchorKey != null &&
|
||||||
: [];
|
polosaId != null &&
|
||||||
const isStrip = stripN >= 2;
|
polosaId === polosa.idByKey.get(anchorKey);
|
||||||
const showStripChip = isStrip && stripSelectable.length > 0;
|
const isAnchorRow = anchorKey === key;
|
||||||
const stripAllSelected =
|
|
||||||
showStripChip && stripSelectable.every((k) => selectedKeys.has(k));
|
|
||||||
// Рельс — только в сортировке «время» (там члены полосы идут подряд);
|
|
||||||
// в «крен»/«вклад» — подсветка по StripId при наведении.
|
|
||||||
const showRail = isStrip && sortMode === "time";
|
|
||||||
const rowClass = [
|
const rowClass = [
|
||||||
isActive ? "is-active" : "",
|
isActive ? "is-active" : "",
|
||||||
isStrip && hoveredStrip === stripId ? "is-strip-hover" : "",
|
inAnchorPolosa ? "tgu-slots-row--strip" : "",
|
||||||
showRail ? "tgu-slots-row--strip" : "",
|
isAnchorRow ? "is-strip-anchor" : "",
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" ");
|
.join(" ");
|
||||||
@@ -1317,14 +1306,18 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
|
|||||||
data-slot-id={id}
|
data-slot-id={id}
|
||||||
style={{
|
style={{
|
||||||
height: ROW_HEIGHT,
|
height: ROW_HEIGHT,
|
||||||
...(showRail && stripId
|
...(inAnchorPolosa && polosaId
|
||||||
? { ["--strip-hue" as string]: String(stripHue(stripId)) }
|
? { ["--strip-hue" as string]: String(stripHue(polosaId)) }
|
||||||
: {}),
|
: {}),
|
||||||
}}
|
}}
|
||||||
className={rowClass || undefined}
|
className={rowClass || undefined}
|
||||||
onClick={() => selectRow(slot)}
|
onClick={(event) => {
|
||||||
onMouseEnter={isStrip ? () => setHoveredStrip(stripId!) : undefined}
|
// Клик по строке = и подсветка на карте, и жест выбора полосы:
|
||||||
onMouseLeave={isStrip ? () => setHoveredStrip(null) : undefined}
|
// обычный клик выбирает слот + ставит якорь, Shift+клик тянет полосу
|
||||||
|
// до этой строки. BUSY не выбираем (бронировать нельзя).
|
||||||
|
selectRow(slot);
|
||||||
|
if (!slotIsBusy(slot)) handleSlotCheck(vrow.index, event.shiftKey);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<td onClick={(event) => event.stopPropagation()}>
|
<td onClick={(event) => event.stopPropagation()}>
|
||||||
<input
|
<input
|
||||||
@@ -1337,33 +1330,7 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
|
|||||||
aria-label="Выбрать слот"
|
aria-label="Выбрать слот"
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>{slot.satelliteId}</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>
|
<td>
|
||||||
{slot.revolution} / {slot.revolutionSign === "DESC" ? "нисх" : "восх"} /{" "}
|
{slot.revolution} / {slot.revolutionSign === "DESC" ? "нисх" : "восх"} /{" "}
|
||||||
{slot.cycle}
|
{slot.cycle}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { chunk, selectionAfterCheck, toggleStripSelection } from "./slotSelection";
|
import { chunk, selectionAfterCheck, selectStripRange } from "./slotSelection";
|
||||||
|
|
||||||
const keyAt = (i: number) => `k${i}`;
|
const keyAt = (i: number) => `k${i}`;
|
||||||
|
|
||||||
@@ -42,26 +42,38 @@ describe("selectionAfterCheck", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("toggleStripSelection", () => {
|
describe("selectStripRange", () => {
|
||||||
it("добавляет все члены полосы, если выбраны не все", () => {
|
const members = ["a", "b", "c", "d"]; // в порядке времени
|
||||||
const next = toggleStripSelection(new Set(["a"]), ["a", "b", "c"]);
|
|
||||||
expect([...next].sort()).toEqual(["a", "b", "c"]);
|
it("выделяет членов полосы от первого к последнему включительно", () => {
|
||||||
|
const next = selectStripRange(new Set(), members, "b", "d");
|
||||||
|
expect([...next].sort()).toEqual(["b", "c", "d"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("снимает все члены, если все уже выбраны", () => {
|
it("работает при обратном порядке кликов (последний → первый)", () => {
|
||||||
const next = toggleStripSelection(new Set(["a", "b", "c", "x"]), ["a", "b", "c"]);
|
const next = selectStripRange(new Set(), members, "d", "b");
|
||||||
expect([...next]).toEqual(["x"]);
|
expect([...next].sort()).toEqual(["b", "c", "d"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("пустой набор членов — выбор без изменений", () => {
|
it("один и тот же ключ → один член", () => {
|
||||||
const next = toggleStripSelection(new Set(["x"]), []);
|
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"]);
|
expect([...next]).toEqual(["x"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("не мутирует исходное множество", () => {
|
it("не мутирует исходное множество", () => {
|
||||||
const current = new Set(["a"]);
|
const current = new Set(["x"]);
|
||||||
toggleStripSelection(current, ["a", "b"]);
|
selectStripRange(current, members, "a", "b");
|
||||||
expect([...current]).toEqual(["a"]);
|
expect([...current]).toEqual(["x"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -40,25 +40,30 @@ export function selectionAfterCheck(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Новое множество выбора после клика по чипу «полоса». Поведение зеркалит «выбрать видимые»,
|
* Новое множество выбора после жеста «полоса от первого к последнему»: добавляет всех членов
|
||||||
* но в рамках одной полосы: если ВСЕ её видимые выбираемые члены уже выбраны — снимаем их,
|
* полосы между fromKey и toKey включительно (в порядке времени). Это ВЫДЕЛЕНИЕ, не toggle —
|
||||||
* иначе добавляем все. Скрытые (отфильтрованные) и BUSY-члены сюда не передаются — выбираем
|
* существующий выбор сохраняется. Жест ограничен одной полосой: вызывающий передаёт members
|
||||||
* только то, что видно и можно бронировать (доверие счётчику «Выбрано»). Вход не мутируется.
|
* именно её (один КА, один крен, подряд по времени); граница полосы — гарантия «нельзя выделить
|
||||||
|
* не полосу». Если fromKey или toKey не из members — выбор не меняется (страховка). Вход не мутируется.
|
||||||
*
|
*
|
||||||
* @param current Текущее множество выбранных ключей.
|
* @param current Текущее множество выбранных ключей.
|
||||||
* @param memberKeys Ключи видимых выбираемых членов полосы.
|
* @param members Ключи членов полосы в порядке времени.
|
||||||
|
* @param fromKey Ключ первого клика (якорь).
|
||||||
|
* @param toKey Ключ второго клика.
|
||||||
*/
|
*/
|
||||||
export function toggleStripSelection(
|
export function selectStripRange(
|
||||||
current: ReadonlySet<string>,
|
current: ReadonlySet<string>,
|
||||||
memberKeys: readonly string[],
|
members: readonly string[],
|
||||||
|
fromKey: string,
|
||||||
|
toKey: string,
|
||||||
): Set<string> {
|
): Set<string> {
|
||||||
const next = new Set(current)
|
const next = new Set(current)
|
||||||
if (memberKeys.length === 0) return next
|
const from = members.indexOf(fromKey)
|
||||||
const allSelected = memberKeys.every((k) => current.has(k))
|
const to = members.indexOf(toKey)
|
||||||
for (const k of memberKeys) {
|
if (from < 0 || to < 0) return next
|
||||||
if (allSelected) next.delete(k)
|
const lo = Math.min(from, to)
|
||||||
else next.add(k)
|
const hi = Math.max(from, to)
|
||||||
}
|
for (let i = lo; i <= hi; i++) next.add(members[i]!)
|
||||||
return next
|
return next
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { ROLL_EPSILON, groupStrips } from "./slotStrips";
|
import { ROLL_EPSILON, groupStrips, type StripGrouping } from "./slotStrips";
|
||||||
|
|
||||||
/** Минимальный слот для группировки. tn задаём как локальную ISO-строку (как отдаёт бэкенд). */
|
/** Минимальный слот для группировки. tn задаём как локальную ISO-строку (как отдаёт бэкенд). */
|
||||||
type S = {
|
type S = {
|
||||||
@@ -13,17 +13,17 @@ type S = {
|
|||||||
|
|
||||||
const keyOf = (s: S) => `${s.satelliteId}:${s.slotNumber}:${s.cycle}`;
|
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 {
|
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 tn = `2026-06-17T10:${String(mm).padStart(2, "0")}:00`;
|
||||||
const tk = `2026-06-17T10:${String(mm).padStart(2, "0")}:30`;
|
const tk = `2026-06-17T10:${String(mm).padStart(2, "0")}:30`;
|
||||||
return { satelliteId, slotNumber, cycle, tn, tk, roll };
|
return { satelliteId, slotNumber, cycle, tn, tk, roll };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Разбиение на полосы как множество множеств ключей — для сравнения, не зависящего от имён StripId. */
|
/** Разбиение на полосы как множество множеств ключей — для сравнения, не зависящего от имён PolosaId. */
|
||||||
function partition(map: Map<string, string>): string[][] {
|
function partition(g: StripGrouping): string[][] {
|
||||||
const byStrip = new Map<string, 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) ?? [];
|
const arr = byStrip.get(strip) ?? [];
|
||||||
arr.push(key);
|
arr.push(key);
|
||||||
byStrip.set(strip, arr);
|
byStrip.set(strip, arr);
|
||||||
@@ -32,62 +32,78 @@ function partition(map: Map<string, string>): string[][] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("groupStrips", () => {
|
describe("groupStrips", () => {
|
||||||
it("пустой вход → пустая Map", () => {
|
it("пустой вход → пустая разметка", () => {
|
||||||
expect(groupStrips([], keyOf).size).toBe(0);
|
const g = groupStrips([], keyOf);
|
||||||
|
expect(g.idByKey.size).toBe(0);
|
||||||
|
expect(g.membersOf.size).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("один слот → своя полоса", () => {
|
it("один слот → своя полоса", () => {
|
||||||
const map = groupStrips([slot(1, 1, 5, 0)], keyOf);
|
const g = groupStrips([slot(1, 1, 5, 0)], keyOf);
|
||||||
expect(map.get("1:1:1")).toBe("strip:1:1:1");
|
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 = [
|
const slots = [
|
||||||
slot(1, 1, 5, 0),
|
slot(1, 1, 36.5, 0),
|
||||||
slot(1, 2, 5, 1),
|
slot(1, 2, 39.5, 0),
|
||||||
slot(1, 3, 20, 2), // крен изменился → новая полоса
|
slot(1, 3, 36.5, 1),
|
||||||
slot(1, 4, 20, 3),
|
slot(1, 4, 39.5, 1),
|
||||||
];
|
];
|
||||||
expect(partition(groupStrips(slots, keyOf))).toEqual([
|
expect(partition(groupStrips(slots, keyOf))).toEqual([
|
||||||
["1:1:1", "1:2:1"],
|
["1:1:1", "1:3:1"], // крен 36.5 вдоль времени
|
||||||
["1:3:1", "1:4:1"],
|
["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 = [
|
const slots = [
|
||||||
slot(1, 1, 5, 0),
|
slot(1, 1, 5, 0),
|
||||||
slot(2, 1, 5, 0),
|
|
||||||
slot(1, 2, 5, 1),
|
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([
|
expect(partition(groupStrips(slots, keyOf))).toEqual([
|
||||||
["1:1:1", "1:2:1"],
|
["1:1:1", "1:2:1"],
|
||||||
["2:1:1", "2: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);
|
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"]]);
|
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"]]);
|
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("устойчивость к порядку входа: разбиение одинаково при перемешивании", () => {
|
it("устойчивость к порядку входа: разбиение одинаково при перемешивании", () => {
|
||||||
const slots = [
|
const slots = [slot(1, 1, 5, 0), slot(1, 2, 5, 1), slot(1, 3, 20, 0), slot(2, 1, 5, 0)];
|
||||||
slot(1, 1, 5, 0),
|
|
||||||
slot(1, 2, 5, 1),
|
|
||||||
slot(1, 3, 20, 2),
|
|
||||||
slot(2, 1, 5, 0),
|
|
||||||
];
|
|
||||||
const a = partition(groupStrips(slots, keyOf));
|
const a = partition(groupStrips(slots, keyOf));
|
||||||
const b = partition(groupStrips([...slots].reverse(), keyOf));
|
const b = partition(groupStrips([...slots].reverse(), keyOf));
|
||||||
const c = partition(groupStrips([slots[2]!, slots[0]!, slots[3]!, slots[1]!], keyOf));
|
const c = partition(groupStrips([slots[2]!, slots[0]!, slots[3]!, slots[1]!], keyOf));
|
||||||
@@ -95,28 +111,19 @@ describe("groupStrips", () => {
|
|||||||
expect(c).toEqual(a);
|
expect(c).toEqual(a);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("StripId стабилен = strip:${ключ первого по времени члена}", () => {
|
it("PolosaId = polosa:${ключ первого по времени члена}; membersOf — в порядке времени", () => {
|
||||||
const map = groupStrips([slot(1, 2, 5, 1), slot(1, 1, 5, 0)], keyOf);
|
const g = groupStrips([slot(1, 2, 5, 1), slot(1, 1, 5, 0)], keyOf);
|
||||||
// первый по времени — slotNumber 1 (mm=0); оба члена несут его StripId.
|
expect(g.idByKey.get("1:1:1")).toBe("polosa:1:1:1");
|
||||||
expect(map.get("1:1:1")).toBe("strip:1:1:1");
|
expect(g.idByKey.get("1:2:1")).toBe("polosa:1:1:1");
|
||||||
expect(map.get("1:2:1")).toBe("strip:1:1:1");
|
expect(g.membersOf.get("polosa:1:1:1")).toEqual(["1:1:1", "1:2:1"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("tie-break при равном tn: устойчивая группировка по slotNumber/cycle/ключу", () => {
|
it("NaN tn → синглтон без падения, не клеит и не рвёт валидных соседей", () => {
|
||||||
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 → синглтон без падения, не клеит соседей", () => {
|
|
||||||
const broken: S = { satelliteId: 1, slotNumber: 9, cycle: 1, tn: "битый", tk: "битый", roll: 5 };
|
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 slots = [slot(1, 1, 5, 0), broken, slot(1, 2, 5, 1)];
|
||||||
const map = groupStrips(slots, keyOf);
|
const g = groupStrips(slots, keyOf);
|
||||||
expect(map.get("1:9:1")).toBe("strip:1:9:1");
|
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(map.get("1:1:1")).toBe("strip:1:1:1");
|
expect(g.idByKey.get("1:2:1")).toBe("polosa:1:1:1");
|
||||||
expect(map.get("1:2:1")).toBe("strip:1:1:1");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,53 +1,94 @@
|
|||||||
// Группировка слотов в «полосы» (strips) — максимальные цепочки идущих подряд по времени
|
// Группировка слотов в «полосы» (strips) для ручного выбора полосы мышью.
|
||||||
// слотов одного КА с одним креном. Чистая логика под юнит-тесты (см. slotStrips.test.ts,
|
|
||||||
// образец стиля slotSelection.ts / coverage.ts). Используется UI для жеста «выбрать полосу»
|
|
||||||
// и визуальной группировки строк таблицы.
|
|
||||||
//
|
//
|
||||||
// Это ТОЛЬКО identity-группировка (какой слот к какой полосе относится), НЕ проверка
|
// Полоса = слоты ОДНОГО КА + ОДИН крен (с допуском ROLL_EPSILON) + ПОДРЯД по времени (без разрыва
|
||||||
// выполнимости брони. Проверка манёвренности КА (mmi/maxSurveyDuration/maxChainLength) живёт
|
// каденции). Виток/цикл в границу НЕ входят: у экватора номер витка меняется, а слоты остаются в
|
||||||
// в slots-service по-КА из каталога и будет вынесена в бэкенд validate-эндпоинт (iteration 2),
|
// одной полосе, поэтому группируем только по (КА, крен, непрерывность по времени).
|
||||||
// чтобы не дублировать физику на клиенте.
|
//
|
||||||
|
// Это ТОЛЬКО identity-группировка (какой слот к какой полосе относится), НЕ проверка выполнимости
|
||||||
|
// брони. Проверка манёвренности КА (mmi/maxSurveyDuration/maxChainLength/паузы) живёт в slots-service
|
||||||
|
// по-КА из каталога и будет вынесена в бэкенд validate-эндпоинт (iteration 2), чтобы не дублировать
|
||||||
|
// физику на клиенте.
|
||||||
|
|
||||||
import type { SlotDTO } from "../../model/slotTypes";
|
import type { SlotDTO } from "../../model/slotTypes";
|
||||||
import { parseLocal } from "../../utils/localTime";
|
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">;
|
type Stripable = Pick<SlotDTO, "satelliteId" | "slotNumber" | "cycle" | "tn" | "tk" | "roll">;
|
||||||
|
|
||||||
/** Сравнение чисел с детерминированным порядком; NaN — в конец (устойчивость к битому tn). */
|
type Item = { key: string; sat: number; roll: number; tnMs: number };
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
function cmpStr(a: string, b: string): number {
|
function cmpStr(a: string, b: string): number {
|
||||||
return a < b ? -1 : a > b ? 1 : 0;
|
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
|
* Делит кластер слотов одного крена (одного КА) на полосы по непрерывности времени: сортирует по
|
||||||
* идущих подряд по времени слотов ОДНОГО КА с креном, отличающимся от якорного крена полосы
|
* времени и режет там, где разрыв заметно больше типичной каденции (медиана шагов × CADENCE_GAP_FACTOR).
|
||||||
* не более чем на ROLL_EPSILON (сравнение с якорем, а не с предыдущим слотом — иначе дрейф
|
* До двух членов каденцию определить нельзя — считаем их одной полосой.
|
||||||
* крена 0.00→0.009→0.018 ошибочно склеил бы всё в одну полосу). Совпадает с бэкендовым
|
*/
|
||||||
* `sameRoll(roll, chainRoll)`.
|
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 slots Слоты для разметки (любой порядок).
|
||||||
* @param keyOf Аксессор ключа слота (как `keyAt` в selectionAfterCheck) — чтобы не дублировать
|
* @param keyOf Аксессор ключа слота (как `keyAt` в selectionAfterCheck) — чтобы не дублировать
|
||||||
@@ -56,53 +97,55 @@ function cmpStr(a: string, b: string): number {
|
|||||||
export function groupStrips<T extends Stripable>(
|
export function groupStrips<T extends Stripable>(
|
||||||
slots: readonly T[],
|
slots: readonly T[],
|
||||||
keyOf: (slot: T) => string,
|
keyOf: (slot: T) => string,
|
||||||
): Map<string, StripId> {
|
): StripGrouping {
|
||||||
const result = new Map<string, StripId>();
|
const idByKey = new Map<string, PolosaId>();
|
||||||
if (slots.length === 0) return result;
|
const membersOf = new Map<PolosaId, string[]>();
|
||||||
|
if (slots.length === 0) return { idByKey, membersOf };
|
||||||
|
|
||||||
const items = slots.map((slot) => ({
|
const assign = (members: readonly Item[]) => {
|
||||||
slot,
|
const keys = members.map((m) => m.key);
|
||||||
key: keyOf(slot),
|
const id: PolosaId = `polosa:${keys[0]}`;
|
||||||
tnMs: parseLocal(slot.tn),
|
membersOf.set(id, keys);
|
||||||
tkMs: parseLocal(slot.tk),
|
for (const k of keys) idByKey.set(k, id);
|
||||||
}));
|
};
|
||||||
|
|
||||||
items.sort(
|
const bySat = new Map<number, Item[]>();
|
||||||
(a, b) =>
|
for (const slot of slots) {
|
||||||
cmpNum(a.slot.satelliteId, b.slot.satelliteId) ||
|
const item: Item = {
|
||||||
cmpNum(a.tnMs, b.tnMs) ||
|
key: keyOf(slot),
|
||||||
cmpNum(a.tkMs, b.tkMs) ||
|
sat: slot.satelliteId,
|
||||||
cmpNum(a.slot.slotNumber, b.slot.slotNumber) ||
|
roll: slot.roll,
|
||||||
cmpNum(a.slot.cycle, b.slot.cycle) ||
|
tnMs: parseLocal(slot.tn),
|
||||||
cmpStr(a.key, b.key),
|
};
|
||||||
);
|
// Слот без валидного времени не встроить в полосу по непрерывности — отдельная полоса-синглтон.
|
||||||
|
if (Number.isNaN(item.tnMs)) {
|
||||||
let stripId: StripId | null = null;
|
assign([item]);
|
||||||
let anchorRoll = 0;
|
continue;
|
||||||
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 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);
|
background: rgba(104, 195, 189, 0.36);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Ячейка «КА»: номер КА + чип выбора полосы рядом. */
|
/* Строки таблицы — поверхность жеста выбора полосы (клик + Shift+клик), поэтому гасим выделение
|
||||||
.tgu-slots-sat {
|
текста, чтобы Shift+клик не подсвечивал текст между строками. */
|
||||||
display: inline-flex;
|
.tgu-slots-table tbody {
|
||||||
align-items: center;
|
user-select: none;
|
||||||
gap: 0.4rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Чип «полоса ×N» — выбрать всю полосу (слоты одного крена подряд) одним кликом.
|
/* Рельс полосы текущего якоря: приглушённая цветная полоса слева у всех членов полосы, в которой
|
||||||
Нейтральный по умолчанию; .is-on — когда вся полоса уже выбрана. */
|
стоит якорь жеста «полоса от первого к последнему». Оттенок детерминирован PolosaId (--strip-hue).
|
||||||
.tgu-slots-strip-chip {
|
Так оператор видит границу полосы — что выделит Shift+клик и за что выйти нельзя. У активной
|
||||||
display: inline-flex;
|
строки рельс перекрывается акцентным (is-active), это норма. */
|
||||||
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), это норма. */
|
|
||||||
.tgu-slots-table tbody tr.tgu-slots-row--strip {
|
.tgu-slots-table tbody tr.tgu-slots-row--strip {
|
||||||
box-shadow: inset 3px 0 0 hsl(var(--strip-hue, 0) 45% 55% / 0.55);
|
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);
|
background: rgba(104, 195, 189, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user