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