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,115 @@
import type {
BookedSlotDTO,
RequestSlotsParams,
SlotBookingRequest,
SlotDTO,
} from '../model/slotTypes'
import { apiFetch } from './apiFetch'
// slots-service через gateway: /api/pcp-slots/** → StripPrefix=2 → slots-service /api/slots/**
const BASE = '/api/pcp-slots/api/slots'
async function readErrorMessage(response: Response, fallback: string): Promise<string> {
try {
const text = await response.text()
if (!text) return `${fallback}: ${response.status}`
try {
const body = JSON.parse(text) as { error?: string; message?: string }
return body.error || body.message || text
} catch {
return text
}
} catch {
return `${fallback}: ${response.status}`
}
}
/**
* Рассчитывает слоты покрытия заявки (slots-service: GET /api/slots/request-cover).
* @param params Заявка, интервал и фильтры (ветвь, признак покрытия, КА, угол Солнца).
*
* @returns Список слотов-кандидатов с контурами для карты.
*/
export async function fetchRequestSlots(params: RequestSlotsParams): Promise<SlotDTO[]> {
const query = new URLSearchParams()
query.set('requestId', params.requestId)
query.set('timeStart', params.timeStart)
query.set('timeStop', params.timeStop)
if (params.revSign) query.set('revSign', params.revSign)
if (params.cov != null) query.set('cov', String(params.cov))
if (params.sun != null) query.set('sun', String(params.sun))
params.satellites?.forEach((id) => query.append('satellites', String(id)))
const response = await apiFetch(`${BASE}/request-cover?${query}`, {
headers: { Accept: 'application/json' },
})
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Не удалось рассчитать слоты'))
}
return response.json() as Promise<SlotDTO[]>
}
/**
* Бронирует выбранные слоты под заявку (slots-service: POST /api/slots/booking/request).
* @param requestId Идентификатор заявки.
* @param slots Список слотов (КА, номер слота, цикл).
*
* @returns Забронированные слоты.
*/
export async function bookRequestSlots(
requestId: string,
slots: SlotBookingRequest[],
): Promise<BookedSlotDTO[]> {
const response = await apiFetch(`${BASE}/booking/request`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ requestId, slots }),
})
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Не удалось забронировать слоты'))
}
return response.json() as Promise<BookedSlotDTO[]>
}
/**
* Снимает все брони заявки (slots-service: DELETE /api/slots/booking/request).
* @param requestId Идентификатор заявки.
*
* @returns Сколько броней снято.
*/
export async function cancelRequestBooking(requestId: string): Promise<number> {
const response = await apiFetch(
`${BASE}/booking/request?requestId=${encodeURIComponent(requestId)}`,
{ method: 'DELETE', headers: { Accept: 'application/json' } },
)
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Не удалось снять бронь'))
}
const value = Number(await response.text())
return Number.isFinite(value) ? value : 0
}
/**
* Загружает уже забронированные слоты заявки (slots-service: GET /api/slots/booking/by-request/{id}).
* @param requestId Идентификатор заявки.
*
* @returns Список забронированных слотов с контурами.
*/
export async function fetchBookedSlotsByRequest(requestId: string): Promise<SlotDTO[]> {
const response = await apiFetch(
`${BASE}/booking/by-request/${encodeURIComponent(requestId)}`,
{ headers: { Accept: 'application/json' } },
)
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Не удалось загрузить брони заявки'))
}
return response.json() as Promise<SlotDTO[]>
}
@@ -672,6 +672,17 @@ export function MapInfoPanel({ selection, requestItems, onClose }: MapInfoPanelP
)}
</section>
)}
{selection.type === "polygon" && selection.layer === "swath" && (
<section className="tgu-editor-panel__section">
<div className="tgu-editor-eyebrow">{selection.name}</div>
<dl className="tgu-map-request-details__grid">
{selection.spacecraftId && (<><dt>КА</dt><dd className="mono">{selection.spacecraftId}</dd></>)}
<dt>Виток</dt>
<dd className="mono">{selection.revolution ?? "—"}</dd>
</dl>
</section>
)}
</div>
);
}
@@ -755,7 +755,10 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
planEnd: toLocalIso(selectedPlan.endMs)
});
setSurveyRoute(savedRoute);
void refreshDraftModes(draftId);
// Ждём перечитывания planModes ДО снятия editingWorkId ниже: иначе на миг editingModeId
// перестаёт исключать старый режим из сцены, а свежие planModes ещё не пришли — и старый
// контур мелькает на карте, пока не сделаешь что-нибудь ещё.
await refreshDraftModes(draftId);
} catch (error) {
setRouteError(error instanceof Error ? error.message : "Не удалось сохранить маршрут съёмки.");
return;
@@ -794,6 +797,9 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
setTarget(undefined);
setTargetPickActive(false);
setSelectedShootPass(undefined);
// Гасим превью окна построения сразу: сохранённый маршрут уже виден из planModes/черновика.
setSurveyRoute(undefined);
setRouteWindow(undefined);
};
const stationNameById = useCallback(
@@ -852,8 +858,9 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
});
savedDropId = savedDrop.id;
savedDropVolumeMb = savedDrop.volumeMb;
// Перечитываем включения DRAFT (источник истины) и ревалидируем.
void refreshDraftModes(draftId);
// Перечитываем включения DRAFT (источник истины) и ревалидируем. Ждём ДО снятия
// editingWorkId ниже — иначе старая трасса сброса мелькает, пока planModes не обновились.
await refreshDraftModes(draftId);
} catch (error) {
setDropError(error instanceof Error ? error.message : "Не удалось сохранить сброс.");
return;
@@ -1630,6 +1637,8 @@ function buildEditorMapScene(
kind: "swath",
layer: "swath",
points: [...leftEdge, ...rightEdge],
spacecraftId,
revolution,
zIndex: zIndex++
});
}
@@ -27,6 +27,8 @@ type MapObjectInspectorProps = {
requestItems?: RequestMapItem[];
/** Ячейки карты приоритетов — для карточки ячейки тепловой карты (layer "heatmap"). */
priorityCells?: PriorityCell[];
/** Если задан — в карточке заявки появляется кнопка «Выбрать» (вкладка «Слоты»). */
onSelectRequest?: (requestId: string) => void;
};
export function MapObjectInspector({
@@ -37,6 +39,7 @@ export function MapObjectInspector({
modes = [],
requestItems = [],
priorityCells = [],
onSelectRequest,
}: MapObjectInspectorProps) {
const renderCard = (selection: Tgu2DMapSelection): ReactNode => {
if (selection.type === "marker") {
@@ -49,7 +52,7 @@ export function MapObjectInspector({
case "complexPlan":
return <ModeCard selection={selection} modes={modes} />;
case "requests":
return <RequestCard selection={selection} requestItems={requestItems} />;
return <RequestCard selection={selection} requestItems={requestItems} onSelectRequest={onSelectRequest} />;
case "heatmap":
return <CellCard selection={selection} priorityCells={priorityCells} />;
case "planWorks":
@@ -129,7 +132,15 @@ function ModeCard({ selection, modes }: { selection: PolygonSelection; modes: Co
);
}
function RequestCard({ selection, requestItems }: { selection: PolygonSelection; requestItems: RequestMapItem[] }) {
function RequestCard({
selection,
requestItems,
onSelectRequest,
}: {
selection: PolygonSelection;
requestItems: RequestMapItem[];
onSelectRequest?: (requestId: string) => void;
}) {
const item = selection.requestId ? requestItems.find((r) => r.id === selection.requestId) : undefined;
if (!item) return <div className="tgu-editor-panel__hint">{selection.name || selection.id}</div>;
return (
@@ -156,6 +167,17 @@ function RequestCard({ selection, requestItems }: { selection: PolygonSelection;
<dt>Приоритет передачи</dt>
<dd>{item.highPriorityTransmit ? "Да" : "Нет"}</dd>
</dl>
{onSelectRequest && (
<div className="tgu-editor-panel__actions">
<button
className="tgu-button tgu-button--primary"
type="button"
onClick={() => onSelectRequest(item.id)}
>
Выбрать
</button>
</div>
)}
</section>
);
}
@@ -112,6 +112,10 @@ export function Tgu2DMapView({
const [tooltip, setTooltip] = useState<TooltipState>();
// Гео-координаты под курсором — показываем по центру внизу карты.
const [cursorGeo, setCursorGeo] = useState<GeoPoint>();
// Линейка: включается кнопкой в правом нижнем углу; в активном режиме клик по карте
// добавляет вершину замера, а наложение рисует ломаную с накопленным расстоянием.
const [rulerActive, setRulerActive] = useState(false);
const [rulerPoints, setRulerPoints] = useState<GeoPoint[]>([]);
const selectedPolygonRef = useRef<MapPolygon | undefined>(undefined);
const selectedLineRef = useRef<MapLine | undefined>(undefined);
const activePolygons = useMemo(
@@ -256,6 +260,10 @@ export function Tgu2DMapView({
);
const onPointerDown = (event: React.PointerEvent) => {
// Нажатие на кнопки управления картой (зум, линейка, «Сброс») не должно запускать перетаскивание
// и захват указателя: setPointerCapture на контейнере иначе перехватывает жест, и кнопки внутри
// карты перестают «кликаться» (родная причина того, что +/−/□ и «Сброс» не срабатывали).
if ((event.target as HTMLElement).closest("button")) return;
const screenPoint = screenPointFromEvent(event);
hasDraggedRef.current = false;
dragRef.current = {
@@ -303,6 +311,14 @@ export function Tgu2DMapView({
wrapperRef.current?.releasePointerCapture(event.pointerId);
};
// Линейка: переключаем режим; при выключении замер очищаем.
const toggleRuler = () => {
setRulerActive((active) => {
if (active) setRulerPoints([]);
return !active;
});
};
// Применить выбор конкретного объекта (из меню выбора либо при единственном попадании):
// выставляем внутреннюю подсветку и сообщаем наверх через onObjectSelect.
const applyEditorHit = useCallback((hit: EditorHit) => {
@@ -313,16 +329,24 @@ export function Tgu2DMapView({
}, [onObjectSelect]);
// Клик в редакторе: собираем ВСЕ интерактивные объекты под точкой (заявки, маршруты съёмки,
// сбросы, станции). 0 → возможно полоса обзора под низом; 1 → выбираем сразу;
// сбросы, станции). 0 → смотрим на полосы обзора; 1 → выбираем сразу;
// ≥2 → отдаём список наверх (оператор выбирает нужный в правой панели).
const handleEditorClick = useCallback((screenPoint: ScreenPoint) => {
const geoPoint = projection.unproject(screenPoint);
const hits: EditorHit[] = [];
// Полосы обзора собираем отдельно: они почти всегда лежат под кликом и не должны
// превращать выбор заявки/маршрута в список. Их список показываем, только когда под точкой
// нет другого интерактивного объекта, а самих полос несколько (наложение соседних витков).
const swathHits: EditorHit[] = [];
const marker = hitTestMarkers(activeMarkers, screenPoint, projection);
if (marker) hits.push({ kind: "marker", marker, selection: markerToSelection(marker) });
for (const polygon of hitTestAllPolygons(polygonIndex, geoPoint)) {
if (polygon.layer === "swath") {
swathHits.push({ kind: "polygon", polygon, selection: polygonToSelection(polygon) });
continue;
}
if (
polygon.layer !== "requests" &&
polygon.layer !== "planWorks" &&
@@ -340,19 +364,38 @@ export function Tgu2DMapView({
}
if (hits.length >= 2 && onObjectsAtPoint) {
// Несколько объектов — внутреннюю подсветку снимаем (её ведёт правая панель через selectedObjectId).
// Несколько интерактивных объектов — показываем список. В него добавляем и полосы обзора,
// лежащие под точкой, чтобы их данные тоже были видны. Порядок: маршрут → заявка → полоса.
const combined = [...hits, ...swathHits].sort((a, b) => editorHitRank(a) - editorHitRank(b));
// Внутреннюю подсветку снимаем (её ведёт правая панель через selectedObjectId).
selectedPolygonRef.current = undefined;
selectedMarkerRef.current = undefined;
selectedLineRef.current = undefined;
onObjectsAtPoint(hits.map((hit) => hit.selection));
onObjectsAtPoint(combined.map((hit) => hit.selection));
return;
}
if (hits.length >= 1) {
if (hits.length === 1) {
// Единственный интерактивный объект — выбираем сразу (полосы обзора лежат под кликом почти
// всегда, поэтому ради одного объекта список не показываем — иначе любой клик стал бы списком).
applyEditorHit(hits[0]);
return;
}
// Ничего «интерактивного» — выбираем верхний прочий полигон (напр. полосу обзора) либо снимаем выбор.
// Другого интерактивного объекта под точкой нет — работаем с полосами обзора:
// ≥2 (наложение витков) → список справа; ровно одна → выбираем её.
if (swathHits.length >= 2 && onObjectsAtPoint) {
selectedPolygonRef.current = undefined;
selectedMarkerRef.current = undefined;
selectedLineRef.current = undefined;
onObjectsAtPoint(swathHits.map((hit) => hit.selection));
return;
}
if (swathHits.length === 1) {
applyEditorHit(swathHits[0]);
return;
}
// Совсем ничего интерактивного — выбираем верхний прочий полигон либо снимаем выбор.
const fallback = hitTestPolygons(polygonIndex, geoPoint);
selectedPolygonRef.current = fallback;
selectedMarkerRef.current = undefined;
@@ -363,6 +406,11 @@ export function Tgu2DMapView({
const onClick = (event: React.MouseEvent) => {
if (hasDraggedRef.current) return;
const screenPoint = screenPointFromEvent(event);
if (rulerActive) {
// Режим линейки: клик добавляет вершину замера, выбор объектов не трогаем.
setRulerPoints((points) => [...points, projection.unproject(screenPoint)]);
return;
}
if (drawMode) {
onDrawAddPoint?.(projection.unproject(screenPoint));
return;
@@ -449,8 +497,22 @@ export function Tgu2DMapView({
}));
};
const selectedPolygon = selectedPolygonRef.current;
const selectedMarker = selectedMarkerRef.current;
// Внутренние ref-выделения (клик по карте) могут указывать на объект, которого уже нет в сцене —
// например, контур съёмки, скрытый при входе в режим правки (sceneModes исключает редактируемый
// режим). SVG-оверлей рисуется на каждый рендер из ref, поэтому без сверки с живой сценой старая
// подсветка «висит» поверх карты, пока не кликнешь в другое место. Рисуем только то, что есть в сцене.
const selectedPolygon =
selectedPolygonRef.current && activePolygons.some((p) => p.id === selectedPolygonRef.current!.id)
? selectedPolygonRef.current
: undefined;
const selectedLine =
selectedLineRef.current && scene.lines.some((l) => l.id === selectedLineRef.current!.id)
? selectedLineRef.current
: undefined;
const selectedMarker =
selectedMarkerRef.current && activeMarkers.some((m) => m.id === selectedMarkerRef.current!.id)
? selectedMarkerRef.current
: undefined;
const externalSelectedPolygon = useMemo(() => {
const id = selectedRequestPolygonId ?? selectedObjectId;
return id ? activePolygons.find((p) => p.id === id) : undefined;
@@ -461,7 +523,7 @@ export function Tgu2DMapView({
[selectedObjectId, scene.lines]
);
// Линии под подсветкой: выбранная кликом на карте (внутренняя) и заданная извне (по selectedObjectId).
const overlayLines = uniqueLines([selectedLineRef.current, externalSelectedLine]);
const overlayLines = uniqueLines([selectedLine, externalSelectedLine]);
const externalHoveredPolygon = useMemo(
() => hoveredRequestPolygonId ? activePolygons.find((p) => p.id === hoveredRequestPolygonId) : undefined,
[hoveredRequestPolygonId, activePolygons]
@@ -469,9 +531,21 @@ export function Tgu2DMapView({
const overlayPolygons = uniquePolygons([externalSelectedPolygon, selectedPolygon, externalHoveredPolygon, hoverRef.current]);
const overlayMarkers = uniqueMarkers([selectedMarker, hoverMarkerRef.current]);
// Накопленное расстояние (км) от первой вершины линейки до каждой — по дуге большого круга.
const rulerCumKm = useMemo(() => {
const out: number[] = [];
let acc = 0;
rulerPoints.forEach((point, i) => {
if (i > 0) acc += haversineKm(rulerPoints[i - 1], point);
out.push(acc);
});
return out;
}, [rulerPoints]);
const rulerTotalKm = rulerCumKm.length > 0 ? rulerCumKm[rulerCumKm.length - 1] : 0;
return (
<div
className={`tgu-2d-map ${pickMode || drawMode ? "is-picking" : ""}`}
className={`tgu-2d-map ${pickMode || drawMode || rulerActive ? "is-picking" : ""}`}
ref={wrapperRef}
onClick={onClick}
onPointerDown={onPointerDown}
@@ -579,6 +653,28 @@ export function Tgu2DMapView({
</g>
);
})()}
{rulerPoints.length > 0 && (() => {
const pts = unwrapX(rulerPoints.map((point) => projection.project(point)), projection.worldSize);
return (
<g className="tgu-2d-map__ruler">
{pts.length >= 2 && (
<polyline
className="tgu-2d-map__ruler-line"
points={pts.map((p) => `${p.x},${p.y}`).join(" ")}
fill="none"
/>
)}
{pts.map((p, i) => (
<g key={i}>
<circle cx={p.x} cy={p.y} r={4} className="tgu-2d-map__ruler-vertex" />
<text x={p.x + 8} y={p.y - 8} className="tgu-2d-map__ruler-label">
{i === 0 ? "0" : formatDistanceKm(rulerCumKm[i])}
</text>
</g>
))}
</g>
);
})()}
</svg>
{tooltip && (
<div className="tgu-2d-map__tooltip" style={{ left: tooltip.x, top: tooltip.y }}>
@@ -588,15 +684,69 @@ export function Tgu2DMapView({
)}
{cursorGeo && <div className="tgu-2d-map__coords">{formatLatLon(cursorGeo)}</div>}
<div className="tgu-2d-map__attribution">© <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener noreferrer">OpenStreetMap</a> contributors · © <a href="https://carto.com/attributions" target="_blank" rel="noopener noreferrer">CARTO</a></div>
{rulerActive && (
<div className="tgu-2d-map__ruler-total">
<span className="tgu-2d-map__ruler-total-label">Линейка</span>
<span className="tgu-2d-map__ruler-total-value">
{rulerPoints.length < 2 ? "кликайте по карте" : formatDistanceKm(rulerTotalKm)}
</span>
{rulerPoints.length > 0 && (
<button
type="button"
className="tgu-2d-map__ruler-reset"
onClick={(event) => {
event.stopPropagation();
setRulerPoints([]);
}}
>
Сброс
</button>
)}
</div>
)}
<div className="tgu-2d-map__zoom">
<button type="button" onClick={() => setView((current) => ({ ...current, zoom: Math.min(8, current.zoom + 0.5) }))}>+</button>
<button type="button" onClick={() => setView((current) => ({ ...current, zoom: Math.max(0.8, current.zoom - 0.5) }))}>-</button>
<button type="button" onClick={() => setView(INITIAL_VIEW)}></button>
<button
type="button"
className={`tgu-2d-map__zoom-ruler${rulerActive ? " is-active" : ""}`}
title="Линейка — измерение расстояния по карте"
aria-pressed={rulerActive}
onClick={(event) => {
event.stopPropagation();
toggleRuler();
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<rect x="2.5" y="8.5" width="19" height="7" rx="1" />
<path d="M6.5 8.5v3M10 8.5v4M13.5 8.5v3M17 8.5v4" />
</svg>
</button>
<button type="button" onClick={(event) => { event.stopPropagation(); setView((current) => ({ ...current, zoom: Math.min(8, current.zoom + 0.5) })); }}>+</button>
<button type="button" onClick={(event) => { event.stopPropagation(); setView((current) => ({ ...current, zoom: Math.max(0.8, current.zoom - 0.5) })); }}>-</button>
<button type="button" title="Вернуть обзор" onClick={(event) => { event.stopPropagation(); setView(INITIAL_VIEW); }}></button>
</div>
</div>
);
}
/** Расстояние между двумя гео-точками по дуге большого круга (haversine), км. */
function haversineKm(a: GeoPoint, b: GeoPoint): number {
const R = 6371;
const toRad = Math.PI / 180;
const dLat = (b.lat - a.lat) * toRad;
const dLon = (b.lon - a.lon) * toRad;
const lat1 = a.lat * toRad;
const lat2 = b.lat * toRad;
const h = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2;
return 2 * R * Math.asin(Math.min(1, Math.sqrt(h)));
}
/** Человекочитаемое расстояние: метры до 1 км, иначе километры. */
function formatDistanceKm(km: number): string {
if (km < 1) return `${(km * 1000).toFixed(0)} м`;
if (km < 100) return `${km.toFixed(2)} км`;
return `${km.toFixed(1)} км`;
}
/** Подпись координат под курсором: широта/долгота с полушариями. */
function formatLatLon(point: GeoPoint): string {
const lat = point.lat;
@@ -695,6 +845,26 @@ type EditorHit =
| { kind: "polygon"; polygon: MapPolygon; selection: Tgu2DMapSelection }
| { kind: "line"; line: MapLine; selection: Tgu2DMapSelection };
// Порядок объектов в списке выбора под точкой: сначала маршрут съёмки, затем заявка,
// в конце — полоса обзора (как фон). Остальные объекты — между ними.
function editorHitRank(hit: EditorHit): number {
if (hit.kind === "marker") return 0; // станции / маркеры КА
if (hit.kind === "line") return 3; // сбросы
switch (hit.polygon.layer) {
case "planWorks": // маршрут съёмки
case "complexPlan":
return 1;
case "requests": // заявка на съёмку
return 2;
case "heatmap":
return 4;
case "swath": // полоса обзора — последней
return 5;
default:
return 6;
}
}
function markerToSelection(marker: MapMarker): Tgu2DMapSelection {
return {
type: "marker",
@@ -722,6 +892,7 @@ function polygonToSelection(polygon: MapPolygon): Tgu2DMapSelection {
surveyType: polygon.surveyType,
status: polygon.status,
modeId: polygon.modeId,
revolution: polygon.revolution,
importance: polygon.importance,
cellNum: polygon.cellNum
};
@@ -24,6 +24,7 @@ export function hitTestAllPolygons(index: PolygonSpatialIndex, point: GeoPoint):
const hits: MapPolygon[] = [];
for (const entry of index.entries) {
if (entry.polygon.pickable === false) continue; // контур отрисовывается, но из выбора исключён
const inBbox =
bboxContainsPoint(entry.bbox, point) ||
bboxContainsPoint(entry.bbox, { lon: point.lon + 360, lat: point.lat }) ||
@@ -46,6 +47,7 @@ export function hitTestPolygons(index: PolygonSpatialIndex, point: GeoPoint): Ma
let selected: MapPolygon | undefined;
for (const entry of index.entries) {
if (entry.polygon.pickable === false) continue; // контур отрисовывается, но из выбора исключён
// Check bbox allowing ±360° shifts so antimeridian-crossing polygons are not skipped.
const inBbox =
bboxContainsPoint(entry.bbox, point) ||
@@ -13,6 +13,8 @@ export type Tgu2DMapSelection =
status?: TguPlanStatus;
/** id режима плана (включения) для polygon-ов слоя planWorks — связь с работой таймлайна. */
modeId?: number;
/** Номер витка (layer "swath") — для карточки полосы обзора. */
revolution?: number;
/** Важность ячейки тепловой карты (layer "heatmap"). */
importance?: number;
/** Номер ячейки сетки заданий (layer "heatmap"). */
@@ -34,10 +34,14 @@ export type MapPolygon = {
surveyType?: string;
/** id режима плана (survey), к которому относится контур — для связи «включение ↔ объект на карте». */
modeId?: number;
/** Номер витка (layer "swath") — для карточки полосы обзора. */
revolution?: number;
/** Важность ячейки тепловой карты (layer "heatmap"): задаёт цвет заливки и показывается в инспекторе. */
importance?: number;
/** Номер ячейки сетки заданий (layer "heatmap") — ключ для поиска полной карточки ячейки. */
cellNum?: number;
/** false — контур рисуется, но исключён из hit-теста/выбора (напр. контур заявки на вкладке «Слоты»). */
pickable?: boolean;
zIndex: number;
};
@@ -14,6 +14,7 @@ import { ConstellationTab } from "../constellation/ConstellationTab";
import { TguEditorTab } from "../tgu-editor/TguEditorTab";
import { Tgu2DMapTab } from "../tgu-map-2d/Tgu2DMapTab";
import { TguRequestsTab } from "../tgu-requests/TguRequestsTab";
import { TguSlotsTab } from "../tgu-slots/TguSlotsTab";
import {
abandonDraft,
createDraft,
@@ -587,6 +588,12 @@ export function TguPlanningPage() {
</div>
)}
{visitedTabs.has("slots") && (
<div style={tabStyle("slots")}>
<TguSlotsTab range={appliedRange} />
</div>
)}
{visitedTabs.has("constellation") && (
<div style={tabStyle("constellation")}>
<ConstellationTab />
@@ -26,6 +26,11 @@ export function TguToolbar({
onApply,
onQuickRange
}: TguToolbarProps) {
// Глобальный диапазон «С … По» относится только к вкладке «Планы». Остальные вкладки имеют свой
// источник интервала: «Карта» и «Заявки» — фильтр дат в левой панели, «Комплексный план» — свой
// диапазон внутри вкладки, «Редактор» отражает выбранный на «Планах» план (его окно задаётся
// планом), «Группировка» диапазоном не пользуется. Поэтому панель показываем только на «Планах».
const showRange = activeTab === "timeline";
return (
<header className="tgu-toolbar">
<div className="tgu-toolbar__row">
@@ -83,6 +88,15 @@ export function TguToolbar({
>
Заявки
</button>
<button
className={activeTab === "slots" ? "is-active" : ""}
type="button"
role="tab"
aria-selected={activeTab === "slots"}
onClick={() => onTabChange("slots")}
>
Слоты
</button>
<button
className={activeTab === "constellation" ? "is-active" : ""}
type="button"
@@ -93,29 +107,33 @@ export function TguToolbar({
Группировка
</button>
</div>
<label className="tgu-field">
<span>С</span>
<DateTime24Field value={fromValue} onChange={onFromChange} />
</label>
<label className="tgu-field">
<span>По</span>
<DateTime24Field value={toValue} onChange={onToChange} />
</label>
<button className="tgu-button tgu-button--primary" type="button" onClick={onApply} disabled={loading}>
Показать
</button>
<button className="tgu-button" type="button" onClick={() => onQuickRange(24)} disabled={loading}>
24ч
</button>
<button className="tgu-button" type="button" onClick={() => onQuickRange(72)} disabled={loading}>
3 суток
</button>
<button className="tgu-button" type="button" onClick={() => onQuickRange(168)} disabled={loading}>
7 суток
</button>
</div>
{showRange && (
<div className="tgu-toolbar__row tgu-toolbar__row--range">
<label className="tgu-field">
<span>С</span>
<DateTime24Field value={fromValue} onChange={onFromChange} />
</label>
<label className="tgu-field">
<span>По</span>
<DateTime24Field value={toValue} onChange={onToChange} />
</label>
<button className="tgu-button tgu-button--primary" type="button" onClick={onApply} disabled={loading}>
Показать
</button>
<button className="tgu-button" type="button" onClick={() => onQuickRange(24)} disabled={loading}>
24ч
</button>
<button className="tgu-button" type="button" onClick={() => onQuickRange(72)} disabled={loading}>
3 суток
</button>
<button className="tgu-button" type="button" onClick={() => onQuickRange(168)} disabled={loading}>
7 суток
</button>
</div>
)}
{error && <div className="tgu-alert tgu-alert--danger">{error}</div>}
</header>
);
@@ -0,0 +1,115 @@
import { useRef } from "react";
/** Сутки расчёта слотов: индекс (1-based), начало суток (мс, локально), метка ДД.ММ и число слотов. */
export type SlotDay = { index: number; dayMs: number; label: string; count: number };
type SlotDaySelectorProps = {
days: SlotDay[];
fromIndex: number;
toIndex: number;
onFromChange: (idx: number) => void;
onToChange: (idx: number) => void;
};
/**
* Двуручейный фильтр отображения слотов по суткам — визуально тот же ползунок, что «Витки» на
* вкладке «Редактор» (классы map-pass-selector*). Накладывается на карту; сужает и таблицу, и сцену.
*/
export function SlotDaySelector({ days, fromIndex, toIndex, onFromChange, onToChange }: SlotDaySelectorProps) {
const trackRef = useRef<HTMLDivElement>(null);
const n = days.length;
if (n === 0) return null;
const fromDay = days[Math.max(0, Math.min(fromIndex - 1, n - 1))];
const toDay = days[Math.max(0, Math.min(toIndex - 1, n - 1))];
const isFullRange = fromIndex === 1 && toIndex === n;
const countInRange = days
.slice(fromIndex - 1, toIndex)
.reduce((sum, day) => sum + day.count, 0);
const pct = (idx: number): number => (n <= 1 ? 0 : ((idx - 1) / (n - 1)) * 100);
const fromPct = pct(fromIndex);
const toPct = pct(toIndex);
const indexAt = (clientX: number): number => {
const rect = trackRef.current?.getBoundingClientRect();
if (!rect || n <= 1) return 1;
return Math.round(Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)) * (n - 1)) + 1;
};
const makeHandlers = (isFrom: boolean) => ({
onPointerDown(e: React.PointerEvent<HTMLDivElement>) {
e.stopPropagation();
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
},
onPointerMove(e: React.PointerEvent<HTMLDivElement>) {
if (!(e.currentTarget as HTMLDivElement).hasPointerCapture(e.pointerId)) return;
const idx = indexAt(e.clientX);
if (isFrom) {
onFromChange(Math.max(1, Math.min(idx, toIndex)));
} else {
onToChange(Math.min(n, Math.max(idx, fromIndex)));
}
},
onPointerUp(e: React.PointerEvent<HTMLDivElement>) {
(e.currentTarget as HTMLDivElement).releasePointerCapture(e.pointerId);
},
});
const onTrackPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
const idx = indexAt(e.clientX);
if (Math.abs(idx - fromIndex) <= Math.abs(idx - toIndex)) {
onFromChange(Math.max(1, Math.min(idx, toIndex)));
} else {
onToChange(Math.min(n, Math.max(idx, fromIndex)));
}
};
return (
<div className="map-pass-selector">
<div className="map-pass-selector__header">
<span className="map-pass-selector__title">Сутки</span>
<span className="map-pass-selector__nums">
{fromDay.label === toDay.label ? fromDay.label : `${fromDay.label}${toDay.label}`}
</span>
<span className="map-pass-selector__times">слотов {countInRange}</span>
{!isFullRange && (
<button
type="button"
className="map-pass-selector__reset"
title="Все сутки"
onClick={() => { onFromChange(1); onToChange(n); }}
>
Все
</button>
)}
</div>
<div className="map-pass-selector__body" onPointerDown={onTrackPointerDown}>
<div className="map-pass-selector__track" ref={trackRef}>
<div
className="map-pass-selector__fill"
style={{ left: `${fromPct}%`, width: `${toPct - fromPct}%` }}
/>
{days.map((day) => (
<div
key={day.index}
className={`map-pass-selector__tick${day.index >= fromIndex && day.index <= toIndex ? " is-active" : ""}`}
style={{ left: `${pct(day.index)}%` }}
/>
))}
<div
className="map-pass-selector__thumb"
style={{ left: `${fromPct}%` }}
{...makeHandlers(true)}
/>
<div
className="map-pass-selector__thumb"
style={{ left: `${toPct}%` }}
{...makeHandlers(false)}
/>
</div>
</div>
</div>
);
}
@@ -0,0 +1,48 @@
import type { SlotDTO } from "../../model/slotTypes";
import { formatLocalDateTime, parseLocal } from "../../utils/localTime";
/** Один слот в меню выбора: id полигона на карте, сам слот и признак брони. */
export type SlotPick = { id: string; slot: SlotDTO; booked: boolean };
type SlotPickMenuProps = {
picks: SlotPick[];
activeId?: string;
onPick: (id: string) => void;
onClose: () => void;
};
/**
* Меню выбора слота при плотном наложении: клик по карте, под которым лежит несколько слотов,
* раскрывает список (КА · слот · виток/ветвь · время), выбор подсвечивает строку в таблице.
*/
export function SlotPickMenu({ picks, activeId, onPick, onClose }: SlotPickMenuProps) {
return (
<aside className="tgu-slots-pick">
<div className="tgu-slots-pick__head">
<span>Слоты под курсором · {picks.length}</span>
<button type="button" className="tgu-icon-button" onClick={onClose} title="Закрыть">
×
</button>
</div>
<div className="tgu-slots-pick__list">
{picks.map(({ id, slot, booked }) => (
<button
key={id}
type="button"
className={`tgu-slots-pick__item${id === activeId ? " is-active" : ""}`}
onClick={() => onPick(id)}
>
<span className="tgu-slots-pick__title">
КА {slot.satelliteId} · слот {slot.slotNumber}
{booked && <span className="tgu-slots-badge tgu-slots-badge--booked">бронь</span>}
</span>
<span className="tgu-slots-pick__meta">
виток {slot.revolution} / {slot.revolutionSign === "DESC" ? "нисх" : "восх"} ·{" "}
{formatLocalDateTime(parseLocal(slot.tn))}
</span>
</button>
))}
</div>
</aside>
);
}
@@ -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 || "—";
}
}
@@ -0,0 +1,55 @@
// Типы слотов покрытия и бронирования. Зеркалят DTO из pcp-types-lib
// (space.nstart.pcp.pcp_types_lib.dto.requests.slots), которые отдаёт slots-service.
/** Признак ветви витка (восходящая/нисходящая). */
export type RevolutionSign = "ASC" | "DESC";
/** Статус слота из расчёта покрытия. */
export type SlotStatus = "AVAILABLE" | "BOOKED" | "OCCUPIED" | string;
/** Слот покрытия заявки — кандидат на бронирование (slots-service: SlotDTO). */
export type SlotDTO = {
cycle: number;
satelliteId: number;
tn: string;
tk: string;
roll: number;
/** Контур полосы слота в WKT (POLYGON ...) — рисуем на 2D-карте. */
contour: string;
revolution: number;
revolutionSign: RevolutionSign;
slotNumber: number;
state: SlotStatus;
latitude: number;
longitude: number;
};
/** Забронированный слот (slots-service: BookedSlotDTO) — ответ на POST booking/request. */
export type BookedSlotDTO = {
bookedSlotId: number;
satelliteId: number;
slotNum: number;
cycle: number;
};
/** Идентификатор слота для бронирования (slots-service: SlotBookingRequestDTO). */
export type SlotBookingRequest = {
satelliteId: number;
slotNum: number;
cycle: number;
};
/** Параметры расчёта слотов покрытия заявки (GET /api/slots/request-cover). */
export type RequestSlotsParams = {
requestId: string;
timeStart: string;
timeStop: string;
/** Ветвь витка; не задано — обе ветви. */
revSign?: RevolutionSign;
/** true — считать ещё и площадь покрытия (дольше); false — только слоты-кандидаты. */
cov?: boolean;
/** Ограничение по КА; пусто — все доступные. */
satellites?: number[];
/** Угол Солнца, град (для оптики); не задано — без ограничения. */
sun?: number;
};
@@ -22,7 +22,14 @@ export type TimelineRange = {
toMs: number;
};
export type TguActiveTab = "timeline" | "map" | "complex" | "editor" | "requests" | "constellation";
export type TguActiveTab =
| "timeline"
| "map"
| "complex"
| "editor"
| "requests"
| "slots"
| "constellation";
export type TimelineRow = {
spacecraftId: string;
@@ -23,6 +23,14 @@
overflow-x: auto;
}
.tgu-toolbar__row--range {
min-height: 0;
padding-top: 0.55rem;
padding-bottom: 0.6rem;
gap: 0.5rem;
border-top: 1px solid var(--line);
}
.tgu-toolbar__brand {
display: flex;
align-items: center;
@@ -906,6 +914,215 @@
min-height: 0;
}
/* Вкладка «Слоты»: слева список слотов, справа карта. */
.tgu-slots-body {
display: grid;
grid-template-columns: minmax(22rem, 32rem) minmax(0, 1fr);
min-width: 0;
min-height: 0;
}
.tgu-slots-list {
min-width: 0;
min-height: 0;
overflow: auto;
border-right: 1px solid var(--line);
background: var(--bg-panel);
}
.tgu-slots-empty {
padding: 1rem;
color: var(--text-dim);
font-size: 0.82rem;
}
/* Имя выбранной заявки в тулбаре (на месте прежнего выпадающего списка). */
.tgu-slots-request {
align-items: center;
}
.tgu-slots-request__name {
min-height: 2rem;
display: flex;
align-items: center;
max-width: 18rem;
padding: 0.38rem 0.55rem;
border: 1px solid var(--line);
border-radius: 6px;
background: #101214;
color: var(--text);
font-size: 0.78rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Левая панель в режиме «заявка не выбрана»: фильтр интервала заявок + подсказка. */
.tgu-slots-requests-panel {
display: grid;
gap: 0.6rem;
padding: 0.9rem;
}
/* Кнопка «Сброс» — справа над картой (поверх неё). */
.tgu-slots-reset {
position: absolute;
top: 0.55rem;
right: 0.55rem;
z-index: 21;
background: rgba(23, 26, 28, 0.93);
backdrop-filter: blur(6px);
}
/* Меню выбора слота при плотном наложении — плавающий список справа над картой (под «Сбросом»). */
.tgu-slots-pick {
position: absolute;
top: 3rem;
right: 0.55rem;
z-index: 24;
width: 22rem;
max-width: calc(100% - 1.1rem);
max-height: calc(100% - 3.6rem);
display: flex;
flex-direction: column;
border: 1px solid var(--line);
border-radius: 8px;
background: rgba(23, 26, 28, 0.96);
backdrop-filter: blur(6px);
overflow: hidden;
}
.tgu-slots-pick__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.5rem 0.65rem;
border-bottom: 1px solid var(--line);
font-size: 0.78rem;
font-weight: 600;
color: var(--text);
}
.tgu-slots-pick__list {
overflow: auto;
min-height: 0;
}
.tgu-slots-pick__item {
display: grid;
gap: 0.15rem;
width: 100%;
padding: 0.45rem 0.65rem;
border: 0;
border-bottom: 1px solid var(--line);
background: transparent;
text-align: left;
cursor: pointer;
color: var(--text);
}
.tgu-slots-pick__item:hover {
background: rgba(104, 195, 189, 0.1);
}
.tgu-slots-pick__item.is-active {
background: rgba(104, 195, 189, 0.22);
box-shadow: inset 3px 0 0 var(--accent);
}
.tgu-slots-pick__title {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.78rem;
font-weight: 600;
}
.tgu-slots-pick__meta {
font-family: var(--mono);
font-size: 0.7rem;
color: var(--text-dim);
}
/* Инспектор заявки — плавающая панель справа над картой (до выбора заявки). */
.tgu-slots-inspector {
position: absolute;
top: 0.55rem;
right: 0.55rem;
z-index: 25;
width: 23rem;
max-width: calc(100% - 1.1rem);
max-height: calc(100% - 1.1rem);
overflow: auto;
padding: 0.9rem;
border: 1px solid var(--line);
border-radius: 8px;
background: rgba(23, 26, 28, 0.96);
backdrop-filter: blur(6px);
}
.tgu-slots-table {
width: 100%;
border-collapse: collapse;
font-size: 0.78rem;
}
.tgu-slots-table th,
.tgu-slots-table td {
padding: 0.35rem 0.5rem;
text-align: left;
white-space: nowrap;
border-bottom: 1px solid var(--line);
}
.tgu-slots-table thead th {
position: sticky;
top: 0;
z-index: 1;
background: var(--bg-panel);
color: var(--text-dim);
font-weight: 600;
}
.tgu-slots-table tbody tr {
cursor: pointer;
}
.tgu-slots-table tbody tr:hover {
background: rgba(104, 195, 189, 0.08);
}
.tgu-slots-table tbody tr.is-active {
background: rgba(104, 195, 189, 0.3);
box-shadow: inset 3px 0 0 var(--accent);
}
.tgu-slots-table tbody tr.is-active td {
color: var(--text);
font-weight: 600;
}
.tgu-slots-table tbody tr.is-active:hover {
background: rgba(104, 195, 189, 0.36);
}
.tgu-slots-badge {
display: inline-block;
padding: 0.1rem 0.45rem;
border-radius: 999px;
background: #101214;
border: 1px solid var(--line);
color: var(--text-muted);
font-size: 0.72rem;
}
.tgu-slots-badge--booked {
border-color: rgba(104, 195, 189, 0.58);
background: rgba(104, 195, 189, 0.16);
color: var(--accent-strong);
}
.tgu-map-stage {
position: relative;
min-width: 0;
@@ -1052,6 +1269,75 @@
background: var(--bg-panel);
color: var(--text);
font-weight: 700;
cursor: pointer;
}
.tgu-2d-map__zoom button:hover {
border-color: rgba(104, 195, 189, 0.5);
}
/* Кнопка-линейка: активный режим подсвечиваем как тумблер. */
.tgu-2d-map__zoom-ruler.is-active {
border-color: rgba(104, 195, 189, 0.58);
background: rgba(104, 195, 189, 0.18);
color: var(--accent-strong);
}
/* Замер линейки на оверлее: ломаная, вершины и подписи расстояния. */
.tgu-2d-map__ruler-line {
stroke: #f0ad2e;
stroke-width: 2;
stroke-dasharray: 6 4;
}
.tgu-2d-map__ruler-vertex {
fill: #f0ad2e;
stroke: #1a1305;
stroke-width: 1;
}
.tgu-2d-map__ruler-label {
fill: #ffe7b5;
font-size: 11px;
font-weight: 600;
paint-order: stroke;
stroke: rgba(10, 12, 14, 0.85);
stroke-width: 3;
}
/* Бейдж итоговой длины — в правом нижнем углу, над кнопками управления. */
.tgu-2d-map__ruler-total {
position: absolute;
right: 0.75rem;
bottom: 9rem;
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.3rem 0.55rem;
border: 1px solid rgba(240, 173, 46, 0.42);
border-radius: 7px;
background: rgba(17, 19, 21, 0.9);
color: #ffe7b5;
font-size: 0.78rem;
backdrop-filter: blur(6px);
}
.tgu-2d-map__ruler-total-label {
color: var(--text-dim);
}
.tgu-2d-map__ruler-total-value {
font-weight: 700;
}
.tgu-2d-map__ruler-reset {
border: 1px solid var(--line);
border-radius: 5px;
padding: 0.1rem 0.45rem;
background: #101214;
color: var(--text-muted);
font-size: 0.72rem;
cursor: pointer;
}
.tgu-map-overlay {