6689351ef0
- новая вкладка: заявки интервала на карте, выбор заявки кликом/инспектором, расчёт слотов покрытия, бронирование и снятие брони - выбор заявки без выпадающего списка (не масштабируется на ~10к): все заявки на карте, панель справа с кнопкой «Выбрать», имя выбранной заявки в тулбаре, кнопка «Сброс», фильтр заявок по интервалу - бронь отмечаем по ответу POST booking/request (by-request отдаёт слот в чужом cycle-фрейме — ключ не совпадал, бейдж «бронь» не появлялся) - подсветка и автоскролл активной строки по клику на слот (ручной scrollBy) - меню выбора при плотном наложении слотов (SlotPickMenu); контур заявки pickable:false, чтобы не «съедать» меню; панель не закрывается при выборе - фильтр отображения по суткам (SlotDaySelector) - gateway: маршрут slots (/api/pcp-slots/**) - карточка полосы обзора (swath): КА и виток в инспекторе
802 lines
35 KiB
TypeScript
802 lines
35 KiB
TypeScript
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 || "—";
|
||
}
|
||
}
|