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:
@@ -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":
|
||||
|
||||
Reference in New Issue
Block a user