feat(tgu-ui): #50 слой поддержки решений по слотам + выбор полосами

Поверх ручного бронирования — информационный слой (достаточность судит оператор).

M1 покрытие: coverage.ts (растровое Monte-Carlo % покрытия заявки, чистые функции
  + coverage.test.ts), CoveragePanel (живой % + бар), слой coverage на карте
  (drawCoverage, union контуров, клип по заявке).
M2 эргономика: сортировка списка по вкладу в покрытие (жадный маргинальный прирост),
  по крену; колонка «Вклад» (+N%).
M3 конфликты: бейдж держателя BUSY-слота (заявка + важность) из holders в SlotDTO.

Выбор полосами (TASK_slot-strip-selection it.1): slotStrips.ts (groupStrips —
  same-roll цепочки по-КА, ε=0.01 с якорным креном, тотальный порядок) +
  slotStrips.test.ts; toggleStripSelection в slotSelection.ts; чип «полоса ×N» в
  строке, рельс по StripId в сортировке «время», подсветка полосы при наведении.
  Физику выполнимости не дублируем на фронте — это backend validate (it.2).

Смежный баг: anchorIndexRef (якорь Shift-выбора) сбрасывается при смене видимого
набора (фильтры/окно суток), не только сортировки.
This commit is contained in:
Дмитрий Соловьев
2026-06-17 09:06:25 +03:00
parent 20d9eb9579
commit 1f44c1a5bb
15 changed files with 1473 additions and 65 deletions
@@ -26,9 +26,20 @@ import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView";
import { formatLocalDateTimeSeconds, parseLocal, toLocalInputValue, toLocalIso } from "../../utils/localTime";
import { SlotDaySelector, type SlotDay } from "./SlotDaySelector";
import { SlotPickMenu, type SlotPick } from "./SlotPickMenu";
import { SlotFilterBar } from "./SlotFilterBar";
import { SlotFilterBar, type SlotSortMode } from "./SlotFilterBar";
import { CoveragePanel } from "./CoveragePanel";
import {
buildCoverageGrid,
coverageOfMasks,
rankByMarginalGain,
sampleSlotMask,
unionMasks,
type RankCandidate,
type SlotMask
} from "./coverage";
import { applySlotFilters, slotStatusKey, type SlotStatusKey } from "./slotFilters";
import { selectionAfterCheck } from "./slotSelection";
import { selectionAfterCheck, toggleStripSelection } from "./slotSelection";
import { groupStrips } from "./slotStrips";
import { runChunked } from "./runChunked";
import { ChunkProgress } from "./ChunkProgress";
@@ -87,6 +98,13 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
const [slots, setSlots] = useState<SlotDTO[]>([]);
const [bookedSlots, setBookedSlots] = useState<BookedSlotView[]>([]);
const [selectedKeys, setSelectedKeys] = useState<ReadonlySet<string>>(() => new Set());
// Тумблер следа покрытия на карте (D4 — локальный state, вкл. по умолчанию; глобальный
// Tgu2DMapLayersState не трогаем). Оператор может снять след, оставив только % в панели.
const [coverageOn, setCoverageOn] = useState(true);
// Сортировка таблицы: «время» (порядок расчёта), «вклад» (жадное ранжирование по приросту
// покрытия, колонка +N%), «крен» (по модулю угла крена). Вклад доступен только при наличии
// геометрии заявки (иначе чип «вклад» заблокирован в SlotFilterBar).
const [sortMode, setSortMode] = useState<SlotSortMode>("time");
// Фильтр отображения по суткам: индексы (1-based) выбранного диапазона суток расчёта.
const [dayFrom, setDayFrom] = useState(1);
@@ -127,6 +145,9 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
// Подсветка строки/контура по клику на карте и центрирование на слоте.
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);
@@ -274,14 +295,35 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
[dayWindowed, statusFilter, satFilter, branchFilter, bookedKeys],
);
// Виртуализация таблицы: в DOM держим только видимое окно строк (+overscan), а не все слоты —
// иначе на тысячах строк рендер встаёт. Скролл-контейнер — секция списка (listRef).
const rowVirtualizer = useVirtualizer({
count: filteredSlots.length,
getScrollElement: () => listRef.current,
estimateSize: () => ROW_HEIGHT,
overscan: 12
});
// Идентичность полос — над СТАБИЛЬНЫМ набором displaySlots (НЕ filteredSlots/sortedSlots:
// иначе фильтр-дырка ложно разорвала или склеила бы полосу). Map<slotKey, StripId>.
const stripIdByKey = 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-диапазон после фильтрации выбрал бы не те строки.
useEffect(() => {
anchorIndexRef.current = null;
}, [filteredSlots]);
// Загрузка заявок интервала (на карту); вызывается на входе, при смене диапазона и из фильтра.
const loadRequests = useCallback(async (fromInput: string, toInput: string) => {
@@ -312,16 +354,6 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
void loadRequests(fromInput, toInput);
}, [range.fromMs, range.toMs, loadRequests]);
// Клик по слоту на карте подсвечивает строку — центрируем её в видимой части таблицы.
// Скроллим контейнер вручную (по rect-ам), а не через scrollIntoView: тот может выбрать другой
// скролл-предок или счесть строку «достаточно близкой» и не сдвинуться.
useEffect(() => {
if (!activeSlotId || !activeSlotId.startsWith("slot:")) return;
const key = activeSlotId.slice("slot:".length);
const index = filteredSlots.findIndex((slot) => slotKey(slot) === key);
if (index >= 0) rowVirtualizer.scrollToIndex(index, { align: "center" });
}, [activeSlotId, filteredSlots, rowVirtualizer]);
const focusOn = useCallback((point?: GeoPoint) => {
if (!point) return;
setFocusPoint(point);
@@ -340,6 +372,150 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
return rings;
}, []);
// Сетка сэмплов покрытия по геометрии заявки (кэш на заявку, D1). null — заявка без геометрии
// или дегенерат (0 валидных сэмплов) → панель «недоступно».
const coverageGrid = useMemo(
() => (selectedRequest?.geometry ? buildCoverageGrid(parseWktPolygon(selectedRequest.geometry)) : null),
[selectedRequest?.geometry]
);
// Маска слота на сетке заявки, с кэшем по контуру (D2 — сигнатура grid+контур). Кэш живёт ровно
// пока стабильна сетка: новый coverageGrid (смена заявки/геометрии) пересоздаёт Map → чистка.
const maskFor = useMemo(() => {
const grid = coverageGrid;
const cache = new Map<string, SlotMask>();
return (key: string, contour: string): SlotMask | null => {
if (!grid) return null;
let mask = cache.get(contour);
if (!mask) {
mask = sampleSlotMask(grid, ringsFor(key, contour));
cache.set(contour, mask);
}
return mask;
};
}, [coverageGrid, ringsFor]);
// Кольца следа покрытия для карты = union контуров (забронированное ∪ выбранное галочками),
// независимо от фильтров вида (codex P1). Пусто, когда тумблер выключен или нет заявки.
const coverageRings = useMemo<GeoPoint[][] | undefined>(() => {
if (!coverageOn || !hasRequest) return undefined;
const rings: GeoPoint[][] = [];
const seen = new Set<string>();
const push = (key: string, contour: string) => {
if (seen.has(key)) return;
seen.add(key);
for (const ring of ringsFor(key, contour)) if (ring.length >= 3) rings.push(ring);
};
for (const slot of bookedSlots) push(slotKey(slot), slot.contour);
for (const key of selectedKeys) {
const slot = slotByKey.get(key);
if (slot) push(key, slot.contour);
}
return rings;
}, [coverageOn, hasRequest, bookedSlots, selectedKeys, slotByKey, ringsFor]);
// % покрытия для панели: забронированным набором и набором (забронированное ∪ выбранное).
// Семантика codex: % = покрытие union(booked selected), независимо от фильтров вида.
const coverageStats = useMemo(() => {
if (!coverageGrid) return { booked: null, withSelection: null, hasSelection: false };
const bookedMasks: SlotMask[] = [];
for (const slot of bookedSlots) {
const mask = maskFor(slotKey(slot), slot.contour);
if (mask) bookedMasks.push(mask);
}
const selMasks: SlotMask[] = [];
let hasSelection = false;
for (const key of selectedKeys) {
if (bookedKeys.has(key)) continue; // бронь уже учтена
const slot = slotByKey.get(key);
if (!slot) continue;
const mask = maskFor(key, slot.contour);
if (mask) {
selMasks.push(mask);
hasSelection = true;
}
}
return {
booked: coverageOfMasks(coverageGrid, bookedMasks),
withSelection: coverageOfMasks(coverageGrid, [...bookedMasks, ...selMasks]),
hasSelection,
};
}, [coverageGrid, maskFor, bookedSlots, selectedKeys, bookedKeys, slotByKey]);
// Покрытие доступно (есть геометрия заявки) — гейт сортировки «по вкладу» и колонки +N%.
const coverageAvailable = coverageGrid !== null;
// Ранжирование «по вкладу»: жадно (rankByMarginalGain) поверх забронированного union по ВИДИМЫМ
// незабронированным кандидатам. Вклад фиксируется на момент ранжирования (не live — отсюда подпись
// колонки). Считаем только в режиме сортировки «вклад» и при наличии сетки. key → gainPercent + ранг.
const gainRanking = useMemo(() => {
if (sortMode !== "gain" || !coverageGrid) return null;
const bookedMasks: SlotMask[] = [];
for (const slot of bookedSlots) {
const mask = maskFor(slotKey(slot), slot.contour);
if (mask) bookedMasks.push(mask);
}
const baseUnion = unionMasks(coverageGrid, bookedMasks);
const candidates: RankCandidate[] = [];
for (const slot of filteredSlots) {
const key = slotKey(slot);
if (bookedKeys.has(key)) continue; // бронь уже в baseUnion → её вклад 0
const mask = maskFor(key, slot.contour);
if (mask) candidates.push({ key, mask });
}
const ranked = rankByMarginalGain(coverageGrid, candidates, baseUnion);
const order = new Map<string, number>();
const pct = new Map<string, number>();
ranked.forEach((r, i) => {
order.set(r.key, i);
pct.set(r.key, r.gainPercent);
});
return { order, pct };
}, [sortMode, coverageGrid, maskFor, bookedSlots, filteredSlots, bookedKeys]);
// Колонку +N% показываем только в режиме «вклад» и при наличии геометрии.
const showGain = sortMode === "gain" && coverageAvailable;
// Порядок строк таблицы по выбранной сортировке. «время» — исходный (порядок расчёта, как есть).
// «крен» — по модулю крена (меньше = лучше геометрия), стабильно. «вклад» — жадный ранг; брони
// (вне ранжирования) уходят в конец, сохраняя исходный порядок. sort стабилен (ES2019+).
const sortedSlots = useMemo<SlotDTO[]>(() => {
if (sortMode === "roll") {
return [...filteredSlots].sort((a, b) => Math.abs(a.roll) - Math.abs(b.roll));
}
if (sortMode === "gain" && gainRanking) {
const order = gainRanking.order;
return [...filteredSlots].sort((a, b) => {
const ra = order.get(slotKey(a));
const rb = order.get(slotKey(b));
if (ra == null && rb == null) return 0;
if (ra == null) return 1;
if (rb == null) return -1;
return ra - rb;
});
}
return filteredSlots;
}, [sortMode, filteredSlots, gainRanking]);
// Виртуализация таблицы: в DOM держим только видимое окно строк (+overscan), а не все слоты —
// иначе на тысячах строк рендер встаёт. Скролл-контейнер — секция списка (listRef).
const rowVirtualizer = useVirtualizer({
count: sortedSlots.length,
getScrollElement: () => listRef.current,
estimateSize: () => ROW_HEIGHT,
overscan: 12
});
// Клик по слоту на карте подсвечивает строку — центрируем её в видимой части таблицы.
// Скроллим контейнер вручную (по rect-ам), а не через scrollIntoView: тот может выбрать другой
// скролл-предок или счесть строку «достаточно близкой» и не сдвинуться.
useEffect(() => {
if (!activeSlotId || !activeSlotId.startsWith("slot:")) return;
const key = activeSlotId.slice("slot:".length);
const index = sortedSlots.findIndex((slot) => slotKey(slot) === key);
if (index >= 0) rowVirtualizer.scrollToIndex(index, { align: "center" });
}, [activeSlotId, sortedSlots, rowVirtualizer]);
// Загружает брони выбранной заявки (для бейджей и слоя карты).
const loadBooked = useCallback(async (requestId: string) => {
try {
@@ -368,6 +544,7 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
// Смена заявки — фильтры видимости в дефолт (satFilter раскроется через satSignature-эффект).
setStatusFilter(new Set<SlotStatusKey>(["free", "booked", "occupied"]));
setBranchFilter(new Set<RevolutionSign>(["ASC", "DESC"]));
setSortMode("time");
if (selectedRequestId) void loadBooked(selectedRequestId);
}, [selectedRequestId, loadBooked]);
@@ -482,8 +659,8 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
// Источник истины — ответ POST: BookedSlotDTO несёт bookedSlotId и тот же cycle, что мы
// отправили (= cycle рассчитанного слота). Контур берём из рассчитанного слота по ключу;
// by-request не перечитываем. Так bookedSlots получает и контур, и bookedSlotId.
const idByKey = new Map(
collected.map((item) => [`${item.satelliteId}:${item.slotNum}:${item.cycle}`, item.bookedSlotId] as const),
const idByKey = new Map<string, number>(
collected.map((item) => [`${item.satelliteId}:${item.slotNum}:${item.cycle}`, item.bookedSlotId]),
);
const justBooked: BookedSlotView[] = slots
.filter((slot) => idByKey.has(slotKey(slot)))
@@ -530,11 +707,13 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
// (тот же бар, что у брони), затем убирает снятые из bookedSlots и из выбора. На него опираются
// и «Снять выбранные», и «Снять всё», и ✕ в строке — снятие везде пачками с прогрессом, а не
// одним bulk-запросом (по одному HTTP нельзя показать ход на 7000 слотов). setBusy — флаг кнопки.
const runCancel = async (ids: number[], setBusy: (value: boolean) => void) => {
const runCancel = async (ids: number[], setBusy: (value: boolean) => void, forceProgress = false) => {
setBusy(true);
setError(undefined);
setNotice(undefined);
setProgress(ids.length > BOOK_CHUNK ? { label: "Снятие брони", done: 0, total: ids.length } : null);
// forceProgress — для «Снять всё»: держим бар весь процесс независимо от объёма (операция
// делает доп. сверку by-request, поэтому заметно дольше точечного снятия даже на малом наборе).
setProgress(forceProgress || ids.length > BOOK_CHUNK ? { label: "Снятие брони", done: 0, total: ids.length } : null);
try {
const counts = await runChunked(
ids,
@@ -577,6 +756,12 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
// набора и серверного. Так «Снять всё» убирает вообще все брони заявки, даже если локальный
// bookedSlots разошёлся с базой (bookedSlotId надёжен независимо от cycle-фрейма брони).
const performCancelAll = async () => {
// Мгновенная обратная связь (как у «Забронировать»/«Снять выбранные»): входим в busy и
// показываем прогресс СРАЗУ, ещё до сетевой сверки by-request, а не после её round-trip.
setCancelling(true);
setError(undefined);
setNotice(undefined);
setProgress({ label: "Снятие брони", done: 0, total: bookedSlots.length });
const idSet = new Set(bookedSlots.map((slot) => slot.bookedSlotId));
try {
const fresh = await fetchBookedSlotsByRequest(selectedRequestId);
@@ -584,7 +769,7 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
} catch {
// by-request недоступен — снимаем по локальному набору броней.
}
await runCancel(Array.from(idSet), setCancelling);
await runCancel(Array.from(idSet), setCancelling, true);
};
const cancelAll = () => {
@@ -601,7 +786,7 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
// текущей строки; направление (выбрать/снять) задаётся новым состоянием текущей строки.
const handleSlotCheck = (index: number, shiftKey: boolean) => {
setSelectedKeys((current) =>
selectionAfterCheck(current, (i) => slotKey(filteredSlots[i]), index, shiftKey, anchorIndexRef.current)
selectionAfterCheck(current, (i) => slotKey(sortedSlots[i]), index, shiftKey, anchorIndexRef.current)
);
anchorIndexRef.current = index;
};
@@ -620,6 +805,15 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
});
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;
};
// Выбрать все слоты расчёта (по всем суткам, кроме занятых бронью), снять весь выбор.
const selectAllDays = () =>
setSelectedKeys(new Set(displaySlots.filter((slot) => !slotIsBusy(slot)).map(slotKey)));
@@ -635,6 +829,11 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
else next.add(value);
return next;
};
// Смена сортировки меняет позиции строк — якорь Shift-выбора больше не валиден, сбрасываем.
const changeSort = (mode: SlotSortMode) => {
setSortMode(mode);
anchorIndexRef.current = null;
};
const toggleStatus = (key: SlotStatusKey) => setStatusFilter((prev) => toggleInSet(prev, key));
const toggleSat = (id: number) => setSatFilter((prev) => toggleInSet(prev, id));
const toggleBranch = (branch: RevolutionSign) => setBranchFilter((prev) => toggleInSet(prev, branch));
@@ -761,7 +960,7 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
}
}
}
return { polygons, lines: [], markers: [] };
return { polygons, lines: [], markers: [], coverageRings };
}, [
hasRequest,
requestItems,
@@ -773,7 +972,8 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
ringsFor,
statusFilter,
satFilter,
branchFilter
branchFilter,
coverageRings
]);
const layers = useMemo<Tgu2DMapLayersState>(
@@ -873,6 +1073,9 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
? "Слоты не рассчитаны — нажмите «Показать все слоты» или «Подобрать покрытие»."
: undefined;
// Число колонок таблицы (для colSpan распорок виртуализатора) — +1 на колонку «Вклад».
const colCount = showGain ? 8 : 7;
// Видимое окно строк виртуализатора и распорки сверху/снизу (держат суммарную высоту списка).
const virtualRows = rowVirtualizer.getVirtualItems();
const virtualTotal = rowVirtualizer.getTotalSize();
@@ -983,10 +1186,23 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
onToggleSat={toggleSat}
branchFilter={branchFilter}
onToggleBranch={toggleBranch}
sortMode={sortMode}
onSortMode={changeSort}
gainAvailable={coverageAvailable}
isDefault={filtersDefault}
onReset={resetFilters}
/>
)}
{displaySlots.length > 0 && (
<CoveragePanel
booked={coverageStats.booked}
withSelection={coverageStats.withSelection}
hasSelection={coverageStats.hasSelection}
available={coverageGrid !== null}
coverageOn={coverageOn}
onToggleCoverage={setCoverageOn}
/>
)}
<div className="tgu-slots-selbar">
<span className="tgu-slots-selbar__count">
Выбрано: {selectedKeys.size}
@@ -1052,28 +1268,63 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
<th>Начало</th>
<th>Длит.</th>
<th>Крен</th>
{showGain && (
<th
className="tgu-slots-gain-col"
title="Прирост покрытия в порядке ранжирования — фиксируется на момент ранжирования, не пересчитывается при выборе"
>
Вклад
</th>
)}
<th>Статус</th>
</tr>
</thead>
<tbody>
{padTop > 0 && (
<tr aria-hidden="true" style={{ height: padTop }}>
<td colSpan={7} />
<td colSpan={colCount} />
</tr>
)}
{virtualRows.map((vrow) => {
const slot = filteredSlots[vrow.index];
const slot = sortedSlots[vrow.index];
const key = slotKey(slot);
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";
const rowClass = [
isActive ? "is-active" : "",
isStrip && hoveredStrip === stripId ? "is-strip-hover" : "",
showRail ? "tgu-slots-row--strip" : "",
]
.filter(Boolean)
.join(" ");
return (
<tr
key={key}
data-slot-id={id}
style={{ height: ROW_HEIGHT }}
className={isActive ? "is-active" : undefined}
style={{
height: ROW_HEIGHT,
...(showRail && stripId
? { ["--strip-hue" as string]: String(stripHue(stripId)) }
: {}),
}}
className={rowClass || undefined}
onClick={() => selectRow(slot)}
onMouseEnter={isStrip ? () => setHoveredStrip(stripId!) : undefined}
onMouseLeave={isStrip ? () => setHoveredStrip(null) : undefined}
>
<td onClick={(event) => event.stopPropagation()}>
<input
@@ -1086,7 +1337,33 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
aria-label="Выбрать слот"
/>
</td>
<td>{slot.satelliteId}</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.revolution} / {slot.revolutionSign === "DESC" ? "нисх" : "восх"} /{" "}
{slot.cycle}
@@ -1094,6 +1371,11 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
<td>{formatLocalDateTimeSeconds(parseLocal(slot.tn))}</td>
<td>{slotDurationSec(slot)} с</td>
<td>{slot.roll.toFixed(1)}°</td>
{showGain && (
<td className="tgu-slots-gain">
{booked ? "—" : `+${gainRanking?.pct.get(key) ?? 0}%`}
</td>
)}
<td>
{booked ? (
<span className="tgu-slots-status-cell">
@@ -1114,14 +1396,35 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
</button>
</span>
) : (
<span
className={
slot.state === "BUSY"
? "tgu-slots-badge tgu-slots-badge--occupied"
: "tgu-slots-badge"
}
>
{slotStateLabel(slot.state)}
<span className="tgu-slots-status-cell">
<span
className={
slot.state === "BUSY"
? "tgu-slots-badge tgu-slots-badge--occupied"
: "tgu-slots-badge"
}
>
{slotStateLabel(slot.state)}
</span>
{slot.state === "BUSY" &&
slot.holders?.map((holder) => (
<span
key={holder.requestId}
className="tgu-slots-holder"
title={`Слот держит заявка ${holder.name ?? holder.requestId}${
holder.importance != null
? ` · важность ${holder.importance.toFixed(2)}`
: ""
}`}
>
{holder.name ?? holder.requestId}
{holder.importance != null && (
<span className="tgu-slots-holder__imp">
{holder.importance.toFixed(2)}
</span>
)}
</span>
))}
</span>
)}
</td>
@@ -1130,7 +1433,7 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
})}
{padBottom > 0 && (
<tr aria-hidden="true" style={{ height: padBottom }}>
<td colSpan={7} />
<td colSpan={colCount} />
</tr>
)}
</tbody>
@@ -1254,7 +1557,11 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
<Tgu2DMapView
scene={scene}
layers={layers}
redrawToken={hasRequest ? filteredSlots.length + bookedSlots.length : requestItems.length}
redrawToken={
hasRequest
? filteredSlots.length + bookedSlots.length + selectedKeys.size + (coverageOn ? 1 : 0)
: requestItems.length
}
enableObjectChooser
onObjectsAtPoint={handleObjectsAtPoint}
onObjectSelect={handleObjectSelect}
@@ -1304,6 +1611,14 @@ function slotIsBusy(slot: Pick<SlotDTO, "state">): boolean {
return slot.state === "BUSY";
}
/** Детерминированный оттенок (0..359) полосы по её StripId — для приглушённого рельса слева,
* чтобы соседние полосы визуально различались. Чистый хэш строки, без состояния. */
function stripHue(stripId: string): number {
let h = 0;
for (let i = 0; i < stripId.length; i++) h = (h * 31 + stripId.charCodeAt(i)) >>> 0;
return h % 360;
}
function slotStateLabel(state: string): string {
switch (state) {
case "AVAILABLE":