feat(tgu-ui): вкладка «Слоты» — расчёт покрытия и бронирование слотов заявки

- новая вкладка: заявки интервала на карте, выбор заявки кликом/инспектором,
  расчёт слотов покрытия, бронирование и снятие брони
- выбор заявки без выпадающего списка (не масштабируется на ~10к): все заявки
  на карте, панель справа с кнопкой «Выбрать», имя выбранной заявки в тулбаре,
  кнопка «Сброс», фильтр заявок по интервалу
- бронь отмечаем по ответу POST booking/request (by-request отдаёт слот в чужом
  cycle-фрейме — ключ не совпадал, бейдж «бронь» не появлялся)
- подсветка и автоскролл активной строки по клику на слот (ручной scrollBy)
- меню выбора при плотном наложении слотов (SlotPickMenu); контур заявки
  pickable:false, чтобы не «съедать» меню; панель не закрывается при выборе
- фильтр отображения по суткам (SlotDaySelector)
- gateway: маршрут slots (/api/pcp-slots/**)
- карточка полосы обзора (swath): КА и виток в инспекторе
This commit is contained in:
Дмитрий Соловьев
2026-06-11 12:20:28 +03:00
parent 802c966347
commit 6689351ef0
18 changed files with 1735 additions and 47 deletions
@@ -0,0 +1,801 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { fetchRequestsForMap } from "../../api/requestApi";
import {
bookRequestSlots,
cancelRequestBooking,
fetchBookedSlotsByRequest,
fetchRequestSlots
} from "../../api/slotsApi";
import type { RequestMapItem } from "../../model/requestTypes";
import type { RevolutionSign, SlotDTO } from "../../model/slotTypes";
import type { TimelineRange } from "../../model/timelineTypes";
import { DateTime24Field } from "../../components/timeFields";
import { parseWktPolygon } from "../tgu-map-2d/geometry/wktParser";
import type { GeoPoint, MapPolygon, Tgu2DMapScene } from "../tgu-map-2d/model/mapTypes";
import type { Tgu2DMapLayersState } from "../tgu-map-2d/model/mapLayerTypes";
import type { Tgu2DMapSelection } from "../tgu-map-2d/model/mapSelectionTypes";
import { MapObjectInspector } from "../tgu-map-2d/MapObjectInspector";
import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView";
import { formatLocalDateTime, parseLocal, toLocalInputValue, toLocalIso } from "../../utils/localTime";
import { SlotDaySelector, type SlotDay } from "./SlotDaySelector";
import { SlotPickMenu, type SlotPick } from "./SlotPickMenu";
const DAY_MS = 24 * 60 * 60 * 1000;
/** Начало локальных суток момента (мс) — для группировки слотов по суткам. */
function dayStartMs(ms: number): number {
const d = new Date(ms);
d.setHours(0, 0, 0, 0);
return d.getTime();
}
/** Метка суток ДД.ММ (локально). */
function dayLabel(ms: number): string {
const d = new Date(ms);
return `${String(d.getDate()).padStart(2, "0")}.${String(d.getMonth() + 1).padStart(2, "0")}`;
}
type TguSlotsTabProps = {
/** Диапазон вкладки «Планы» — начальный интервал отбора заявок и расчёта слотов. */
range: TimelineRange;
};
/** Ключ слота: КА + номер слота + цикл — уникален в пределах расчёта и совпадает с ключом брони. */
function slotKey(slot: { satelliteId: number; slotNumber: number; cycle: number }): string {
return `${slot.satelliteId}:${slot.slotNumber}:${slot.cycle}`;
}
export function TguSlotsTab({ range }: TguSlotsTabProps) {
// Заявки интервала (показываются на карте, выбираются кликом) и выбранная заявка.
const [requestItems, setRequestItems] = useState<RequestMapItem[]>([]);
const [requestsLoading, setRequestsLoading] = useState(false);
const [requestsLoaded, setRequestsLoaded] = useState(false);
const [requestsTotal, setRequestsTotal] = useState(0);
const [requestsTruncated, setRequestsTruncated] = useState(false);
const [requestsError, setRequestsError] = useState<string>();
const [selectedRequestId, setSelectedRequestId] = useState<string>("");
// Интервал отбора заявок — какие заявки показываем на карте (аналог фильтра вкладки «Карта»).
const [reqFromValue, setReqFromValue] = useState(() => toLocalInputValue(range.fromMs));
const [reqToValue, setReqToValue] = useState(() => toLocalInputValue(range.toMs));
// Интервал и фильтры расчёта слотов выбранной заявки.
const [fromValue, setFromValue] = useState(() => toLocalInputValue(range.fromMs));
const [toValue, setToValue] = useState(() => toLocalInputValue(range.toMs));
const [revSign, setRevSign] = useState<"" | RevolutionSign>("");
const [withCoverage, setWithCoverage] = useState(false);
// Результаты: слоты-кандидаты и уже забронированные слоты заявки.
const [slots, setSlots] = useState<SlotDTO[]>([]);
const [bookedSlots, setBookedSlots] = useState<SlotDTO[]>([]);
const [selectedKeys, setSelectedKeys] = useState<ReadonlySet<string>>(() => new Set());
// Фильтр отображения по суткам: индексы (1-based) выбранного диапазона суток расчёта.
const [dayFrom, setDayFrom] = useState(1);
const [dayTo, setDayTo] = useState(1);
const [calculating, setCalculating] = useState(false);
const [booking, setBooking] = useState(false);
const [error, setError] = useState<string>();
const [notice, setNotice] = useState<string>();
// Подсветка строки/контура по клику на карте и центрирование на слоте.
const [activeSlotId, setActiveSlotId] = useState<string>();
const [focusPoint, setFocusPoint] = useState<GeoPoint>();
const focusTokenRef = useRef(0);
const [focusToken, setFocusToken] = useState(0);
// Контейнер списка слотов — по нему ищем активную строку (data-slot-id) и скроллим её в зону видимости.
const listRef = useRef<HTMLElement>(null);
// Инспектор заявки справа над картой: объекты под кликом и раскрытый (только до выбора заявки).
const [mapHits, setMapHits] = useState<Tgu2DMapSelection[]>([]);
const [activeHitId, setActiveHitId] = useState<string>();
// Меню выбора слота при плотном наложении (несколько слотов под точкой клика).
const [slotPicks, setSlotPicks] = useState<SlotPick[]>([]);
const hasRequest = !!selectedRequestId;
const selectedRequest = useMemo(
() => requestItems.find((item) => item.id === selectedRequestId),
[requestItems, selectedRequestId]
);
const parsedRange = useMemo(
() => ({ fromMs: parseLocal(fromValue), toMs: parseLocal(toValue) }),
[fromValue, toValue]
);
const rangeInvalid =
!Number.isFinite(parsedRange.fromMs) ||
!Number.isFinite(parsedRange.toMs) ||
parsedRange.fromMs >= parsedRange.toMs;
// Ключи забронированных слотов — для бейджа «бронь» в таблице и отдельного слоя на карте.
const bookedKeys = useMemo(() => new Set(bookedSlots.map(slotKey)), [bookedSlots]);
// Слот по ключу — чтобы из выбора на карте (id "slot:key"/"booked:key") восстановить полные данные.
const slotByKey = useMemo(() => {
const map = new Map<string, SlotDTO>();
for (const slot of slots) map.set(slotKey(slot), slot);
for (const slot of bookedSlots) if (!map.has(slotKey(slot))) map.set(slotKey(slot), slot);
return map;
}, [slots, bookedSlots]);
// Сутки, по которым раскладываются рассчитанные слоты (для фильтра отображения).
const days = useMemo<SlotDay[]>(() => {
const buckets = new Map<number, number>();
for (const slot of slots) {
const ms = parseLocal(slot.tn);
if (!Number.isFinite(ms)) continue;
const start = dayStartMs(ms);
buckets.set(start, (buckets.get(start) ?? 0) + 1);
}
return Array.from(buckets.entries())
.sort(([a], [b]) => a - b)
.map(([dayMs, count], idx) => ({ index: idx + 1, dayMs, count, label: dayLabel(dayMs) }));
}, [slots]);
// Новый расчёт меняет набор суток — раскрываем фильтр на весь диапазон.
useEffect(() => {
setDayFrom(1);
setDayTo(days.length || 1);
}, [days]);
const effDayFrom = Math.max(1, Math.min(dayFrom, days.length || 1));
const effDayTo = Math.max(effDayFrom, Math.min(dayTo, days.length || 1));
// Слоты выбранного диапазона суток — то, что видно и в таблице, и на карте.
const filteredSlots = useMemo(() => {
if (days.length === 0) return slots;
const fromMs = days[effDayFrom - 1].dayMs;
const toMsExcl = days[effDayTo - 1].dayMs + DAY_MS;
return slots.filter((slot) => {
const ms = parseLocal(slot.tn);
return Number.isFinite(ms) && ms >= fromMs && ms < toMsExcl;
});
}, [slots, days, effDayFrom, effDayTo]);
// Загрузка заявок интервала (на карту); вызывается на входе, при смене диапазона и из фильтра.
const loadRequests = useCallback(async (fromInput: string, toInput: string) => {
const fromMs = parseLocal(fromInput);
const toMs = parseLocal(toInput);
if (!Number.isFinite(fromMs) || !Number.isFinite(toMs) || fromMs >= toMs) return;
setRequestsLoading(true);
setRequestsError(undefined);
try {
const result = await fetchRequestsForMap({ endFrom: toLocalIso(fromMs), beginTo: toLocalIso(toMs) });
setRequestItems(result.items);
setRequestsTotal(result.totalItems);
setRequestsTruncated(result.truncated);
setRequestsLoaded(true);
} catch (caught) {
setRequestsError(caught instanceof Error ? caught.message : "Не удалось загрузить заявки.");
} finally {
setRequestsLoading(false);
}
}, []);
// Заявки за диапазон вкладки — грузим на входе и при смене диапазона.
useEffect(() => {
const fromInput = toLocalInputValue(range.fromMs);
const toInput = toLocalInputValue(range.toMs);
setReqFromValue(fromInput);
setReqToValue(toInput);
void loadRequests(fromInput, toInput);
}, [range.fromMs, range.toMs, loadRequests]);
// Клик по слоту на карте подсвечивает строку — центрируем её в видимой части таблицы.
// Скроллим контейнер вручную (по rect-ам), а не через scrollIntoView: тот может выбрать другой
// скролл-предок или счесть строку «достаточно близкой» и не сдвинуться.
useEffect(() => {
if (!activeSlotId) return;
const container = listRef.current;
const row = container?.querySelector<HTMLElement>(`[data-slot-id="${activeSlotId}"]`);
if (!container || !row) return;
const cRect = container.getBoundingClientRect();
const rRect = row.getBoundingClientRect();
if (rRect.top >= cRect.top && rRect.bottom <= cRect.bottom) return; // строка уже целиком видна
const delta = rRect.top + rRect.height / 2 - (cRect.top + container.clientHeight / 2);
container.scrollBy({ top: delta, behavior: "smooth" });
}, [activeSlotId]);
const focusOn = useCallback((point?: GeoPoint) => {
if (!point) return;
setFocusPoint(point);
focusTokenRef.current += 1;
setFocusToken(focusTokenRef.current);
}, []);
// Загружает брони выбранной заявки (для бейджей и слоя карты).
const loadBooked = useCallback(async (requestId: string) => {
try {
const booked = await fetchBookedSlotsByRequest(requestId);
setBookedSlots(booked);
} catch (caught) {
setBookedSlots([]);
setError(caught instanceof Error ? caught.message : "Не удалось загрузить брони заявки.");
}
}, []);
// Смена заявки — сбрасываем результаты и подтягиваем уже существующие брони.
useEffect(() => {
setSlots([]);
setSelectedKeys(new Set());
setBookedSlots([]);
setNotice(undefined);
setError(undefined);
setActiveSlotId(undefined);
if (selectedRequestId) void loadBooked(selectedRequestId);
}, [selectedRequestId, loadBooked]);
const reqFromMs = parseLocal(reqFromValue);
const reqToMs = parseLocal(reqToValue);
const reqRangeInvalid =
!Number.isFinite(reqFromMs) || !Number.isFinite(reqToMs) || reqFromMs >= reqToMs;
const applyRequests = () => void loadRequests(reqFromValue, reqToValue);
const setQuickRange = (fromMs: number, toMs: number) => {
const fromInput = toLocalInputValue(fromMs);
const toInput = toLocalInputValue(toMs);
setReqFromValue(fromInput);
setReqToValue(toInput);
void loadRequests(fromInput, toInput);
};
const quickToday = () => {
const now = new Date();
const fromMs = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
setQuickRange(fromMs, fromMs + DAY_MS);
};
const quick7days = () => setQuickRange(Date.now(), Date.now() + 7 * DAY_MS);
const quick30days = () => setQuickRange(Date.now(), Date.now() + 30 * DAY_MS);
const calculate = async () => {
if (!selectedRequestId || rangeInvalid || calculating) return;
setCalculating(true);
setError(undefined);
setNotice(undefined);
try {
const result = await fetchRequestSlots({
requestId: selectedRequestId,
timeStart: toLocalIso(parsedRange.fromMs),
timeStop: toLocalIso(parsedRange.toMs),
revSign: revSign || undefined,
cov: withCoverage
});
setSlots(result);
setSelectedKeys(new Set());
setNotice(
result.length > 0
? `Найдено слотов: ${result.length}.`
: "Для выбранного интервала слотов покрытия не найдено."
);
if (result.length > 0) {
focusOn({ lat: result[0].latitude, lon: result[0].longitude });
}
} catch (caught) {
setSlots([]);
setError(caught instanceof Error ? caught.message : "Не удалось рассчитать слоты.");
} finally {
setCalculating(false);
}
};
const book = async () => {
if (!selectedRequestId || booking || selectedKeys.size === 0) return;
const toBook = slots
.filter((slot) => selectedKeys.has(slotKey(slot)))
.map((slot) => ({ satelliteId: slot.satelliteId, slotNum: slot.slotNumber, cycle: slot.cycle }));
if (toBook.length === 0) return;
setBooking(true);
setError(undefined);
setNotice(undefined);
try {
const booked = await bookRequestSlots(selectedRequestId, toBook);
// Источник истины — ответ POST: BookedSlotDTO несёт тот же cycle, что мы отправили (= cycle
// рассчитанного слота). А вот GET booking/by-request отдаёт базовый слот в ДРУГОМ цикле
// (slot.toDTO без сдвига на cycle брони), из-за чего ключ sat:slotNum:cycle не совпадает и
// бейдж «бронь» не появляется. Поэтому отмечаем забронированными ровно те рассчитанные слоты
// (с контуром), что вернул POST, не перечитывая by-request.
const bookedSet = new Set(booked.map((item) => `${item.satelliteId}:${item.slotNum}:${item.cycle}`));
const justBooked = slots.filter((slot) => bookedSet.has(slotKey(slot)));
setBookedSlots((prev) => {
const merged = new Map(prev.map((slot) => [slotKey(slot), slot]));
for (const slot of justBooked) merged.set(slotKey(slot), slot);
return Array.from(merged.values());
});
setSelectedKeys(new Set());
setNotice(`Забронировано слотов: ${booked.length}.`);
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Не удалось забронировать слоты.");
} finally {
setBooking(false);
}
};
const cancelBooking = async () => {
if (!selectedRequestId || booking || bookedSlots.length === 0) return;
setBooking(true);
setError(undefined);
setNotice(undefined);
try {
const cancelled = await cancelRequestBooking(selectedRequestId);
// Снимаются все брони заявки — очищаем локально (by-request не перечитываем, см. book()).
setBookedSlots([]);
setNotice(`Снято броней: ${cancelled}.`);
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Не удалось снять бронь.");
} finally {
setBooking(false);
}
};
const toggleSlot = (key: string) => {
setSelectedKeys((current) => {
const next = new Set(current);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
};
// «Выбрать все» работает по видимым (отфильтрованным) слотам.
const allSelected =
filteredSlots.length > 0 && filteredSlots.every((slot) => selectedKeys.has(slotKey(slot)));
const toggleAll = () => {
setSelectedKeys(allSelected ? new Set() : new Set(filteredSlots.map(slotKey)));
};
const selectRow = (slot: SlotDTO) => {
const id = `slot:${slotKey(slot)}`;
setActiveSlotId(id);
focusOn({ lat: slot.latitude, lon: slot.longitude });
};
// Выбор заявки из инспектора — закрываем инспектор, переходим в режим расчёта слотов.
const chooseRequest = useCallback((requestId: string) => {
setSelectedRequestId(requestId);
setMapHits([]);
setActiveHitId(undefined);
}, []);
// Сброс окна к состоянию «заявка не выбрана, слоты не показаны».
const resetSelection = useCallback(() => {
setSelectedRequestId("");
setMapHits([]);
setActiveHitId(undefined);
setActiveSlotId(undefined);
}, []);
// Сцена карты. До выбора заявки — все заявки интервала (для выбора кликом). После выбора —
// контур заявки (слой requests), слоты-кандидаты (swath) и брони (complexPlan).
const scene = useMemo<Tgu2DMapScene>(() => {
const polygons: MapPolygon[] = [];
let z = 0;
if (!hasRequest) {
for (const item of requestItems) {
if (!item.geometry) continue;
for (const ring of parseWktPolygon(item.geometry)) {
if (ring.length < 3) continue;
polygons.push({
id: `request:${item.id}`,
name: item.name,
kind: "request",
layer: "requests",
points: ring,
requestId: item.id,
zIndex: z++
});
}
}
return { polygons, lines: [], markers: [] };
}
if (selectedRequest?.geometry) {
for (const ring of parseWktPolygon(selectedRequest.geometry)) {
if (ring.length < 3) continue;
polygons.push({
id: `request:${selectedRequest.id}`,
name: selectedRequest.name,
kind: "request",
layer: "requests",
points: ring,
requestId: selectedRequest.id,
// Контур заявки рисуем, но из выбора исключаем — иначе он «съедает» меню выбора слотов
// (как единственный requests-hit под точкой) и любой клик внутри выбирал бы заявку.
pickable: false,
zIndex: z++
});
}
}
for (const slot of filteredSlots) {
const key = slotKey(slot);
for (const ring of parseWktPolygon(slot.contour)) {
if (ring.length < 3) continue;
polygons.push({
id: `slot:${key}`,
name: `КА ${slot.satelliteId} · слот ${slot.slotNumber}`,
kind: bookedKeys.has(key) ? "complexPlan" : "swath",
layer: bookedKeys.has(key) ? "complexPlan" : "swath",
points: ring,
spacecraftId: String(slot.satelliteId),
revolution: slot.revolution,
zIndex: z++
});
}
}
// Брони, которых нет в текущем расчёте (напр. до нажатия «Рассчитать»), — показываем отдельно.
const slotKeysShown = new Set(slots.map(slotKey));
for (const slot of bookedSlots) {
const key = slotKey(slot);
if (slotKeysShown.has(key)) continue;
for (const ring of parseWktPolygon(slot.contour)) {
if (ring.length < 3) continue;
polygons.push({
id: `booked:${key}`,
name: `КА ${slot.satelliteId} · слот ${slot.slotNumber} (бронь)`,
kind: "complexPlan",
layer: "complexPlan",
points: ring,
spacecraftId: String(slot.satelliteId),
revolution: slot.revolution,
zIndex: z++
});
}
}
return { polygons, lines: [], markers: [] };
}, [hasRequest, requestItems, selectedRequest, slots, filteredSlots, bookedSlots, bookedKeys]);
const layers = useMemo<Tgu2DMapLayersState>(
() => ({
tracks: false,
swath: true,
planWorks: false,
complexPlan: true,
heatmap: false,
stations: false,
spacecraftMarkers: false,
requests: true
}),
[]
);
// Клик по карте. С выбранной заявкой — подсветка слота (синхрон со строкой). Без заявки —
// открываем инспектор заявки (одиночный клик) для выбора.
const handleObjectSelect = useCallback(
(selection?: Tgu2DMapSelection) => {
if (hasRequest) {
// Одиночное попадание — подсветку строки ведём только по клику на слот; клик по пустому
// месту снимает подсветку. Меню выбора (наложение) при этом закрываем.
setSlotPicks([]);
const id = selection?.id;
if (!selection) setActiveSlotId(undefined);
else if (id && (id.startsWith("slot:") || id.startsWith("booked:"))) setActiveSlotId(id);
return;
}
if (selection && selection.type === "polygon" && selection.layer === "requests") {
setMapHits([selection]);
setActiveHitId(selection.id);
} else {
setMapHits([]);
setActiveHitId(undefined);
}
},
[hasRequest]
);
// Наложение объектов под точкой. С выбранной заявкой — меню выбора слота (плотные наложения);
// без заявки — список заявок в инспекторе с раскрытием нужной.
const handleObjectsAtPoint = useCallback(
(selections: Tgu2DMapSelection[]) => {
if (hasRequest) {
const seen = new Set<string>();
const picks: SlotPick[] = [];
for (const sel of selections) {
if (sel.type !== "polygon") continue;
if (!sel.id.startsWith("slot:") && !sel.id.startsWith("booked:")) continue;
if (seen.has(sel.id)) continue;
const key = sel.id.slice(sel.id.indexOf(":") + 1);
const slot = slotByKey.get(key);
if (!slot) continue;
seen.add(sel.id);
picks.push({ id: sel.id, slot, booked: bookedKeys.has(key) });
}
setSlotPicks(picks);
return;
}
const requests = selections.filter((s) => s.type === "polygon" && s.layer === "requests");
const hits = requests.length > 0 ? requests : selections;
setMapHits(hits);
setActiveHitId(hits[0]?.id);
},
[hasRequest, slotByKey, bookedKeys]
);
// Выбор слота из меню наложения — подсвечиваем строку, но меню оставляем открытым
// (с пометкой активного пункта), чтобы можно было перебрать наложенные слоты; закрыть — крестиком.
const pickSlot = useCallback((id: string) => {
setActiveSlotId(id);
}, []);
const closeSlotPick = useCallback(() => setSlotPicks([]), []);
const toggleHit = useCallback((selection: Tgu2DMapSelection) => {
setActiveHitId((current) => (current === selection.id ? undefined : selection.id));
}, []);
const closeInspector = useCallback(() => {
setMapHits([]);
setActiveHitId(undefined);
}, []);
const canCalculate = hasRequest && !rangeInvalid && !calculating;
const overlayMessage = calculating
? "Расчёт слотов…"
: !hasRequest
? requestsLoading
? "Загрузка заявок…"
: undefined
: slots.length === 0 && bookedSlots.length === 0
? "Слоты не рассчитаны — нажмите «Рассчитать слоты»."
: undefined;
return (
<main className="tgu-map-workspace">
<div className="tgu-map-toolbar">
<div>
<div className="tgu-map-toolbar__title">Слоты</div>
<div className="tgu-map-toolbar__subtitle">Расчёт покрытия и бронирование слотов заявки</div>
</div>
<div className="tgu-field tgu-slots-request">
<span>Заявка</span>
<div className="tgu-slots-request__name" title={selectedRequest?.name}>
{selectedRequest ? selectedRequest.name : "—"}
</div>
</div>
<label className="tgu-field">
<span>С</span>
<DateTime24Field value={fromValue} onChange={setFromValue} />
</label>
<label className="tgu-field">
<span>По</span>
<DateTime24Field value={toValue} onChange={setToValue} />
</label>
<label className="tgu-field">
<span>Ветвь</span>
<select
className="tgu-select"
value={revSign}
onChange={(event) => setRevSign(event.target.value as "" | RevolutionSign)}
>
<option value="">обе</option>
<option value="ASC">восходящая</option>
<option value="DESC">нисходящая</option>
</select>
</label>
<label className="tgu-checkbox" title="Считать площадь покрытия (дольше)">
<input
type="checkbox"
checked={withCoverage}
onChange={(event) => setWithCoverage(event.target.checked)}
/>
<span>Покрытие</span>
</label>
<button className="tgu-button tgu-button--primary" type="button" onClick={calculate} disabled={!canCalculate}>
{calculating ? "Расчёт…" : "Рассчитать слоты"}
</button>
<button
className="tgu-button"
type="button"
onClick={book}
disabled={booking || selectedKeys.size === 0}
>
Забронировать{selectedKeys.size > 0 ? ` (${selectedKeys.size})` : ""}
</button>
<button
className="tgu-button"
type="button"
onClick={cancelBooking}
disabled={booking || bookedSlots.length === 0}
>
Снять бронь{bookedSlots.length > 0 ? ` (${bookedSlots.length})` : ""}
</button>
<div className="tgu-map-toolbar__spacer" style={{ flex: 1 }} />
{(slots.length > 0 || bookedSlots.length > 0) && (
<div className="tgu-map-toolbar__subtitle">
слотов {slots.length} · забронировано {bookedSlots.length}
</div>
)}
</div>
<div className="tgu-slots-body">
<section className="tgu-slots-list" ref={listRef}>
{hasRequest ? (
filteredSlots.length === 0 ? (
<div className="tgu-slots-empty">
{slots.length > 0 ? "На выбранные сутки слотов нет." : "Слоты не рассчитаны."}
</div>
) : (
<table className="tgu-slots-table">
<thead>
<tr>
<th>
<input type="checkbox" checked={allSelected} onChange={toggleAll} aria-label="Выбрать все" />
</th>
<th>КА</th>
<th>Виток</th>
<th>Слот / цикл</th>
<th>Начало</th>
<th>Конец</th>
<th>Крен</th>
<th>Статус</th>
</tr>
</thead>
<tbody>
{filteredSlots.map((slot) => {
const key = slotKey(slot);
const id = `slot:${key}`;
const booked = bookedKeys.has(key);
const isActive = activeSlotId === id;
return (
<tr
key={key}
data-slot-id={id}
className={isActive ? "is-active" : undefined}
onClick={() => selectRow(slot)}
>
<td onClick={(event) => event.stopPropagation()}>
<input
type="checkbox"
checked={selectedKeys.has(key)}
onChange={() => toggleSlot(key)}
aria-label="Выбрать слот"
/>
</td>
<td>{slot.satelliteId}</td>
<td>
{slot.revolution} / {slot.revolutionSign === "DESC" ? "нисх" : "восх"}
</td>
<td>
{slot.slotNumber} / {slot.cycle}
</td>
<td>{formatLocalDateTime(parseLocal(slot.tn))}</td>
<td>{formatLocalDateTime(parseLocal(slot.tk))}</td>
<td>{slot.roll.toFixed(1)}°</td>
<td>
{booked ? (
<span className="tgu-slots-badge tgu-slots-badge--booked">бронь</span>
) : (
<span className="tgu-slots-badge">{slotStateLabel(slot.state)}</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
)
) : (
<div className="tgu-slots-requests-panel">
<div className="tgu-map-panel__section">
<div className="tgu-map-panel__label">Заявки на интервал</div>
<div className="tgu-map-requests-filter">
<label className="tgu-field">
<span>С</span>
<DateTime24Field value={reqFromValue} onChange={setReqFromValue} />
</label>
<label className="tgu-field">
<span>По</span>
<DateTime24Field value={reqToValue} onChange={setReqToValue} />
</label>
<div className="tgu-map-requests-filter__actions">
<button
className="tgu-button tgu-button--primary tgu-button--sm"
type="button"
onClick={applyRequests}
disabled={requestsLoading || reqRangeInvalid}
>
{requestsLoading ? "Загрузка..." : "Загрузить"}
</button>
<button className="tgu-button tgu-button--sm" type="button" onClick={quickToday} disabled={requestsLoading}>
Сегодня
</button>
<button className="tgu-button tgu-button--sm" type="button" onClick={quick7days} disabled={requestsLoading}>
7 дней
</button>
<button className="tgu-button tgu-button--sm" type="button" onClick={quick30days} disabled={requestsLoading}>
30 дней
</button>
</div>
{requestsError && (
<div className="tgu-alert tgu-alert--danger tgu-alert--compact">{requestsError}</div>
)}
{requestsLoaded && !requestsError && (
<div className="tgu-map-requests-filter__status">
{requestItems.length === 0
? "Заявок не найдено."
: `Загружено: ${requestItems.length} из ${requestsTotal}`}
{requestsTruncated && (
<span className="tgu-map-requests-filter__truncated"> (показаны первые 5000 уточните интервал)</span>
)}
</div>
)}
{requestItems.length > 0 && (
<div className="tgu-map-requests-legend">
<span><i className="req-optics" />OPTICS</span>
<span><i className="req-rsa" />RSA</span>
<span><i className="req-combined" />COMBINED</span>
</div>
)}
</div>
</div>
<div className="tgu-slots-empty">Кликните заявку на карте, чтобы выбрать.</div>
</div>
)}
</section>
<section className="tgu-map-stage">
{overlayMessage && <div className="tgu-map-overlay">{overlayMessage}</div>}
{hasRequest && (
<button type="button" className="tgu-button tgu-slots-reset" onClick={resetSelection}>
Сброс
</button>
)}
{hasRequest && days.length > 0 && (
<SlotDaySelector
days={days}
fromIndex={effDayFrom}
toIndex={effDayTo}
onFromChange={setDayFrom}
onToChange={setDayTo}
/>
)}
<Tgu2DMapView
scene={scene}
layers={layers}
redrawToken={hasRequest ? filteredSlots.length + bookedSlots.length : requestItems.length}
enableObjectChooser
onObjectsAtPoint={handleObjectsAtPoint}
onObjectSelect={handleObjectSelect}
selectedObjectId={hasRequest ? activeSlotId : activeHitId}
focusPoint={focusPoint}
focusToken={focusToken}
/>
{!hasRequest && mapHits.length > 0 && (
<aside className="tgu-slots-inspector">
<MapObjectInspector
hits={mapHits}
activeId={activeHitId}
onToggle={toggleHit}
onClose={closeInspector}
requestItems={requestItems}
onSelectRequest={chooseRequest}
/>
</aside>
)}
{hasRequest && slotPicks.length > 0 && (
<SlotPickMenu picks={slotPicks} activeId={activeSlotId} onPick={pickSlot} onClose={closeSlotPick} />
)}
</section>
</div>
<div className="tgu-map-errors">
{notice && <div className="tgu-alert">{notice}</div>}
{error && <div className="tgu-alert tgu-alert--danger">{error}</div>}
</div>
</main>
);
}
function slotStateLabel(state: string): string {
switch (state) {
case "AVAILABLE":
return "свободен";
case "BOOKED":
return "бронь";
case "OCCUPIED":
return "занят";
default:
return state || "—";
}
}