feat(tgu-ui): #50 слой поддержки решений по слотам + выбор полосами

Поверх ручного бронирования — информационный слой (достаточность судит оператор).

M1 покрытие: coverage.ts (растровое Monte-Carlo % покрытия заявки, чистые функции
  + coverage.test.ts), CoveragePanel (живой % + бар), слой coverage на карте
  (drawCoverage, union контуров, клип по заявке).
M2 эргономика: сортировка списка по вкладу в покрытие (жадный маргинальный прирост),
  по крену; колонка «Вклад» (+N%).
M3 конфликты: бейдж держателя BUSY-слота (заявка + важность) из holders в SlotDTO.

Выбор полосами (TASK_slot-strip-selection it.1): slotStrips.ts (groupStrips —
  same-roll цепочки по-КА, ε=0.01 с якорным креном, тотальный порядок) +
  slotStrips.test.ts; toggleStripSelection в slotSelection.ts; чип «полоса ×N» в
  строке, рельс по StripId в сортировке «время», подсветка полосы при наведении.
  Физику выполнимости не дублируем на фронте — это backend validate (it.2).

Смежный баг: anchorIndexRef (якорь Shift-выбора) сбрасывается при смене видимого
набора (фильтры/окно суток), не только сортировки.
This commit is contained in:
Дмитрий Соловьев
2026-06-17 09:06:25 +03:00
parent 20d9eb9579
commit 1f44c1a5bb
15 changed files with 1473 additions and 65 deletions
@@ -85,26 +85,6 @@ export async function bookRequestSlots(
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
}
/**
* Снимает выбранные брони заявки по списку bookedSlotId (slots-service: DELETE
* /api/slots/booking/batch). Один запрос = один чанк; крупный набор чанкует вызывающий
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { drawBaseMap } from "./canvas/drawBaseMap";
import { drawComplexPlan } from "./canvas/drawComplexPlan";
import { drawCoverage } from "./canvas/drawCoverage";
import { drawHeatmap } from "./canvas/drawHeatmap";
import { drawPlanWorks } from "./canvas/drawPlanWorks";
import { drawRequests } from "./canvas/drawRequests";
@@ -213,6 +214,11 @@ export function Tgu2DMapView({
if (layers.complexPlan) {
drawComplexPlan(context, visiblePolygons.filter((polygon) => polygon.layer === "complexPlan"), projection, size);
}
// След покрытия (вкладка «Слоты») — отдельный пасс поверх полос слотов; кольца приходят
// через scene.coverageRings (тумблер на стороне TguSlotsTab очищает их, когда слой выключен).
if (scene.coverageRings && scene.coverageRings.length > 0) {
drawCoverage(context, scene.coverageRings, projection, size);
}
if (layers.planWorks) {
drawPlanWorks(context, visiblePolygons.filter((polygon) => polygon.layer === "planWorks"), projection, size);
}
@@ -225,7 +231,7 @@ export function Tgu2DMapView({
});
return () => window.cancelAnimationFrame(frameId);
}, [handleTileLoad, layers, projection, redrawToken, scene.lines, scene.markers, size, tileVersion, view, visiblePolygons]);
}, [handleTileLoad, layers, projection, redrawToken, scene.coverageRings, scene.lines, scene.markers, size, tileVersion, view, visiblePolygons]);
const screenPointFromEvent = useCallback((event: React.PointerEvent | React.MouseEvent): ScreenPoint => {
const bounds = wrapperRef.current?.getBoundingClientRect();
@@ -0,0 +1,82 @@
import type { MapProjection } from "../geometry/mapProjection";
import type { GeoPoint } from "../model/mapTypes";
// Слой следа покрытия (вкладка «Слоты»): заливка union контуров выбранных/забронированных слотов.
// Отдельный draw-пасс (D3) — НЕ MapPolygon, не в spatialIndex/hit-тесте. Цвет — violet flat .22
// без обводки (выбран на /plan-design-review: отличим от swath/complexPlan/requests + CVD).
//
// «Flat» (а не additive): все кольца кладём в ОДИН путь и заливаем одним ctx.fill() на проход —
// перекрытия не темнеют (это одна операция заливки при одной opacity). Чтобы перекрывающиеся кольца
// при nonzero-намотке не «вычитались» в дыры, нормализуем ориентацию колец к одному знаку площади.
type Pt = { x: number; y: number };
const FILL = "rgba(167, 139, 219, 0.22)";
function unwrapX(points: Pt[], worldSize: number): Pt[] {
if (points.length === 0) return [];
const out: Pt[] = [points[0]!];
let prevX = points[0]!.x;
for (let i = 1; i < points.length; i++) {
let x = points[i]!.x;
while (x - prevX > worldSize / 2) x -= worldSize;
while (prevX - x > worldSize / 2) x += worldSize;
out.push({ x, y: points[i]!.y });
prevX = x;
}
return out;
}
function signedArea(points: Pt[]): number {
let area = 0;
for (let i = 0, j = points.length - 1; i < points.length; j = i++) {
area += (points[j]!.x - points[i]!.x) * (points[j]!.y + points[i]!.y);
}
return area;
}
function inView(points: Pt[], dx: number, vw: number, vh: number): boolean {
return points.some((p) => {
const x = p.x + dx;
return x >= -64 && x <= vw + 64 && p.y >= -64 && p.y <= vh + 64;
});
}
export function drawCoverage(
ctx: CanvasRenderingContext2D,
rings: GeoPoint[][],
projection: MapProjection,
viewport: { width: number; height: number }
) {
if (rings.length === 0) return;
// Готовим экранные кольца с единой ориентацией (CW), чтобы nonzero-заливка объединяла, а не вычитала.
const screenRings: Pt[][] = [];
for (const ring of rings) {
if (ring.length < 3) continue;
const raw = ring.map((p) => projection.project(p));
const pts = unwrapX(raw, projection.worldSize);
if (signedArea(pts) < 0) pts.reverse();
screenRings.push(pts);
}
if (screenRings.length === 0) return;
ctx.save();
ctx.fillStyle = FILL;
// Один путь со всеми кольцами как подпути + одна заливка на сдвиг мира → плоская opacity.
for (const dx of [0, projection.worldSize, -projection.worldSize]) {
let any = false;
ctx.beginPath();
for (const pts of screenRings) {
if (!inView(pts, dx, viewport.width, viewport.height)) continue;
any = true;
for (let i = 0; i < pts.length; i++) {
if (i === 0) ctx.moveTo(pts[i]!.x + dx, pts[i]!.y);
else ctx.lineTo(pts[i]!.x + dx, pts[i]!.y);
}
ctx.closePath();
}
if (any) ctx.fill("nonzero");
}
ctx.restore();
}
@@ -76,5 +76,11 @@ export type Tgu2DMapScene = {
polygons: MapPolygon[];
lines: MapLine[];
markers: MapMarker[];
/**
* Кольца следа покрытия (вкладка «Слоты») — рисуются отдельным пассом drawCoverage заливкой,
* НЕ как MapPolygon: вне polygons/spatialIndex, поэтому не участвуют в hit-тесте/z-порядке.
* Пусто/не задано — слой не рисуется (тумблер выключен либо нечего показывать).
*/
coverageRings?: GeoPoint[][];
};
@@ -0,0 +1,73 @@
import type { CoverageResult } from "./coverage";
type CoveragePanelProps = {
/** Покрытие только забронированным набором. null — покрытие недоступно (нет геометрии/0 сэмплов). */
booked: CoverageResult | null;
/** Покрытие union(забронированное ∪ выбранное галочками). */
withSelection: CoverageResult | null;
/** Есть ли выбор сверх брони (показывать второй сегмент «с выбором»). */
hasSelection: boolean;
/** Геометрия заявки пригодна для расчёта (false → «недоступно»). */
available: boolean;
/** Тумблер следа покрытия на карте (D4 — локальный, вкл. по умолчанию). */
coverageOn: boolean;
onToggleCoverage: (on: boolean) => void;
};
// Панель покрытия над списком слотов (D-D1): «забронировано NN% · с выбором MM%» + двухсегментный
// бар + явные состояния. Цвет = цвет слоя coverage на карте (violet) — единый язык «покрыто».
export function CoveragePanel({
booked,
withSelection,
hasSelection,
available,
coverageOn,
onToggleCoverage,
}: CoveragePanelProps) {
const bookedPct = booked?.percent ?? 0;
const withPct = withSelection?.percent ?? bookedPct;
// Прирост от выбора (второй сегмент) — поверх забронированного.
const gainPct = Math.max(0, withPct - bookedPct);
const lowPrecision = !!(booked?.lowPrecision || withSelection?.lowPrecision);
return (
<div className="tgu-coverage">
<div className="tgu-coverage__head">
<span className="tgu-coverage__title">Покрытие заявки</span>
<label className="tgu-coverage__toggle" title="Показывать след покрытия на карте">
<input
type="checkbox"
checked={coverageOn}
onChange={(event) => onToggleCoverage(event.target.checked)}
/>
След на карте
</label>
</div>
{!available ? (
<div className="tgu-coverage__unavailable">Покрытие недоступно у заявки нет геометрии.</div>
) : (
<>
<div className="tgu-coverage__bar" role="presentation">
<div className="tgu-coverage__seg tgu-coverage__seg--booked" style={{ width: `${bookedPct}%` }} />
{hasSelection && gainPct > 0 && (
<div className="tgu-coverage__seg tgu-coverage__seg--sel" style={{ width: `${gainPct}%` }} />
)}
</div>
<div className="tgu-coverage__legend">
<span className="tgu-coverage__booked">забронировано {bookedPct}%</span>
{hasSelection && (
<span className="tgu-coverage__sel"> · с выбором {withPct}%</span>
)}
{lowPrecision && (
<span className="tgu-coverage__low" title="Заявка слишком мала для надёжной оценки">
{" "}
· низкая точность
</span>
)}
</div>
</>
)}
</div>
);
}
@@ -1,6 +1,9 @@
import type { RevolutionSign } from "../../model/slotTypes";
import type { SlotStatusKey } from "./slotFilters";
/** Сортировка таблицы слотов: время (порядок расчёта) · вклад (ранжирование по покрытию) · крен. */
export type SlotSortMode = "time" | "gain" | "roll";
type SlotFilterBarProps = {
/** Счётчики статусов по окну суток (до статус-фильтра) — стабильны при переключении чипов. */
statusCounts: Record<SlotStatusKey, number>;
@@ -12,6 +15,11 @@ type SlotFilterBarProps = {
onToggleSat: (id: number) => void;
branchFilter: ReadonlySet<RevolutionSign>;
onToggleBranch: (branch: RevolutionSign) => void;
/** Текущая сортировка таблицы (одиночный выбор). */
sortMode: SlotSortMode;
onSortMode: (mode: SlotSortMode) => void;
/** Доступна ли сортировка «по вкладу» (есть геометрия заявки) — иначе чип заблокирован. */
gainAvailable: boolean;
/** Фильтры в дефолте (всё видно) — тогда «Сбросить» не показываем. */
isDefault: boolean;
onReset: () => void;
@@ -23,6 +31,12 @@ const STATUS_META: { key: SlotStatusKey; label: string; variant: string }[] = [
{ key: "occupied", label: "недоступен", variant: " is-occupied" },
];
const SORT_META: { key: SlotSortMode; label: string }[] = [
{ key: "time", label: "время" },
{ key: "gain", label: "вклад" },
{ key: "roll", label: "крен" },
];
/**
* Панель фильтров отображения слотов (видимость). Сужает И таблицу, И карту через единый
* filteredSlots. Стоит над строкой выбора: сначала сужаешь, потом отбираешь. Чипы —
@@ -38,6 +52,9 @@ export function SlotFilterBar({
onToggleSat,
branchFilter,
onToggleBranch,
sortMode,
onSortMode,
gainAvailable,
isDefault,
onReset,
}: SlotFilterBarProps) {
@@ -99,6 +116,27 @@ export function SlotFilterBar({
})}
</div>
<div className="tgu-slots-filterbar__group">
<span className="tgu-slots-filterbar__label">Сортировка</span>
{SORT_META.map(({ key, label }) => {
const active = sortMode === key;
const disabled = key === "gain" && !gainAvailable;
return (
<button
key={key}
type="button"
aria-pressed={active}
className={`tgu-slots-chip${active ? " is-active" : ""}`}
disabled={disabled}
title={disabled ? "Сортировка по вкладу недоступна — у заявки нет геометрии" : undefined}
onClick={() => onSortMode(key)}
>
{label}
</button>
);
})}
</div>
{!isDefault && (
<button type="button" className="tgu-button tgu-button--sm tgu-slots-filterbar__reset" onClick={onReset}>
Сбросить фильтры
@@ -26,9 +26,20 @@ import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView";
import { formatLocalDateTimeSeconds, parseLocal, toLocalInputValue, toLocalIso } from "../../utils/localTime";
import { SlotDaySelector, type SlotDay } from "./SlotDaySelector";
import { SlotPickMenu, type SlotPick } from "./SlotPickMenu";
import { SlotFilterBar } from "./SlotFilterBar";
import { SlotFilterBar, type SlotSortMode } from "./SlotFilterBar";
import { CoveragePanel } from "./CoveragePanel";
import {
buildCoverageGrid,
coverageOfMasks,
rankByMarginalGain,
sampleSlotMask,
unionMasks,
type RankCandidate,
type SlotMask
} from "./coverage";
import { applySlotFilters, slotStatusKey, type SlotStatusKey } from "./slotFilters";
import { selectionAfterCheck } from "./slotSelection";
import { selectionAfterCheck, toggleStripSelection } from "./slotSelection";
import { groupStrips } from "./slotStrips";
import { runChunked } from "./runChunked";
import { ChunkProgress } from "./ChunkProgress";
@@ -87,6 +98,13 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
const [slots, setSlots] = useState<SlotDTO[]>([]);
const [bookedSlots, setBookedSlots] = useState<BookedSlotView[]>([]);
const [selectedKeys, setSelectedKeys] = useState<ReadonlySet<string>>(() => new Set());
// Тумблер следа покрытия на карте (D4 — локальный state, вкл. по умолчанию; глобальный
// Tgu2DMapLayersState не трогаем). Оператор может снять след, оставив только % в панели.
const [coverageOn, setCoverageOn] = useState(true);
// Сортировка таблицы: «время» (порядок расчёта), «вклад» (жадное ранжирование по приросту
// покрытия, колонка +N%), «крен» (по модулю угла крена). Вклад доступен только при наличии
// геометрии заявки (иначе чип «вклад» заблокирован в SlotFilterBar).
const [sortMode, setSortMode] = useState<SlotSortMode>("time");
// Фильтр отображения по суткам: индексы (1-based) выбранного диапазона суток расчёта.
const [dayFrom, setDayFrom] = useState(1);
@@ -127,6 +145,9 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
// Подсветка строки/контура по клику на карте и центрирование на слоте.
const [activeSlotId, setActiveSlotId] = useState<string>();
// Полоса под курсором — для подсветки всех её членов по StripId (актуально в сортировках
// «крен»/«вклад», где члены полосы не идут подряд). null — курсор вне полос.
const [hoveredStrip, setHoveredStrip] = useState<string | null>(null);
const [focusPoint, setFocusPoint] = useState<GeoPoint>();
const focusTokenRef = useRef(0);
const [focusToken, setFocusToken] = useState(0);
@@ -274,14 +295,35 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
[dayWindowed, statusFilter, satFilter, branchFilter, bookedKeys],
);
// Виртуализация таблицы: в DOM держим только видимое окно строк (+overscan), а не все слоты —
// иначе на тысячах строк рендер встаёт. Скролл-контейнер — секция списка (listRef).
const rowVirtualizer = useVirtualizer({
count: filteredSlots.length,
getScrollElement: () => listRef.current,
estimateSize: () => ROW_HEIGHT,
overscan: 12
});
// Идентичность полос — над СТАБИЛЬНЫМ набором displaySlots (НЕ filteredSlots/sortedSlots:
// иначе фильтр-дырка ложно разорвала или склеила бы полосу). Map<slotKey, StripId>.
const stripIdByKey = useMemo(() => groupStrips(displaySlots, slotKey), [displaySlots]);
// По каждой видимой полосе: число видимых членов (N для чипа) и ключи видимых ВЫБИРАЕМЫХ
// (не-BUSY) членов — цель выбора. Считаем над filteredSlots: выбираем/считаем только видимое.
const stripInfo = useMemo(() => {
const visibleCount = new Map<string, number>();
const selectableKeys = new Map<string, string[]>();
for (const slot of filteredSlots) {
const key = slotKey(slot);
const strip = stripIdByKey.get(key);
if (!strip) continue;
visibleCount.set(strip, (visibleCount.get(strip) ?? 0) + 1);
if (!slotIsBusy(slot)) {
const arr = selectableKeys.get(strip) ?? [];
arr.push(key);
selectableKeys.set(strip, arr);
}
}
return { visibleCount, selectableKeys };
}, [filteredSlots, stripIdByKey]);
// Якорь Shift-выбора — индекс в видимом списке. Любая смена видимого набора (фильтры
// статус/КА/ветвь, окно суток, бронь) делает индекс невалидным → сбрасываем (как при смене
// сортировки). Иначе Shift-диапазон после фильтрации выбрал бы не те строки.
useEffect(() => {
anchorIndexRef.current = null;
}, [filteredSlots]);
// Загрузка заявок интервала (на карту); вызывается на входе, при смене диапазона и из фильтра.
const loadRequests = useCallback(async (fromInput: string, toInput: string) => {
@@ -312,16 +354,6 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
void loadRequests(fromInput, toInput);
}, [range.fromMs, range.toMs, loadRequests]);
// Клик по слоту на карте подсвечивает строку — центрируем её в видимой части таблицы.
// Скроллим контейнер вручную (по rect-ам), а не через scrollIntoView: тот может выбрать другой
// скролл-предок или счесть строку «достаточно близкой» и не сдвинуться.
useEffect(() => {
if (!activeSlotId || !activeSlotId.startsWith("slot:")) return;
const key = activeSlotId.slice("slot:".length);
const index = filteredSlots.findIndex((slot) => slotKey(slot) === key);
if (index >= 0) rowVirtualizer.scrollToIndex(index, { align: "center" });
}, [activeSlotId, filteredSlots, rowVirtualizer]);
const focusOn = useCallback((point?: GeoPoint) => {
if (!point) return;
setFocusPoint(point);
@@ -340,6 +372,150 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
return rings;
}, []);
// Сетка сэмплов покрытия по геометрии заявки (кэш на заявку, D1). null — заявка без геометрии
// или дегенерат (0 валидных сэмплов) → панель «недоступно».
const coverageGrid = useMemo(
() => (selectedRequest?.geometry ? buildCoverageGrid(parseWktPolygon(selectedRequest.geometry)) : null),
[selectedRequest?.geometry]
);
// Маска слота на сетке заявки, с кэшем по контуру (D2 — сигнатура grid+контур). Кэш живёт ровно
// пока стабильна сетка: новый coverageGrid (смена заявки/геометрии) пересоздаёт Map → чистка.
const maskFor = useMemo(() => {
const grid = coverageGrid;
const cache = new Map<string, SlotMask>();
return (key: string, contour: string): SlotMask | null => {
if (!grid) return null;
let mask = cache.get(contour);
if (!mask) {
mask = sampleSlotMask(grid, ringsFor(key, contour));
cache.set(contour, mask);
}
return mask;
};
}, [coverageGrid, ringsFor]);
// Кольца следа покрытия для карты = union контуров (забронированное ∪ выбранное галочками),
// независимо от фильтров вида (codex P1). Пусто, когда тумблер выключен или нет заявки.
const coverageRings = useMemo<GeoPoint[][] | undefined>(() => {
if (!coverageOn || !hasRequest) return undefined;
const rings: GeoPoint[][] = [];
const seen = new Set<string>();
const push = (key: string, contour: string) => {
if (seen.has(key)) return;
seen.add(key);
for (const ring of ringsFor(key, contour)) if (ring.length >= 3) rings.push(ring);
};
for (const slot of bookedSlots) push(slotKey(slot), slot.contour);
for (const key of selectedKeys) {
const slot = slotByKey.get(key);
if (slot) push(key, slot.contour);
}
return rings;
}, [coverageOn, hasRequest, bookedSlots, selectedKeys, slotByKey, ringsFor]);
// % покрытия для панели: забронированным набором и набором (забронированное ∪ выбранное).
// Семантика codex: % = покрытие union(booked selected), независимо от фильтров вида.
const coverageStats = useMemo(() => {
if (!coverageGrid) return { booked: null, withSelection: null, hasSelection: false };
const bookedMasks: SlotMask[] = [];
for (const slot of bookedSlots) {
const mask = maskFor(slotKey(slot), slot.contour);
if (mask) bookedMasks.push(mask);
}
const selMasks: SlotMask[] = [];
let hasSelection = false;
for (const key of selectedKeys) {
if (bookedKeys.has(key)) continue; // бронь уже учтена
const slot = slotByKey.get(key);
if (!slot) continue;
const mask = maskFor(key, slot.contour);
if (mask) {
selMasks.push(mask);
hasSelection = true;
}
}
return {
booked: coverageOfMasks(coverageGrid, bookedMasks),
withSelection: coverageOfMasks(coverageGrid, [...bookedMasks, ...selMasks]),
hasSelection,
};
}, [coverageGrid, maskFor, bookedSlots, selectedKeys, bookedKeys, slotByKey]);
// Покрытие доступно (есть геометрия заявки) — гейт сортировки «по вкладу» и колонки +N%.
const coverageAvailable = coverageGrid !== null;
// Ранжирование «по вкладу»: жадно (rankByMarginalGain) поверх забронированного union по ВИДИМЫМ
// незабронированным кандидатам. Вклад фиксируется на момент ранжирования (не live — отсюда подпись
// колонки). Считаем только в режиме сортировки «вклад» и при наличии сетки. key → gainPercent + ранг.
const gainRanking = useMemo(() => {
if (sortMode !== "gain" || !coverageGrid) return null;
const bookedMasks: SlotMask[] = [];
for (const slot of bookedSlots) {
const mask = maskFor(slotKey(slot), slot.contour);
if (mask) bookedMasks.push(mask);
}
const baseUnion = unionMasks(coverageGrid, bookedMasks);
const candidates: RankCandidate[] = [];
for (const slot of filteredSlots) {
const key = slotKey(slot);
if (bookedKeys.has(key)) continue; // бронь уже в baseUnion → её вклад 0
const mask = maskFor(key, slot.contour);
if (mask) candidates.push({ key, mask });
}
const ranked = rankByMarginalGain(coverageGrid, candidates, baseUnion);
const order = new Map<string, number>();
const pct = new Map<string, number>();
ranked.forEach((r, i) => {
order.set(r.key, i);
pct.set(r.key, r.gainPercent);
});
return { order, pct };
}, [sortMode, coverageGrid, maskFor, bookedSlots, filteredSlots, bookedKeys]);
// Колонку +N% показываем только в режиме «вклад» и при наличии геометрии.
const showGain = sortMode === "gain" && coverageAvailable;
// Порядок строк таблицы по выбранной сортировке. «время» — исходный (порядок расчёта, как есть).
// «крен» — по модулю крена (меньше = лучше геометрия), стабильно. «вклад» — жадный ранг; брони
// (вне ранжирования) уходят в конец, сохраняя исходный порядок. sort стабилен (ES2019+).
const sortedSlots = useMemo<SlotDTO[]>(() => {
if (sortMode === "roll") {
return [...filteredSlots].sort((a, b) => Math.abs(a.roll) - Math.abs(b.roll));
}
if (sortMode === "gain" && gainRanking) {
const order = gainRanking.order;
return [...filteredSlots].sort((a, b) => {
const ra = order.get(slotKey(a));
const rb = order.get(slotKey(b));
if (ra == null && rb == null) return 0;
if (ra == null) return 1;
if (rb == null) return -1;
return ra - rb;
});
}
return filteredSlots;
}, [sortMode, filteredSlots, gainRanking]);
// Виртуализация таблицы: в DOM держим только видимое окно строк (+overscan), а не все слоты —
// иначе на тысячах строк рендер встаёт. Скролл-контейнер — секция списка (listRef).
const rowVirtualizer = useVirtualizer({
count: sortedSlots.length,
getScrollElement: () => listRef.current,
estimateSize: () => ROW_HEIGHT,
overscan: 12
});
// Клик по слоту на карте подсвечивает строку — центрируем её в видимой части таблицы.
// Скроллим контейнер вручную (по rect-ам), а не через scrollIntoView: тот может выбрать другой
// скролл-предок или счесть строку «достаточно близкой» и не сдвинуться.
useEffect(() => {
if (!activeSlotId || !activeSlotId.startsWith("slot:")) return;
const key = activeSlotId.slice("slot:".length);
const index = sortedSlots.findIndex((slot) => slotKey(slot) === key);
if (index >= 0) rowVirtualizer.scrollToIndex(index, { align: "center" });
}, [activeSlotId, sortedSlots, rowVirtualizer]);
// Загружает брони выбранной заявки (для бейджей и слоя карты).
const loadBooked = useCallback(async (requestId: string) => {
try {
@@ -368,6 +544,7 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
// Смена заявки — фильтры видимости в дефолт (satFilter раскроется через satSignature-эффект).
setStatusFilter(new Set<SlotStatusKey>(["free", "booked", "occupied"]));
setBranchFilter(new Set<RevolutionSign>(["ASC", "DESC"]));
setSortMode("time");
if (selectedRequestId) void loadBooked(selectedRequestId);
}, [selectedRequestId, loadBooked]);
@@ -482,8 +659,8 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
// Источник истины — ответ POST: BookedSlotDTO несёт bookedSlotId и тот же cycle, что мы
// отправили (= cycle рассчитанного слота). Контур берём из рассчитанного слота по ключу;
// by-request не перечитываем. Так bookedSlots получает и контур, и bookedSlotId.
const idByKey = new Map(
collected.map((item) => [`${item.satelliteId}:${item.slotNum}:${item.cycle}`, item.bookedSlotId] as const),
const idByKey = new Map<string, number>(
collected.map((item) => [`${item.satelliteId}:${item.slotNum}:${item.cycle}`, item.bookedSlotId]),
);
const justBooked: BookedSlotView[] = slots
.filter((slot) => idByKey.has(slotKey(slot)))
@@ -530,11 +707,13 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
// (тот же бар, что у брони), затем убирает снятые из bookedSlots и из выбора. На него опираются
// и «Снять выбранные», и «Снять всё», и ✕ в строке — снятие везде пачками с прогрессом, а не
// одним bulk-запросом (по одному HTTP нельзя показать ход на 7000 слотов). setBusy — флаг кнопки.
const runCancel = async (ids: number[], setBusy: (value: boolean) => void) => {
const runCancel = async (ids: number[], setBusy: (value: boolean) => void, forceProgress = false) => {
setBusy(true);
setError(undefined);
setNotice(undefined);
setProgress(ids.length > BOOK_CHUNK ? { label: "Снятие брони", done: 0, total: ids.length } : null);
// forceProgress — для «Снять всё»: держим бар весь процесс независимо от объёма (операция
// делает доп. сверку by-request, поэтому заметно дольше точечного снятия даже на малом наборе).
setProgress(forceProgress || ids.length > BOOK_CHUNK ? { label: "Снятие брони", done: 0, total: ids.length } : null);
try {
const counts = await runChunked(
ids,
@@ -577,6 +756,12 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
// набора и серверного. Так «Снять всё» убирает вообще все брони заявки, даже если локальный
// bookedSlots разошёлся с базой (bookedSlotId надёжен независимо от cycle-фрейма брони).
const performCancelAll = async () => {
// Мгновенная обратная связь (как у «Забронировать»/«Снять выбранные»): входим в busy и
// показываем прогресс СРАЗУ, ещё до сетевой сверки by-request, а не после её round-trip.
setCancelling(true);
setError(undefined);
setNotice(undefined);
setProgress({ label: "Снятие брони", done: 0, total: bookedSlots.length });
const idSet = new Set(bookedSlots.map((slot) => slot.bookedSlotId));
try {
const fresh = await fetchBookedSlotsByRequest(selectedRequestId);
@@ -584,7 +769,7 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
} catch {
// by-request недоступен — снимаем по локальному набору броней.
}
await runCancel(Array.from(idSet), setCancelling);
await runCancel(Array.from(idSet), setCancelling, true);
};
const cancelAll = () => {
@@ -601,7 +786,7 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
// текущей строки; направление (выбрать/снять) задаётся новым состоянием текущей строки.
const handleSlotCheck = (index: number, shiftKey: boolean) => {
setSelectedKeys((current) =>
selectionAfterCheck(current, (i) => slotKey(filteredSlots[i]), index, shiftKey, anchorIndexRef.current)
selectionAfterCheck(current, (i) => slotKey(sortedSlots[i]), index, shiftKey, anchorIndexRef.current)
);
anchorIndexRef.current = index;
};
@@ -620,6 +805,15 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
});
anchorIndexRef.current = null;
};
// Чип «полоса ×N» — выбрать/снять всю полосу: зеркалит toggleAllVisible, но в пределах одной
// полосы (только видимые не-BUSY члены). Сбрасываем якорь Shift — позиции выбора изменились.
const toggleStrip = (stripId: string) => {
const memberKeys = stripInfo.selectableKeys.get(stripId);
if (!memberKeys || memberKeys.length === 0) return;
setSelectedKeys((current) => toggleStripSelection(current, memberKeys));
anchorIndexRef.current = null;
};
// Выбрать все слоты расчёта (по всем суткам, кроме занятых бронью), снять весь выбор.
const selectAllDays = () =>
setSelectedKeys(new Set(displaySlots.filter((slot) => !slotIsBusy(slot)).map(slotKey)));
@@ -635,6 +829,11 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
else next.add(value);
return next;
};
// Смена сортировки меняет позиции строк — якорь Shift-выбора больше не валиден, сбрасываем.
const changeSort = (mode: SlotSortMode) => {
setSortMode(mode);
anchorIndexRef.current = null;
};
const toggleStatus = (key: SlotStatusKey) => setStatusFilter((prev) => toggleInSet(prev, key));
const toggleSat = (id: number) => setSatFilter((prev) => toggleInSet(prev, id));
const toggleBranch = (branch: RevolutionSign) => setBranchFilter((prev) => toggleInSet(prev, branch));
@@ -761,7 +960,7 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
}
}
}
return { polygons, lines: [], markers: [] };
return { polygons, lines: [], markers: [], coverageRings };
}, [
hasRequest,
requestItems,
@@ -773,7 +972,8 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
ringsFor,
statusFilter,
satFilter,
branchFilter
branchFilter,
coverageRings
]);
const layers = useMemo<Tgu2DMapLayersState>(
@@ -873,6 +1073,9 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
? "Слоты не рассчитаны — нажмите «Показать все слоты» или «Подобрать покрытие»."
: undefined;
// Число колонок таблицы (для colSpan распорок виртуализатора) — +1 на колонку «Вклад».
const colCount = showGain ? 8 : 7;
// Видимое окно строк виртуализатора и распорки сверху/снизу (держат суммарную высоту списка).
const virtualRows = rowVirtualizer.getVirtualItems();
const virtualTotal = rowVirtualizer.getTotalSize();
@@ -983,10 +1186,23 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
onToggleSat={toggleSat}
branchFilter={branchFilter}
onToggleBranch={toggleBranch}
sortMode={sortMode}
onSortMode={changeSort}
gainAvailable={coverageAvailable}
isDefault={filtersDefault}
onReset={resetFilters}
/>
)}
{displaySlots.length > 0 && (
<CoveragePanel
booked={coverageStats.booked}
withSelection={coverageStats.withSelection}
hasSelection={coverageStats.hasSelection}
available={coverageGrid !== null}
coverageOn={coverageOn}
onToggleCoverage={setCoverageOn}
/>
)}
<div className="tgu-slots-selbar">
<span className="tgu-slots-selbar__count">
Выбрано: {selectedKeys.size}
@@ -1052,28 +1268,63 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
<th>Начало</th>
<th>Длит.</th>
<th>Крен</th>
{showGain && (
<th
className="tgu-slots-gain-col"
title="Прирост покрытия в порядке ранжирования — фиксируется на момент ранжирования, не пересчитывается при выборе"
>
Вклад
</th>
)}
<th>Статус</th>
</tr>
</thead>
<tbody>
{padTop > 0 && (
<tr aria-hidden="true" style={{ height: padTop }}>
<td colSpan={7} />
<td colSpan={colCount} />
</tr>
)}
{virtualRows.map((vrow) => {
const slot = filteredSlots[vrow.index];
const slot = sortedSlots[vrow.index];
const key = slotKey(slot);
const id = `slot:${key}`;
const booked = bookedKeys.has(key);
const isActive = activeSlotId === id;
// Полоса слота: N видимых членов, ключи видимых выбираемых членов.
const stripId = stripIdByKey.get(key);
const stripN = stripId ? stripInfo.visibleCount.get(stripId) ?? 0 : 0;
const stripSelectable = stripId
? stripInfo.selectableKeys.get(stripId) ?? []
: [];
const isStrip = stripN >= 2;
const showStripChip = isStrip && stripSelectable.length > 0;
const stripAllSelected =
showStripChip && stripSelectable.every((k) => selectedKeys.has(k));
// Рельс — только в сортировке «время» (там члены полосы идут подряд);
// в «крен»/«вклад» — подсветка по StripId при наведении.
const showRail = isStrip && sortMode === "time";
const rowClass = [
isActive ? "is-active" : "",
isStrip && hoveredStrip === stripId ? "is-strip-hover" : "",
showRail ? "tgu-slots-row--strip" : "",
]
.filter(Boolean)
.join(" ");
return (
<tr
key={key}
data-slot-id={id}
style={{ height: ROW_HEIGHT }}
className={isActive ? "is-active" : undefined}
style={{
height: ROW_HEIGHT,
...(showRail && stripId
? { ["--strip-hue" as string]: String(stripHue(stripId)) }
: {}),
}}
className={rowClass || undefined}
onClick={() => selectRow(slot)}
onMouseEnter={isStrip ? () => setHoveredStrip(stripId!) : undefined}
onMouseLeave={isStrip ? () => setHoveredStrip(null) : undefined}
>
<td onClick={(event) => event.stopPropagation()}>
<input
@@ -1086,7 +1337,33 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
aria-label="Выбрать слот"
/>
</td>
<td>{slot.satelliteId}</td>
<td>
<span className="tgu-slots-sat">
{slot.satelliteId}
{showStripChip && (
<button
type="button"
className={
stripAllSelected
? "tgu-slots-strip-chip is-on"
: "tgu-slots-strip-chip"
}
title={
stripAllSelected
? "Снять выбор полосы (слоты одного крена подряд)"
: "Выбрать полосу — слоты одного крена подряд"
}
aria-label="Выбрать полосу"
onClick={(event) => {
event.stopPropagation();
toggleStrip(stripId!);
}}
>
полоса ×{stripN}
</button>
)}
</span>
</td>
<td>
{slot.revolution} / {slot.revolutionSign === "DESC" ? "нисх" : "восх"} /{" "}
{slot.cycle}
@@ -1094,6 +1371,11 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
<td>{formatLocalDateTimeSeconds(parseLocal(slot.tn))}</td>
<td>{slotDurationSec(slot)} с</td>
<td>{slot.roll.toFixed(1)}°</td>
{showGain && (
<td className="tgu-slots-gain">
{booked ? "—" : `+${gainRanking?.pct.get(key) ?? 0}%`}
</td>
)}
<td>
{booked ? (
<span className="tgu-slots-status-cell">
@@ -1114,14 +1396,35 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
</button>
</span>
) : (
<span
className={
slot.state === "BUSY"
? "tgu-slots-badge tgu-slots-badge--occupied"
: "tgu-slots-badge"
}
>
{slotStateLabel(slot.state)}
<span className="tgu-slots-status-cell">
<span
className={
slot.state === "BUSY"
? "tgu-slots-badge tgu-slots-badge--occupied"
: "tgu-slots-badge"
}
>
{slotStateLabel(slot.state)}
</span>
{slot.state === "BUSY" &&
slot.holders?.map((holder) => (
<span
key={holder.requestId}
className="tgu-slots-holder"
title={`Слот держит заявка ${holder.name ?? holder.requestId}${
holder.importance != null
? ` · важность ${holder.importance.toFixed(2)}`
: ""
}`}
>
{holder.name ?? holder.requestId}
{holder.importance != null && (
<span className="tgu-slots-holder__imp">
{holder.importance.toFixed(2)}
</span>
)}
</span>
))}
</span>
)}
</td>
@@ -1130,7 +1433,7 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
})}
{padBottom > 0 && (
<tr aria-hidden="true" style={{ height: padBottom }}>
<td colSpan={7} />
<td colSpan={colCount} />
</tr>
)}
</tbody>
@@ -1254,7 +1557,11 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
<Tgu2DMapView
scene={scene}
layers={layers}
redrawToken={hasRequest ? filteredSlots.length + bookedSlots.length : requestItems.length}
redrawToken={
hasRequest
? filteredSlots.length + bookedSlots.length + selectedKeys.size + (coverageOn ? 1 : 0)
: requestItems.length
}
enableObjectChooser
onObjectsAtPoint={handleObjectsAtPoint}
onObjectSelect={handleObjectSelect}
@@ -1304,6 +1611,14 @@ function slotIsBusy(slot: Pick<SlotDTO, "state">): boolean {
return slot.state === "BUSY";
}
/** Детерминированный оттенок (0..359) полосы по её StripId — для приглушённого рельса слева,
* чтобы соседние полосы визуально различались. Чистый хэш строки, без состояния. */
function stripHue(stripId: string): number {
let h = 0;
for (let i = 0; i < stripId.length; i++) h = (h * 31 + stripId.charCodeAt(i)) >>> 0;
return h % 360;
}
function slotStateLabel(state: string): string {
switch (state) {
case "AVAILABLE":
@@ -0,0 +1,201 @@
import { describe, expect, it } from "vitest";
import { parseWktPolygon } from "../tgu-map-2d/geometry/wktParser";
import type { GeoPoint } from "../tgu-map-2d/model/mapTypes";
import {
COVERAGE_GRID_N,
COVERAGE_MIN_SAMPLES,
buildCoverageGrid,
coverageOf,
coverageOfMasks,
rankByMarginalGain,
sampleSlotMask,
unionMasks,
} from "./coverage";
/** Прямоугольное кольцо (замкнутое) — основной строительный блок фикстур. */
function rect(minLon: number, minLat: number, maxLon: number, maxLat: number): GeoPoint[] {
return [
{ lon: minLon, lat: minLat },
{ lon: maxLon, lat: minLat },
{ lon: maxLon, lat: maxLat },
{ lon: minLon, lat: maxLat },
{ lon: minLon, lat: minLat },
];
}
describe("buildCoverageGrid", () => {
it("квадрат-заявка → все центры ячеек внутри (sampleCount = N²)", () => {
const grid = buildCoverageGrid([rect(0, 0, 10, 10)]);
expect(grid).not.toBeNull();
expect(grid!.n).toBe(COVERAGE_GRID_N);
expect(grid!.sampleCount).toBe(COVERAGE_GRID_N * COVERAGE_GRID_N);
expect(grid!.requestWeight).toBeGreaterThan(0);
});
it("пустой набор колец → null", () => {
expect(buildCoverageGrid([])).toBeNull();
});
it("кольцо <3 точек → null (дегенерат)", () => {
expect(buildCoverageGrid([[{ lon: 0, lat: 0 }, { lon: 1, lat: 1 }]])).toBeNull();
});
it("нулевая площадь (коллинеарные точки) → 0 сэмплов → null", () => {
expect(
buildCoverageGrid([[{ lon: 0, lat: 0 }, { lon: 10, lat: 0 }, { lon: 5, lat: 0 }]]),
).toBeNull();
});
});
describe("sampleSlotMask + coverageOf", () => {
const grid = buildCoverageGrid([rect(0, 0, 10, 10)])!;
it("слот = заявка → ~100% (центр-сэмплинг не теряет краевые ячейки)", () => {
const mask = sampleSlotMask(grid, [rect(0, 0, 10, 10)]);
expect(coverageOf(grid, mask)!.percent).toBeGreaterThanOrEqual(99);
});
it("слот = левая половина → ~50%", () => {
const mask = sampleSlotMask(grid, [rect(0, 0, 5, 10)]);
const pct = coverageOf(grid, mask)!.percent;
expect(pct).toBeGreaterThanOrEqual(45);
expect(pct).toBeLessThanOrEqual(55);
});
it("слот вне bbox заявки (bbox-прунинг) → 0%", () => {
const mask = sampleSlotMask(grid, [rect(100, 100, 110, 110)]);
expect(coverageOf(grid, mask)!.percent).toBe(0);
expect(mask.every((v) => v === 0)).toBe(true);
});
it("битый контур (кольцо <3 точек) → пустая маска, слот не участвует", () => {
const mask = sampleSlotMask(grid, [[{ lon: 0, lat: 0 }, { lon: 5, lat: 5 }]]);
expect(mask.every((v) => v === 0)).toBe(true);
});
});
describe("coverageOfMasks (bookedselected)", () => {
const grid = buildCoverageGrid([rect(0, 0, 10, 10)])!;
it("пустой набор → 0%", () => {
expect(coverageOfMasks(grid, [])!.percent).toBe(0);
});
it("две одинаковые маски (OR de-dup) → ~100%, не 200%", () => {
const full = sampleSlotMask(grid, [rect(0, 0, 10, 10)]);
const res = coverageOfMasks(grid, [full, full])!;
expect(res.percent).toBeLessThanOrEqual(100);
expect(res.percent).toBeGreaterThanOrEqual(99);
});
it("левая ∪ правая половины → ~100%", () => {
const left = sampleSlotMask(grid, [rect(0, 0, 5, 10)]);
const right = sampleSlotMask(grid, [rect(5, 0, 10, 10)]);
expect(coverageOfMasks(grid, [left, right])!.percent).toBeGreaterThanOrEqual(99);
});
it("union частично перекрывающихся слотов не двойного счёта", () => {
const a = sampleSlotMask(grid, [rect(0, 0, 6, 10)]);
const b = sampleSlotMask(grid, [rect(4, 0, 10, 10)]);
const union = unionMasks(grid, [a, b]);
expect(coverageOf(grid, union)!.percent).toBeGreaterThanOrEqual(99);
});
});
describe("rankByMarginalGain (weighted)", () => {
const grid = buildCoverageGrid([rect(0, 0, 10, 10)])!;
const big = sampleSlotMask(grid, [rect(0, 0, 6, 10)]); // 60%
const small = sampleSlotMask(grid, [rect(0, 0, 4, 10)]); // 40%
it("жадный порядок: больший вклад — первым", () => {
const ranked = rankByMarginalGain(grid, [
{ key: "small", mask: small },
{ key: "big", mask: big },
]);
expect(ranked[0]!.key).toBe("big");
expect(ranked[0]!.gainPercent).toBeGreaterThan(ranked[1]!.gainPercent);
});
it("тай-брейк: равный вклад → исходный порядок", () => {
const left = sampleSlotMask(grid, [rect(0, 0, 5, 10)]);
const right = sampleSlotMask(grid, [rect(5, 0, 10, 10)]);
const ranked = rankByMarginalGain(grid, [
{ key: "left", mask: left },
{ key: "right", mask: right },
]);
expect(ranked.map((r) => r.key)).toEqual(["left", "right"]);
});
it("вклад 0: кандидат — подмножество уже покрытого → gainPercent 0 («—»)", () => {
const base = sampleSlotMask(grid, [rect(0, 0, 10, 10)]); // всё покрыто
const ranked = rankByMarginalGain(grid, [{ key: "redundant", mask: small }], base);
expect(ranked[0]!.gainPercent).toBe(0);
});
});
describe("антимеридиан (собственный unwrap-фрейм)", () => {
it("заявка и слот через 180° → корректные ~100%", () => {
// Кольцо пересекает антимеридиан: долготы 170 и -170 (=190 в unwrap-фрейме).
const ring: GeoPoint[] = [
{ lon: 170, lat: 0 },
{ lon: -170, lat: 0 },
{ lon: -170, lat: 10 },
{ lon: 170, lat: 10 },
{ lon: 170, lat: 0 },
];
const grid = buildCoverageGrid([ring]);
expect(grid).not.toBeNull();
expect(grid!.sampleCount).toBe(COVERAGE_GRID_N * COVERAGE_GRID_N);
const mask = sampleSlotMask(grid!, [ring]);
expect(coverageOf(grid!, mask)!.percent).toBeGreaterThanOrEqual(99);
});
});
describe("WKT holes — лимит MVP-парсера зафиксирован", () => {
it("полигон с дырой парсится только во внешнее кольцо (1 кольцо)", () => {
const rings = parseWktPolygon(
"POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0), (2 2, 4 2, 4 4, 2 4, 2 2))",
);
expect(rings).toHaveLength(1);
});
it("дыра игнорируется → покрытие считает её закрытой (~100%)", () => {
const rings = parseWktPolygon(
"POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0), (2 2, 4 2, 4 4, 2 4, 2 2))",
);
const grid = buildCoverageGrid(rings)!;
const mask = sampleSlotMask(grid, rings);
expect(coverageOf(grid, mask)!.percent).toBeGreaterThanOrEqual(99);
});
});
describe("низкая точность (тонкий полигон)", () => {
it("крошечные фигуры в большом bbox → мало сэмплов → lowPrecision", () => {
// Два маленьких треугольника в противоположных углах: bbox 40×40, но суммарная площадь
// ничтожна → < COVERAGE_MIN_SAMPLES сэмплов внутри (тонкий полигон → абсурдный %).
const grid = buildCoverageGrid([
[
{ lon: 0, lat: 0 },
{ lon: 2, lat: 0 },
{ lon: 0, lat: 2 },
{ lon: 0, lat: 0 },
],
[
{ lon: 40, lat: 40 },
{ lon: 40, lat: 38 },
{ lon: 38, lat: 40 },
{ lon: 40, lat: 40 },
],
]);
expect(grid).not.toBeNull();
expect(grid!.sampleCount).toBeGreaterThan(0);
expect(grid!.sampleCount).toBeLessThan(COVERAGE_MIN_SAMPLES);
const res = coverageOf(grid!, new Uint8Array(grid!.lons.length));
expect(res!.lowPrecision).toBe(true);
});
it("полный квадрат → точность нормальная", () => {
const grid = buildCoverageGrid([rect(0, 0, 10, 10)])!;
expect(coverageOf(grid, new Uint8Array(grid.lons.length))!.lowPrecision).toBe(false);
});
});
@@ -0,0 +1,264 @@
// Растровое (Monte-Carlo) покрытие заявки на клиенте — ядро «слоя поддержки решений оператора».
// Чистые функции под юнит-тесты (см. coverage.test.ts, образец slotFilters.test.ts).
//
// Идея: вместо точного векторного union контуров считаем % по детерминированной сетке сэмплов
// внутри bbox заявки. Сэмпл «закрыт», если попал в union контуров выбранных/забронированных слотов.
// % = Σ cos(lat)-вес закрытых сэмплов внутри заявки / Σ вес всех сэмплов внутри заявки. Стабильно,
// дёшево на чек/снятие (per-slot маска кэшируется), ноль зависимостей.
//
// выбор/бронь слотов ─▶ [сетка сэмплов заявки, cos-lat вес] ─▶ маски слотов (OR) ─▶ % + слой coverage
// [0 валидных→null] [битый WKT→skip] [bbox-прунинг] [<порога→низкая точность]
import { pointInPolygon } from "../tgu-map-2d/geometry/pointInPolygon";
import type { GeoPoint } from "../tgu-map-2d/model/mapTypes";
/** Сторона сетки сэмплов (D1): фикс 64×64 ≈ 4096 точек по bbox заявки, независимо от размера. */
export const COVERAGE_GRID_N = 64;
/** Порог «низкой точности» (codex P1): тонкий полигон даёт мало сэмплов → % абсурдный. */
export const COVERAGE_MIN_SAMPLES = 16;
/** Детерминированная сетка сэмплов по bbox заявки (кэш на заявку). */
export type CoverageGrid = {
readonly n: number;
/** Опорная долгота фрейма unwrap (антимеридиан): все долготы приведены к [refLon-180, refLon+180]. */
readonly refLon: number;
/** Долготы сэмплов в unwrap-фрейме (length n*n). */
readonly lons: Float64Array;
/** Широты сэмплов (length n*n). */
readonly lats: Float64Array;
/** Вес сэмпла = max(cos(lat), 0) — компенсация искажения площади к полюсам. */
readonly weights: Float64Array;
/** 1, если сэмпл внутри заявки (union колец); только такие участвуют в %. */
readonly inRequest: Uint8Array;
/** Σ weight по сэмплам внутри заявки (знаменатель %). */
readonly requestWeight: number;
/** Кол-во сэмплов внутри заявки (для порога точности). */
readonly sampleCount: number;
};
/** Маска слота: 1, если сэмпл внутри заявки И закрыт контуром слота (length n*n). */
export type SlotMask = Uint8Array;
/** Результат расчёта покрытия набора. */
export type CoverageResult = {
/** Доля 0..1. */
fraction: number;
/** Целый процент 0..100 (для панели). */
percent: number;
/** Кол-во сэмплов заявки (диагностика). */
sampleCount: number;
/** sampleCount < COVERAGE_MIN_SAMPLES — % ненадёжен, панель помечает «низкая точность». */
lowPrecision: boolean;
};
const DEG2RAD = Math.PI / 180;
/** Приводит долготу к непрерывному фрейму вокруг ref (унос через антимеридиан). */
function unwrapLonTo(lon: number, ref: number): number {
let x = lon;
while (x - ref > 180) x -= 360;
while (ref - x > 180) x += 360;
return x;
}
/** Оставляет кольца ≥3 точек и уносит их долготы в фрейм ref (битые/мелкие — отбрасываем). */
function unwrapRings(rings: GeoPoint[][], ref: number): GeoPoint[][] {
const out: GeoPoint[][] = [];
for (const ring of rings) {
if (ring.length < 3) continue;
out.push(ring.map((p) => ({ lon: unwrapLonTo(p.lon, ref), lat: p.lat })));
}
return out;
}
type Bounds = { minLon: number; minLat: number; maxLon: number; maxLat: number };
function boundsOfRings(rings: GeoPoint[][]): Bounds {
let minLon = Number.POSITIVE_INFINITY;
let minLat = Number.POSITIVE_INFINITY;
let maxLon = Number.NEGATIVE_INFINITY;
let maxLat = Number.NEGATIVE_INFINITY;
for (const ring of rings) {
for (const p of ring) {
if (p.lon < minLon) minLon = p.lon;
if (p.lon > maxLon) maxLon = p.lon;
if (p.lat < minLat) minLat = p.lat;
if (p.lat > maxLat) maxLat = p.lat;
}
}
return { minLon, minLat, maxLon, maxLat };
}
/**
* Строит сетку сэмплов по bbox заявки. Дегенерат (нет валидных колец или 0 сэмплов внутри) null
* («покрытие недоступно»). Антимеридиан собственный unwrap-фрейм (примитив spatialIndex приватный).
*/
export function buildCoverageGrid(requestRings: GeoPoint[][]): CoverageGrid | null {
const valid0 = requestRings.filter((r) => r.length >= 3);
if (valid0.length === 0) return null;
const refLon = valid0[0][0]!.lon;
const rings = unwrapRings(valid0, refLon);
if (rings.length === 0) return null;
const b = boundsOfRings(rings);
if (!Number.isFinite(b.minLon) || !Number.isFinite(b.minLat)) return null;
const n = COVERAGE_GRID_N;
const total = n * n;
const lons = new Float64Array(total);
const lats = new Float64Array(total);
const weights = new Float64Array(total);
const inRequest = new Uint8Array(total);
const spanLon = b.maxLon - b.minLon;
const spanLat = b.maxLat - b.minLat;
let requestWeight = 0;
let sampleCount = 0;
for (let gy = 0; gy < n; gy++) {
// Сэмплируем по центрам ячеек ((gy+0.5)/n), чтобы не попадать ровно на границу bbox/контура.
const lat = b.minLat + ((gy + 0.5) / n) * spanLat;
const w = Math.max(Math.cos(lat * DEG2RAD), 0);
for (let gx = 0; gx < n; gx++) {
const idx = gy * n + gx;
const lon = b.minLon + ((gx + 0.5) / n) * spanLon;
lons[idx] = lon;
lats[idx] = lat;
weights[idx] = w;
const pt = { lon, lat };
let inside = false;
// union колец: нечётное число попаданий не требуется — это отдельные внешние контуры (MULTIPOLYGON),
// достаточно попасть хотя бы в одно. Дыры (внутренние кольца WKT) игнорируются (лимит MVP-парсера).
for (const ring of rings) {
if (pointInPolygon(pt, ring)) {
inside = true;
break;
}
}
if (inside) {
inRequest[idx] = 1;
requestWeight += w;
sampleCount += 1;
}
}
}
if (sampleCount === 0) return null;
return { n, refLon, lons, lats, weights, inRequest, requestWeight, sampleCount };
}
/**
* Маска слота на сетке заявки: 1 там, где сэмпл внутри заявки И закрыт контуром слота.
* bbox-прунинг (D2): сначала отбрасываем сэмплы вне bbox слота, потом точный pointInPolygon.
* Битый WKT / кольца <3 пустая маска (слот не участвует в покрытии, без падения).
*/
export function sampleSlotMask(grid: CoverageGrid, slotRings: GeoPoint[][]): SlotMask {
const mask = new Uint8Array(grid.lons.length);
const rings = unwrapRings(slotRings, grid.refLon);
if (rings.length === 0) return mask;
const b = boundsOfRings(rings);
if (!Number.isFinite(b.minLon)) return mask;
for (let idx = 0; idx < mask.length; idx++) {
if (grid.inRequest[idx] === 0) continue;
const lon = grid.lons[idx]!;
const lat = grid.lats[idx]!;
if (lon < b.minLon || lon > b.maxLon || lat < b.minLat || lat > b.maxLat) continue;
const pt = { lon, lat };
for (const ring of rings) {
if (pointInPolygon(pt, ring)) {
mask[idx] = 1;
break;
}
}
}
return mask;
}
/** OR нескольких масок в одну (union покрытия набора). */
export function unionMasks(grid: CoverageGrid, masks: readonly SlotMask[]): SlotMask {
const out = new Uint8Array(grid.lons.length);
for (const mask of masks) {
for (let i = 0; i < out.length; i++) if (mask[i]) out[i] = 1;
}
return out;
}
/** Покрытие по готовому union-маске. requestWeight<=0 (заявка у полюса) → null. */
export function coverageOf(grid: CoverageGrid, union: SlotMask): CoverageResult | null {
if (grid.requestWeight <= 0) return null;
let covered = 0;
for (let idx = 0; idx < union.length; idx++) {
if (union[idx]) covered += grid.weights[idx]!;
}
const fraction = covered / grid.requestWeight;
return {
fraction,
percent: Math.round(fraction * 100),
sampleCount: grid.sampleCount,
lowPrecision: grid.sampleCount < COVERAGE_MIN_SAMPLES,
};
}
/** Покрытие набора масок (bookedselected): union → coverageOf. Пустой набор → 0%. */
export function coverageOfMasks(grid: CoverageGrid, masks: readonly SlotMask[]): CoverageResult | null {
return coverageOf(grid, unionMasks(grid, masks));
}
/** Маргинальный вклад слота поверх текущего union: Σ cos-lat весов НОВЫХ закрытых сэмплов (не popcount). */
export function marginalGainWeight(grid: CoverageGrid, union: SlotMask, mask: SlotMask): number {
let gain = 0;
for (let idx = 0; idx < mask.length; idx++) {
if (mask[idx] && !union[idx]) gain += grid.weights[idx]!;
}
return gain;
}
/** Кандидат для ранжирования по вкладу. */
export type RankCandidate = { key: string; mask: SlotMask };
/** Ранжированный кандидат: вклад на момент ранжирования (жадный порядок), доля и целый %. */
export type RankedCandidate = { key: string; gainFraction: number; gainPercent: number };
/**
* Жадное ранжирование по маргинальному вкладу (взвешенному, codex P1): на каждом шаге берём
* кандидата с максимальным приростом покрытия поверх уже отобранного union (старт baseUnion,
* напр. забронированное). Тай-брейк исходный порядок (stable). Вклад фиксируется на момент
* ранжирования (для колонки «+N%», подпись «вклад на момент ранжирования», не live).
*/
export function rankByMarginalGain(
grid: CoverageGrid,
candidates: readonly RankCandidate[],
baseUnion?: SlotMask,
): RankedCandidate[] {
const denom = grid.requestWeight > 0 ? grid.requestWeight : 1;
const union = baseUnion ? Uint8Array.from(baseUnion) : new Uint8Array(grid.lons.length);
const remaining = candidates.map((c, order) => ({ ...c, order }));
const ranked: RankedCandidate[] = [];
while (remaining.length > 0) {
let bestIdx = 0;
let bestGain = -1;
for (let i = 0; i < remaining.length; i++) {
const gain = marginalGainWeight(grid, union, remaining[i]!.mask);
// Строгое > сохраняет исходный порядок при равенстве (стабильный тай-брейк).
if (gain > bestGain) {
bestGain = gain;
bestIdx = i;
}
}
const [picked] = remaining.splice(bestIdx, 1);
const gainFraction = bestGain / denom;
ranked.push({
key: picked!.key,
gainFraction,
gainPercent: Math.round(gainFraction * 100),
});
for (let idx = 0; idx < union.length; idx++) if (picked!.mask[idx]) union[idx] = 1;
}
return ranked;
}
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { chunk, selectionAfterCheck } from "./slotSelection";
import { chunk, selectionAfterCheck, toggleStripSelection } from "./slotSelection";
const keyAt = (i: number) => `k${i}`;
@@ -42,6 +42,29 @@ describe("selectionAfterCheck", () => {
});
});
describe("toggleStripSelection", () => {
it("добавляет все члены полосы, если выбраны не все", () => {
const next = toggleStripSelection(new Set(["a"]), ["a", "b", "c"]);
expect([...next].sort()).toEqual(["a", "b", "c"]);
});
it("снимает все члены, если все уже выбраны", () => {
const next = toggleStripSelection(new Set(["a", "b", "c", "x"]), ["a", "b", "c"]);
expect([...next]).toEqual(["x"]);
});
it("пустой набор членов — выбор без изменений", () => {
const next = toggleStripSelection(new Set(["x"]), []);
expect([...next]).toEqual(["x"]);
});
it("не мутирует исходное множество", () => {
const current = new Set(["a"]);
toggleStripSelection(current, ["a", "b"]);
expect([...current]).toEqual(["a"]);
});
});
describe("chunk", () => {
it("бьёт массив на части заданного размера, последняя — остаток", () => {
expect(chunk([1, 2, 3, 4, 5], 2)).toEqual([[1, 2], [3, 4], [5]]);
@@ -39,6 +39,29 @@ export function selectionAfterCheck(
return next
}
/**
* Новое множество выбора после клика по чипу «полоса». Поведение зеркалит «выбрать видимые»,
* но в рамках одной полосы: если ВСЕ её видимые выбираемые члены уже выбраны снимаем их,
* иначе добавляем все. Скрытые (отфильтрованные) и BUSY-члены сюда не передаются выбираем
* только то, что видно и можно бронировать (доверие счётчику «Выбрано»). Вход не мутируется.
*
* @param current Текущее множество выбранных ключей.
* @param memberKeys Ключи видимых выбираемых членов полосы.
*/
export function toggleStripSelection(
current: ReadonlySet<string>,
memberKeys: readonly string[],
): Set<string> {
const next = new Set(current)
if (memberKeys.length === 0) return next
const allSelected = memberKeys.every((k) => current.has(k))
for (const k of memberKeys) {
if (allSelected) next.delete(k)
else next.add(k)
}
return next
}
/**
* Разбивает массив на чанки заданного размера (для чанкованной брони крупных наборов).
* @param items Исходный массив.
@@ -0,0 +1,122 @@
import { describe, expect, it } from "vitest";
import { ROLL_EPSILON, groupStrips } from "./slotStrips";
/** Минимальный слот для группировки. tn задаём как локальную ISO-строку (как отдаёт бэкенд). */
type S = {
satelliteId: number;
slotNumber: number;
cycle: number;
tn: string;
tk: string;
roll: number;
};
const keyOf = (s: S) => `${s.satelliteId}:${s.slotNumber}:${s.cycle}`;
/** Слот на минуту mm (от базовой даты) с длительностью 30 c. */
function slot(satelliteId: number, slotNumber: number, roll: number, mm: number, cycle = 1): S {
const tn = `2026-06-17T10:${String(mm).padStart(2, "0")}:00`;
const tk = `2026-06-17T10:${String(mm).padStart(2, "0")}:30`;
return { satelliteId, slotNumber, cycle, tn, tk, roll };
}
/** Разбиение на полосы как множество множеств ключей — для сравнения, не зависящего от имён StripId. */
function partition(map: Map<string, string>): string[][] {
const byStrip = new Map<string, string[]>();
for (const [key, strip] of map) {
const arr = byStrip.get(strip) ?? [];
arr.push(key);
byStrip.set(strip, arr);
}
return [...byStrip.values()].map((keys) => keys.slice().sort()).sort((a, b) => (a[0]! < b[0]! ? -1 : 1));
}
describe("groupStrips", () => {
it("пустой вход → пустая Map", () => {
expect(groupStrips([], keyOf).size).toBe(0);
});
it("один слот → своя полоса", () => {
const map = groupStrips([slot(1, 1, 5, 0)], keyOf);
expect(map.get("1:1:1")).toBe("strip:1:1:1");
});
it("мультиполоса в одном КА: смена крена начинает новую полосу", () => {
const slots = [
slot(1, 1, 5, 0),
slot(1, 2, 5, 1),
slot(1, 3, 20, 2), // крен изменился → новая полоса
slot(1, 4, 20, 3),
];
expect(partition(groupStrips(slots, keyOf))).toEqual([
["1:1:1", "1:2:1"],
["1:3:1", "1:4:1"],
]);
});
it("два КА вперемешку по времени не склеиваются (даже при одном крене)", () => {
const slots = [
slot(1, 1, 5, 0),
slot(2, 1, 5, 0),
slot(1, 2, 5, 1),
slot(2, 2, 5, 1),
];
expect(partition(groupStrips(slots, keyOf))).toEqual([
["1:1:1", "1:2:1"],
["2:1:1", "2:2:1"],
]);
});
it("граница ε: diff == 0.01 склеивает, 0.0101 рвёт", () => {
const same = groupStrips([slot(1, 1, 5.0, 0), slot(1, 2, 5 + ROLL_EPSILON, 1)], keyOf);
expect(partition(same)).toEqual([["1:1:1", "1:2:1"]]);
const broken = groupStrips([slot(1, 1, 5.0, 0), slot(1, 2, 5 + ROLL_EPSILON + 0.0001, 1)], keyOf);
expect(partition(broken)).toEqual([["1:1:1"], ["1:2:1"]]);
});
it("дрейф крена 0.00/0.009/0.018 (сравнение с якорем) → две полосы, не одна", () => {
const slots = [slot(1, 1, 0.0, 0), slot(1, 2, 0.009, 1), slot(1, 3, 0.018, 2)];
// 0.009−0.0 ≤ ε → в полосе; 0.018−0.0 > ε → новая полоса (а не приклеивание к 0.009).
expect(partition(groupStrips(slots, keyOf))).toEqual([["1:1:1", "1:2:1"], ["1:3:1"]]);
});
it("устойчивость к порядку входа: разбиение одинаково при перемешивании", () => {
const slots = [
slot(1, 1, 5, 0),
slot(1, 2, 5, 1),
slot(1, 3, 20, 2),
slot(2, 1, 5, 0),
];
const a = partition(groupStrips(slots, keyOf));
const b = partition(groupStrips([...slots].reverse(), keyOf));
const c = partition(groupStrips([slots[2]!, slots[0]!, slots[3]!, slots[1]!], keyOf));
expect(b).toEqual(a);
expect(c).toEqual(a);
});
it("StripId стабилен = strip:${ключ первого по времени члена}", () => {
const map = groupStrips([slot(1, 2, 5, 1), slot(1, 1, 5, 0)], keyOf);
// первый по времени — slotNumber 1 (mm=0); оба члена несут его StripId.
expect(map.get("1:1:1")).toBe("strip:1:1:1");
expect(map.get("1:2:1")).toBe("strip:1:1:1");
});
it("tie-break при равном tn: устойчивая группировка по slotNumber/cycle/ключу", () => {
const a = slot(1, 1, 5, 0);
const b = slot(1, 2, 5, 0); // тот же tn/tk
const map = groupStrips([b, a], keyOf);
expect(partition(map)).toEqual([["1:1:1", "1:2:1"]]);
expect(map.get("1:1:1")).toBe("strip:1:1:1"); // меньший ключ — якорь
});
it("NaN tn → синглтон без падения, не клеит соседей", () => {
const broken: S = { satelliteId: 1, slotNumber: 9, cycle: 1, tn: "битый", tk: "битый", roll: 5 };
const slots = [slot(1, 1, 5, 0), broken, slot(1, 2, 5, 1)];
const map = groupStrips(slots, keyOf);
expect(map.get("1:9:1")).toBe("strip:1:9:1");
// валидные соседи — своя полоса, битый слот их не склеивает и не рвёт между собой.
expect(map.get("1:1:1")).toBe("strip:1:1:1");
expect(map.get("1:2:1")).toBe("strip:1:1:1");
});
});
@@ -0,0 +1,108 @@
// Группировка слотов в «полосы» (strips) — максимальные цепочки идущих подряд по времени
// слотов одного КА с одним креном. Чистая логика под юнит-тесты (см. slotStrips.test.ts,
// образец стиля slotSelection.ts / coverage.ts). Используется UI для жеста «выбрать полосу»
// и визуальной группировки строк таблицы.
//
// Это ТОЛЬКО identity-группировка (какой слот к какой полосе относится), НЕ проверка
// выполнимости брони. Проверка манёвренности КА (mmi/maxSurveyDuration/maxChainLength) живёт
// в slots-service по-КА из каталога и будет вынесена в бэкенд validate-эндпоинт (iteration 2),
// чтобы не дублировать физику на клиенте.
import type { SlotDTO } from "../../model/slotTypes";
import { parseLocal } from "../../utils/localTime";
/**
* Допуск сравнения крена (градусы). Совпадает с бэкендовым `ROLL_EPSILON` в slots-service:
* слоты считаются «одним креном», если |roll anchorRoll| ROLL_EPSILON. НЕ 0.1 крупный
* допуск ошибочно склеивал бы разные нацеливания.
*/
export const ROLL_EPSILON = 0.01;
/** Идентификатор полосы. Стабилен и читаем: `strip:${slotKey первого члена}` (не счётчик,
* который зависел бы от порядка обхода). */
export type StripId = string;
/** Минимальный набор полей слота, нужный для группировки. */
type Stripable = Pick<SlotDTO, "satelliteId" | "slotNumber" | "cycle" | "tn" | "tk" | "roll">;
/** Сравнение чисел с детерминированным порядком; NaN — в конец (устойчивость к битому tn). */
function cmpNum(a: number, b: number): number {
const an = Number.isNaN(a);
const bn = Number.isNaN(b);
if (an && bn) return 0;
if (an) return 1;
if (bn) return -1;
return a < b ? -1 : a > b ? 1 : 0;
}
function cmpStr(a: string, b: string): number {
return a < b ? -1 : a > b ? 1 : 0;
}
/**
* Размечает слоты по полосам: возвращает Map<slotKey, StripId>. Полоса максимальный run
* идущих подряд по времени слотов ОДНОГО КА с креном, отличающимся от якорного крена полосы
* не более чем на ROLL_EPSILON (сравнение с якорем, а не с предыдущим слотом иначе дрейф
* крена 0.000.0090.018 ошибочно склеил бы всё в одну полосу). Совпадает с бэкендовым
* `sameRoll(roll, chainRoll)`.
*
* Детерминизм: результат не зависит от порядка входного массива внутри тотальная сортировка
* по `(satelliteId, tn, tk, slotNumber, cycle, slotKey)`.
*
* @param slots Слоты для разметки (любой порядок).
* @param keyOf Аксессор ключа слота (как `keyAt` в selectionAfterCheck) чтобы не дублировать
* формулу `${satelliteId}:${slotNumber}:${cycle}`.
*/
export function groupStrips<T extends Stripable>(
slots: readonly T[],
keyOf: (slot: T) => string,
): Map<string, StripId> {
const result = new Map<string, StripId>();
if (slots.length === 0) return result;
const items = slots.map((slot) => ({
slot,
key: keyOf(slot),
tnMs: parseLocal(slot.tn),
tkMs: parseLocal(slot.tk),
}));
items.sort(
(a, b) =>
cmpNum(a.slot.satelliteId, b.slot.satelliteId) ||
cmpNum(a.tnMs, b.tnMs) ||
cmpNum(a.tkMs, b.tkMs) ||
cmpNum(a.slot.slotNumber, b.slot.slotNumber) ||
cmpNum(a.slot.cycle, b.slot.cycle) ||
cmpStr(a.key, b.key),
);
let stripId: StripId | null = null;
let anchorRoll = 0;
let anchorSat: number | null = null;
for (const item of items) {
const tnBad = Number.isNaN(item.tnMs);
const continues =
stripId != null &&
!tnBad &&
anchorSat === item.slot.satelliteId &&
Math.abs(item.slot.roll - anchorRoll) <= ROLL_EPSILON;
if (!continues) {
stripId = `strip:${item.key}`;
anchorRoll = item.slot.roll;
anchorSat = item.slot.satelliteId;
}
result.set(item.key, stripId!);
// Слот с непарсящимся tn — синглтон: следующий слот должен начать свежую полосу,
// а не приклеиться к этому якорю.
if (tnBad) {
stripId = null;
anchorSat = null;
}
}
return result;
}
@@ -14,6 +14,16 @@ export type SlotCoverageStrategy = "GREEDY" | "CONTINUOUS";
* бронью на этом проходе КА: показываем для обзора, но забронировать нельзя. */
export type SlotStatus = "AVAILABLE" | "BOOKED" | "BUSY" | "PROCESSED" | string;
/**
* Держатель занятого (BUSY) слота заявка, чья бронь блокирует слот (slots-service: SlotHolderDTO).
* name/importance могут отсутствовать, если request-service недоступен (деградация показываем id).
*/
export type SlotHolder = {
requestId: string;
name?: string | null;
importance?: number | null;
};
/** Слот покрытия заявки — кандидат на бронирование (slots-service: SlotDTO). */
export type SlotDTO = {
cycle: number;
@@ -29,6 +39,8 @@ export type SlotDTO = {
state: SlotStatus;
latitude: number;
longitude: number;
/** Держатели BUSY-слота (обзор конфликтов, all=true). Пусто/нет для свободных и брони. */
holders?: SlotHolder[] | null;
};
/** Забронированный слот (slots-service: BookedSlotDTO) — ответ на POST booking/request. */
@@ -963,6 +963,81 @@
gap: 0.35rem;
}
/* Панель покрытия над списком (D-D1). Цвет = слой coverage на карте (violet) — единый язык «покрыто». */
.tgu-coverage {
padding: 0.45rem 0.55rem;
border-bottom: 1px solid var(--line);
background: var(--bg-panel);
font-size: 0.78rem;
}
.tgu-coverage__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 0.4rem;
}
.tgu-coverage__title {
color: var(--text-muted);
font-weight: 600;
}
.tgu-coverage__toggle {
display: inline-flex;
align-items: center;
gap: 0.3rem;
color: var(--text-dim);
cursor: pointer;
user-select: none;
}
.tgu-coverage__toggle input {
accent-color: rgb(167, 139, 219);
}
.tgu-coverage__bar {
position: relative;
display: flex;
height: 0.5rem;
border-radius: 999px;
overflow: hidden;
background: rgba(167, 139, 219, 0.12);
border: 1px solid var(--line);
}
.tgu-coverage__seg--booked {
background: rgba(167, 139, 219, 0.65);
}
.tgu-coverage__seg--sel {
background: rgba(167, 139, 219, 0.32);
}
.tgu-coverage__legend {
margin-top: 0.3rem;
color: var(--text-dim);
}
.tgu-coverage__booked {
color: rgb(186, 164, 230);
font-weight: 600;
}
.tgu-coverage__sel {
color: var(--text-muted);
}
.tgu-coverage__low {
color: var(--text-dim);
font-style: italic;
}
.tgu-coverage__unavailable {
color: var(--text-dim);
}
.tgu-slots-empty {
padding: 1rem;
color: var(--text-dim);
@@ -1187,6 +1262,17 @@
text-align: right;
}
/* Колонка «Вклад» (+N%) — по правому краю, цвет совпадает со слоем покрытия (violet). */
.tgu-slots-gain-col,
.tgu-slots-gain {
text-align: right;
}
.tgu-slots-gain {
color: rgb(167, 139, 219);
font-weight: 600;
}
.tgu-slots-table thead th {
position: sticky;
top: 0;
@@ -1219,6 +1305,52 @@
background: rgba(104, 195, 189, 0.36);
}
/* Ячейка «КА»: номер КА + чип выбора полосы рядом. */
.tgu-slots-sat {
display: inline-flex;
align-items: center;
gap: 0.4rem;
}
/* Чип «полоса ×N» выбрать всю полосу (слоты одного крена подряд) одним кликом.
Нейтральный по умолчанию; .is-on когда вся полоса уже выбрана. */
.tgu-slots-strip-chip {
display: inline-flex;
align-items: center;
padding: 0.02rem 0.4rem;
border-radius: 999px;
border: 1px solid var(--line);
background: #101214;
color: var(--text-muted);
font-size: 0.68rem;
line-height: 1.4;
cursor: pointer;
white-space: nowrap;
}
.tgu-slots-strip-chip:hover {
border-color: var(--accent);
color: var(--text);
}
.tgu-slots-strip-chip.is-on {
border-color: rgba(104, 195, 189, 0.58);
background: rgba(104, 195, 189, 0.16);
color: var(--accent-strong);
}
/* Рельс полосы (сортировка «время», члены идут подряд): приглушённая цветная полоса слева,
оттенок детерминирован StripId (--strip-hue) соседние полосы визуально различаются.
У активной строки рельс перекрывается акцентным (is-active), это норма. */
.tgu-slots-table tbody tr.tgu-slots-row--strip {
box-shadow: inset 3px 0 0 hsl(var(--strip-hue, 0) 45% 55% / 0.55);
}
/* Подсветка всех членов полосы под курсором (актуально в «крен»/«вклад», где они не подряд). */
.tgu-slots-table tbody tr.is-strip-hover {
background: rgba(104, 195, 189, 0.12);
}
.tgu-slots-badge {
display: inline-block;
padding: 0.1rem 0.45rem;
@@ -1243,6 +1375,29 @@
color: #e98a8a;
}
/* Держатель BUSY-слота (M3): кто блокирует слот. Тот же приглушённо-красный регистр, что и
бейдж «недоступен» (--occupied), но мягче это уточнение к статусу, не отдельный сигнал. */
.tgu-slots-holder {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.1rem 0.4rem;
border-radius: 999px;
border: 1px solid rgba(214, 69, 69, 0.32);
background: rgba(214, 69, 69, 0.08);
color: #e98a8a;
font-size: 0.7rem;
max-width: 12rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tgu-slots-holder__imp {
color: var(--text-muted);
font-variant-numeric: tabular-nums;
}
.tgu-map-stage {
position: relative;
min-width: 0;