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:
@@ -85,26 +85,6 @@ export async function bookRequestSlots(
|
|||||||
return response.json() as Promise<BookedSlotDTO[]>
|
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
|
* Снимает выбранные брони заявки по списку bookedSlotId (slots-service: DELETE
|
||||||
* /api/slots/booking/batch). Один запрос = один чанк; крупный набор чанкует вызывающий
|
* /api/slots/booking/batch). Один запрос = один чанк; крупный набор чанкует вызывающий
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { drawBaseMap } from "./canvas/drawBaseMap";
|
import { drawBaseMap } from "./canvas/drawBaseMap";
|
||||||
import { drawComplexPlan } from "./canvas/drawComplexPlan";
|
import { drawComplexPlan } from "./canvas/drawComplexPlan";
|
||||||
|
import { drawCoverage } from "./canvas/drawCoverage";
|
||||||
import { drawHeatmap } from "./canvas/drawHeatmap";
|
import { drawHeatmap } from "./canvas/drawHeatmap";
|
||||||
import { drawPlanWorks } from "./canvas/drawPlanWorks";
|
import { drawPlanWorks } from "./canvas/drawPlanWorks";
|
||||||
import { drawRequests } from "./canvas/drawRequests";
|
import { drawRequests } from "./canvas/drawRequests";
|
||||||
@@ -213,6 +214,11 @@ export function Tgu2DMapView({
|
|||||||
if (layers.complexPlan) {
|
if (layers.complexPlan) {
|
||||||
drawComplexPlan(context, visiblePolygons.filter((polygon) => polygon.layer === "complexPlan"), projection, size);
|
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) {
|
if (layers.planWorks) {
|
||||||
drawPlanWorks(context, visiblePolygons.filter((polygon) => polygon.layer === "planWorks"), projection, size);
|
drawPlanWorks(context, visiblePolygons.filter((polygon) => polygon.layer === "planWorks"), projection, size);
|
||||||
}
|
}
|
||||||
@@ -225,7 +231,7 @@ export function Tgu2DMapView({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return () => window.cancelAnimationFrame(frameId);
|
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 screenPointFromEvent = useCallback((event: React.PointerEvent | React.MouseEvent): ScreenPoint => {
|
||||||
const bounds = wrapperRef.current?.getBoundingClientRect();
|
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[];
|
polygons: MapPolygon[];
|
||||||
lines: MapLine[];
|
lines: MapLine[];
|
||||||
markers: MapMarker[];
|
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 { RevolutionSign } from "../../model/slotTypes";
|
||||||
import type { SlotStatusKey } from "./slotFilters";
|
import type { SlotStatusKey } from "./slotFilters";
|
||||||
|
|
||||||
|
/** Сортировка таблицы слотов: время (порядок расчёта) · вклад (ранжирование по покрытию) · крен. */
|
||||||
|
export type SlotSortMode = "time" | "gain" | "roll";
|
||||||
|
|
||||||
type SlotFilterBarProps = {
|
type SlotFilterBarProps = {
|
||||||
/** Счётчики статусов по окну суток (до статус-фильтра) — стабильны при переключении чипов. */
|
/** Счётчики статусов по окну суток (до статус-фильтра) — стабильны при переключении чипов. */
|
||||||
statusCounts: Record<SlotStatusKey, number>;
|
statusCounts: Record<SlotStatusKey, number>;
|
||||||
@@ -12,6 +15,11 @@ type SlotFilterBarProps = {
|
|||||||
onToggleSat: (id: number) => void;
|
onToggleSat: (id: number) => void;
|
||||||
branchFilter: ReadonlySet<RevolutionSign>;
|
branchFilter: ReadonlySet<RevolutionSign>;
|
||||||
onToggleBranch: (branch: RevolutionSign) => void;
|
onToggleBranch: (branch: RevolutionSign) => void;
|
||||||
|
/** Текущая сортировка таблицы (одиночный выбор). */
|
||||||
|
sortMode: SlotSortMode;
|
||||||
|
onSortMode: (mode: SlotSortMode) => void;
|
||||||
|
/** Доступна ли сортировка «по вкладу» (есть геометрия заявки) — иначе чип заблокирован. */
|
||||||
|
gainAvailable: boolean;
|
||||||
/** Фильтры в дефолте (всё видно) — тогда «Сбросить» не показываем. */
|
/** Фильтры в дефолте (всё видно) — тогда «Сбросить» не показываем. */
|
||||||
isDefault: boolean;
|
isDefault: boolean;
|
||||||
onReset: () => void;
|
onReset: () => void;
|
||||||
@@ -23,6 +31,12 @@ const STATUS_META: { key: SlotStatusKey; label: string; variant: string }[] = [
|
|||||||
{ key: "occupied", label: "недоступен", variant: " is-occupied" },
|
{ key: "occupied", label: "недоступен", variant: " is-occupied" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const SORT_META: { key: SlotSortMode; label: string }[] = [
|
||||||
|
{ key: "time", label: "время" },
|
||||||
|
{ key: "gain", label: "вклад" },
|
||||||
|
{ key: "roll", label: "крен" },
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Панель фильтров отображения слотов (видимость). Сужает И таблицу, И карту через единый
|
* Панель фильтров отображения слотов (видимость). Сужает И таблицу, И карту через единый
|
||||||
* filteredSlots. Стоит над строкой выбора: сначала сужаешь, потом отбираешь. Чипы —
|
* filteredSlots. Стоит над строкой выбора: сначала сужаешь, потом отбираешь. Чипы —
|
||||||
@@ -38,6 +52,9 @@ export function SlotFilterBar({
|
|||||||
onToggleSat,
|
onToggleSat,
|
||||||
branchFilter,
|
branchFilter,
|
||||||
onToggleBranch,
|
onToggleBranch,
|
||||||
|
sortMode,
|
||||||
|
onSortMode,
|
||||||
|
gainAvailable,
|
||||||
isDefault,
|
isDefault,
|
||||||
onReset,
|
onReset,
|
||||||
}: SlotFilterBarProps) {
|
}: SlotFilterBarProps) {
|
||||||
@@ -99,6 +116,27 @@ export function SlotFilterBar({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</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 && (
|
{!isDefault && (
|
||||||
<button type="button" className="tgu-button tgu-button--sm tgu-slots-filterbar__reset" onClick={onReset}>
|
<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 { formatLocalDateTimeSeconds, parseLocal, toLocalInputValue, toLocalIso } from "../../utils/localTime";
|
||||||
import { SlotDaySelector, type SlotDay } from "./SlotDaySelector";
|
import { SlotDaySelector, type SlotDay } from "./SlotDaySelector";
|
||||||
import { SlotPickMenu, type SlotPick } from "./SlotPickMenu";
|
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 { applySlotFilters, slotStatusKey, type SlotStatusKey } from "./slotFilters";
|
||||||
import { selectionAfterCheck } from "./slotSelection";
|
import { selectionAfterCheck, toggleStripSelection } from "./slotSelection";
|
||||||
|
import { groupStrips } from "./slotStrips";
|
||||||
import { runChunked } from "./runChunked";
|
import { runChunked } from "./runChunked";
|
||||||
import { ChunkProgress } from "./ChunkProgress";
|
import { ChunkProgress } from "./ChunkProgress";
|
||||||
|
|
||||||
@@ -87,6 +98,13 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
|
|||||||
const [slots, setSlots] = useState<SlotDTO[]>([]);
|
const [slots, setSlots] = useState<SlotDTO[]>([]);
|
||||||
const [bookedSlots, setBookedSlots] = useState<BookedSlotView[]>([]);
|
const [bookedSlots, setBookedSlots] = useState<BookedSlotView[]>([]);
|
||||||
const [selectedKeys, setSelectedKeys] = useState<ReadonlySet<string>>(() => new Set());
|
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) выбранного диапазона суток расчёта.
|
// Фильтр отображения по суткам: индексы (1-based) выбранного диапазона суток расчёта.
|
||||||
const [dayFrom, setDayFrom] = useState(1);
|
const [dayFrom, setDayFrom] = useState(1);
|
||||||
@@ -127,6 +145,9 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
|
|||||||
|
|
||||||
// Подсветка строки/контура по клику на карте и центрирование на слоте.
|
// Подсветка строки/контура по клику на карте и центрирование на слоте.
|
||||||
const [activeSlotId, setActiveSlotId] = useState<string>();
|
const [activeSlotId, setActiveSlotId] = useState<string>();
|
||||||
|
// Полоса под курсором — для подсветки всех её членов по StripId (актуально в сортировках
|
||||||
|
// «крен»/«вклад», где члены полосы не идут подряд). null — курсор вне полос.
|
||||||
|
const [hoveredStrip, setHoveredStrip] = useState<string | null>(null);
|
||||||
const [focusPoint, setFocusPoint] = useState<GeoPoint>();
|
const [focusPoint, setFocusPoint] = useState<GeoPoint>();
|
||||||
const focusTokenRef = useRef(0);
|
const focusTokenRef = useRef(0);
|
||||||
const [focusToken, setFocusToken] = useState(0);
|
const [focusToken, setFocusToken] = useState(0);
|
||||||
@@ -274,14 +295,35 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
|
|||||||
[dayWindowed, statusFilter, satFilter, branchFilter, bookedKeys],
|
[dayWindowed, statusFilter, satFilter, branchFilter, bookedKeys],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Виртуализация таблицы: в DOM держим только видимое окно строк (+overscan), а не все слоты —
|
// Идентичность полос — над СТАБИЛЬНЫМ набором displaySlots (НЕ filteredSlots/sortedSlots:
|
||||||
// иначе на тысячах строк рендер встаёт. Скролл-контейнер — секция списка (listRef).
|
// иначе фильтр-дырка ложно разорвала или склеила бы полосу). Map<slotKey, StripId>.
|
||||||
const rowVirtualizer = useVirtualizer({
|
const stripIdByKey = useMemo(() => groupStrips(displaySlots, slotKey), [displaySlots]);
|
||||||
count: filteredSlots.length,
|
|
||||||
getScrollElement: () => listRef.current,
|
// По каждой видимой полосе: число видимых членов (N для чипа) и ключи видимых ВЫБИРАЕМЫХ
|
||||||
estimateSize: () => ROW_HEIGHT,
|
// (не-BUSY) членов — цель выбора. Считаем над filteredSlots: выбираем/считаем только видимое.
|
||||||
overscan: 12
|
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) => {
|
const loadRequests = useCallback(async (fromInput: string, toInput: string) => {
|
||||||
@@ -312,16 +354,6 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
|
|||||||
void loadRequests(fromInput, toInput);
|
void loadRequests(fromInput, toInput);
|
||||||
}, [range.fromMs, range.toMs, loadRequests]);
|
}, [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) => {
|
const focusOn = useCallback((point?: GeoPoint) => {
|
||||||
if (!point) return;
|
if (!point) return;
|
||||||
setFocusPoint(point);
|
setFocusPoint(point);
|
||||||
@@ -340,6 +372,150 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
|
|||||||
return rings;
|
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) => {
|
const loadBooked = useCallback(async (requestId: string) => {
|
||||||
try {
|
try {
|
||||||
@@ -368,6 +544,7 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
|
|||||||
// Смена заявки — фильтры видимости в дефолт (satFilter раскроется через satSignature-эффект).
|
// Смена заявки — фильтры видимости в дефолт (satFilter раскроется через satSignature-эффект).
|
||||||
setStatusFilter(new Set<SlotStatusKey>(["free", "booked", "occupied"]));
|
setStatusFilter(new Set<SlotStatusKey>(["free", "booked", "occupied"]));
|
||||||
setBranchFilter(new Set<RevolutionSign>(["ASC", "DESC"]));
|
setBranchFilter(new Set<RevolutionSign>(["ASC", "DESC"]));
|
||||||
|
setSortMode("time");
|
||||||
if (selectedRequestId) void loadBooked(selectedRequestId);
|
if (selectedRequestId) void loadBooked(selectedRequestId);
|
||||||
}, [selectedRequestId, loadBooked]);
|
}, [selectedRequestId, loadBooked]);
|
||||||
|
|
||||||
@@ -482,8 +659,8 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
|
|||||||
// Источник истины — ответ POST: BookedSlotDTO несёт bookedSlotId и тот же cycle, что мы
|
// Источник истины — ответ POST: BookedSlotDTO несёт bookedSlotId и тот же cycle, что мы
|
||||||
// отправили (= cycle рассчитанного слота). Контур берём из рассчитанного слота по ключу;
|
// отправили (= cycle рассчитанного слота). Контур берём из рассчитанного слота по ключу;
|
||||||
// by-request не перечитываем. Так bookedSlots получает и контур, и bookedSlotId.
|
// by-request не перечитываем. Так bookedSlots получает и контур, и bookedSlotId.
|
||||||
const idByKey = new Map(
|
const idByKey = new Map<string, number>(
|
||||||
collected.map((item) => [`${item.satelliteId}:${item.slotNum}:${item.cycle}`, item.bookedSlotId] as const),
|
collected.map((item) => [`${item.satelliteId}:${item.slotNum}:${item.cycle}`, item.bookedSlotId]),
|
||||||
);
|
);
|
||||||
const justBooked: BookedSlotView[] = slots
|
const justBooked: BookedSlotView[] = slots
|
||||||
.filter((slot) => idByKey.has(slotKey(slot)))
|
.filter((slot) => idByKey.has(slotKey(slot)))
|
||||||
@@ -530,11 +707,13 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
|
|||||||
// (тот же бар, что у брони), затем убирает снятые из bookedSlots и из выбора. На него опираются
|
// (тот же бар, что у брони), затем убирает снятые из bookedSlots и из выбора. На него опираются
|
||||||
// и «Снять выбранные», и «Снять всё», и ✕ в строке — снятие везде пачками с прогрессом, а не
|
// и «Снять выбранные», и «Снять всё», и ✕ в строке — снятие везде пачками с прогрессом, а не
|
||||||
// одним bulk-запросом (по одному HTTP нельзя показать ход на 7000 слотов). setBusy — флаг кнопки.
|
// одним 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);
|
setBusy(true);
|
||||||
setError(undefined);
|
setError(undefined);
|
||||||
setNotice(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 {
|
try {
|
||||||
const counts = await runChunked(
|
const counts = await runChunked(
|
||||||
ids,
|
ids,
|
||||||
@@ -577,6 +756,12 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
|
|||||||
// набора и серверного. Так «Снять всё» убирает вообще все брони заявки, даже если локальный
|
// набора и серверного. Так «Снять всё» убирает вообще все брони заявки, даже если локальный
|
||||||
// bookedSlots разошёлся с базой (bookedSlotId надёжен независимо от cycle-фрейма брони).
|
// bookedSlots разошёлся с базой (bookedSlotId надёжен независимо от cycle-фрейма брони).
|
||||||
const performCancelAll = async () => {
|
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));
|
const idSet = new Set(bookedSlots.map((slot) => slot.bookedSlotId));
|
||||||
try {
|
try {
|
||||||
const fresh = await fetchBookedSlotsByRequest(selectedRequestId);
|
const fresh = await fetchBookedSlotsByRequest(selectedRequestId);
|
||||||
@@ -584,7 +769,7 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
|
|||||||
} catch {
|
} catch {
|
||||||
// by-request недоступен — снимаем по локальному набору броней.
|
// by-request недоступен — снимаем по локальному набору броней.
|
||||||
}
|
}
|
||||||
await runCancel(Array.from(idSet), setCancelling);
|
await runCancel(Array.from(idSet), setCancelling, true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const cancelAll = () => {
|
const cancelAll = () => {
|
||||||
@@ -601,7 +786,7 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
|
|||||||
// текущей строки; направление (выбрать/снять) задаётся новым состоянием текущей строки.
|
// текущей строки; направление (выбрать/снять) задаётся новым состоянием текущей строки.
|
||||||
const handleSlotCheck = (index: number, shiftKey: boolean) => {
|
const handleSlotCheck = (index: number, shiftKey: boolean) => {
|
||||||
setSelectedKeys((current) =>
|
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;
|
anchorIndexRef.current = index;
|
||||||
};
|
};
|
||||||
@@ -620,6 +805,15 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
|
|||||||
});
|
});
|
||||||
anchorIndexRef.current = null;
|
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 = () =>
|
const selectAllDays = () =>
|
||||||
setSelectedKeys(new Set(displaySlots.filter((slot) => !slotIsBusy(slot)).map(slotKey)));
|
setSelectedKeys(new Set(displaySlots.filter((slot) => !slotIsBusy(slot)).map(slotKey)));
|
||||||
@@ -635,6 +829,11 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
|
|||||||
else next.add(value);
|
else next.add(value);
|
||||||
return next;
|
return next;
|
||||||
};
|
};
|
||||||
|
// Смена сортировки меняет позиции строк — якорь Shift-выбора больше не валиден, сбрасываем.
|
||||||
|
const changeSort = (mode: SlotSortMode) => {
|
||||||
|
setSortMode(mode);
|
||||||
|
anchorIndexRef.current = null;
|
||||||
|
};
|
||||||
const toggleStatus = (key: SlotStatusKey) => setStatusFilter((prev) => toggleInSet(prev, key));
|
const toggleStatus = (key: SlotStatusKey) => setStatusFilter((prev) => toggleInSet(prev, key));
|
||||||
const toggleSat = (id: number) => setSatFilter((prev) => toggleInSet(prev, id));
|
const toggleSat = (id: number) => setSatFilter((prev) => toggleInSet(prev, id));
|
||||||
const toggleBranch = (branch: RevolutionSign) => setBranchFilter((prev) => toggleInSet(prev, branch));
|
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,
|
hasRequest,
|
||||||
requestItems,
|
requestItems,
|
||||||
@@ -773,7 +972,8 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
|
|||||||
ringsFor,
|
ringsFor,
|
||||||
statusFilter,
|
statusFilter,
|
||||||
satFilter,
|
satFilter,
|
||||||
branchFilter
|
branchFilter,
|
||||||
|
coverageRings
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const layers = useMemo<Tgu2DMapLayersState>(
|
const layers = useMemo<Tgu2DMapLayersState>(
|
||||||
@@ -873,6 +1073,9 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
|
|||||||
? "Слоты не рассчитаны — нажмите «Показать все слоты» или «Подобрать покрытие»."
|
? "Слоты не рассчитаны — нажмите «Показать все слоты» или «Подобрать покрытие»."
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
// Число колонок таблицы (для colSpan распорок виртуализатора) — +1 на колонку «Вклад».
|
||||||
|
const colCount = showGain ? 8 : 7;
|
||||||
|
|
||||||
// Видимое окно строк виртуализатора и распорки сверху/снизу (держат суммарную высоту списка).
|
// Видимое окно строк виртуализатора и распорки сверху/снизу (держат суммарную высоту списка).
|
||||||
const virtualRows = rowVirtualizer.getVirtualItems();
|
const virtualRows = rowVirtualizer.getVirtualItems();
|
||||||
const virtualTotal = rowVirtualizer.getTotalSize();
|
const virtualTotal = rowVirtualizer.getTotalSize();
|
||||||
@@ -983,10 +1186,23 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
|
|||||||
onToggleSat={toggleSat}
|
onToggleSat={toggleSat}
|
||||||
branchFilter={branchFilter}
|
branchFilter={branchFilter}
|
||||||
onToggleBranch={toggleBranch}
|
onToggleBranch={toggleBranch}
|
||||||
|
sortMode={sortMode}
|
||||||
|
onSortMode={changeSort}
|
||||||
|
gainAvailable={coverageAvailable}
|
||||||
isDefault={filtersDefault}
|
isDefault={filtersDefault}
|
||||||
onReset={resetFilters}
|
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">
|
<div className="tgu-slots-selbar">
|
||||||
<span className="tgu-slots-selbar__count">
|
<span className="tgu-slots-selbar__count">
|
||||||
Выбрано: {selectedKeys.size}
|
Выбрано: {selectedKeys.size}
|
||||||
@@ -1052,28 +1268,63 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
|
|||||||
<th>Начало</th>
|
<th>Начало</th>
|
||||||
<th>Длит.</th>
|
<th>Длит.</th>
|
||||||
<th>Крен</th>
|
<th>Крен</th>
|
||||||
|
{showGain && (
|
||||||
|
<th
|
||||||
|
className="tgu-slots-gain-col"
|
||||||
|
title="Прирост покрытия в порядке ранжирования — фиксируется на момент ранжирования, не пересчитывается при выборе"
|
||||||
|
>
|
||||||
|
Вклад
|
||||||
|
</th>
|
||||||
|
)}
|
||||||
<th>Статус</th>
|
<th>Статус</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{padTop > 0 && (
|
{padTop > 0 && (
|
||||||
<tr aria-hidden="true" style={{ height: padTop }}>
|
<tr aria-hidden="true" style={{ height: padTop }}>
|
||||||
<td colSpan={7} />
|
<td colSpan={colCount} />
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
{virtualRows.map((vrow) => {
|
{virtualRows.map((vrow) => {
|
||||||
const slot = filteredSlots[vrow.index];
|
const slot = sortedSlots[vrow.index];
|
||||||
const key = slotKey(slot);
|
const key = slotKey(slot);
|
||||||
const id = `slot:${key}`;
|
const id = `slot:${key}`;
|
||||||
const booked = bookedKeys.has(key);
|
const booked = bookedKeys.has(key);
|
||||||
const isActive = activeSlotId === id;
|
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 (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={key}
|
key={key}
|
||||||
data-slot-id={id}
|
data-slot-id={id}
|
||||||
style={{ height: ROW_HEIGHT }}
|
style={{
|
||||||
className={isActive ? "is-active" : undefined}
|
height: ROW_HEIGHT,
|
||||||
|
...(showRail && stripId
|
||||||
|
? { ["--strip-hue" as string]: String(stripHue(stripId)) }
|
||||||
|
: {}),
|
||||||
|
}}
|
||||||
|
className={rowClass || undefined}
|
||||||
onClick={() => selectRow(slot)}
|
onClick={() => selectRow(slot)}
|
||||||
|
onMouseEnter={isStrip ? () => setHoveredStrip(stripId!) : undefined}
|
||||||
|
onMouseLeave={isStrip ? () => setHoveredStrip(null) : undefined}
|
||||||
>
|
>
|
||||||
<td onClick={(event) => event.stopPropagation()}>
|
<td onClick={(event) => event.stopPropagation()}>
|
||||||
<input
|
<input
|
||||||
@@ -1086,7 +1337,33 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
|
|||||||
aria-label="Выбрать слот"
|
aria-label="Выбрать слот"
|
||||||
/>
|
/>
|
||||||
</td>
|
</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>
|
<td>
|
||||||
{slot.revolution} / {slot.revolutionSign === "DESC" ? "нисх" : "восх"} /{" "}
|
{slot.revolution} / {slot.revolutionSign === "DESC" ? "нисх" : "восх"} /{" "}
|
||||||
{slot.cycle}
|
{slot.cycle}
|
||||||
@@ -1094,6 +1371,11 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
|
|||||||
<td>{formatLocalDateTimeSeconds(parseLocal(slot.tn))}</td>
|
<td>{formatLocalDateTimeSeconds(parseLocal(slot.tn))}</td>
|
||||||
<td>{slotDurationSec(slot)} с</td>
|
<td>{slotDurationSec(slot)} с</td>
|
||||||
<td>{slot.roll.toFixed(1)}°</td>
|
<td>{slot.roll.toFixed(1)}°</td>
|
||||||
|
{showGain && (
|
||||||
|
<td className="tgu-slots-gain">
|
||||||
|
{booked ? "—" : `+${gainRanking?.pct.get(key) ?? 0}%`}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
<td>
|
<td>
|
||||||
{booked ? (
|
{booked ? (
|
||||||
<span className="tgu-slots-status-cell">
|
<span className="tgu-slots-status-cell">
|
||||||
@@ -1114,6 +1396,7 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
|
|||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
|
<span className="tgu-slots-status-cell">
|
||||||
<span
|
<span
|
||||||
className={
|
className={
|
||||||
slot.state === "BUSY"
|
slot.state === "BUSY"
|
||||||
@@ -1123,6 +1406,26 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
|
|||||||
>
|
>
|
||||||
{slotStateLabel(slot.state)}
|
{slotStateLabel(slot.state)}
|
||||||
</span>
|
</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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -1130,7 +1433,7 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
|
|||||||
})}
|
})}
|
||||||
{padBottom > 0 && (
|
{padBottom > 0 && (
|
||||||
<tr aria-hidden="true" style={{ height: padBottom }}>
|
<tr aria-hidden="true" style={{ height: padBottom }}>
|
||||||
<td colSpan={7} />
|
<td colSpan={colCount} />
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -1254,7 +1557,11 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
|
|||||||
<Tgu2DMapView
|
<Tgu2DMapView
|
||||||
scene={scene}
|
scene={scene}
|
||||||
layers={layers}
|
layers={layers}
|
||||||
redrawToken={hasRequest ? filteredSlots.length + bookedSlots.length : requestItems.length}
|
redrawToken={
|
||||||
|
hasRequest
|
||||||
|
? filteredSlots.length + bookedSlots.length + selectedKeys.size + (coverageOn ? 1 : 0)
|
||||||
|
: requestItems.length
|
||||||
|
}
|
||||||
enableObjectChooser
|
enableObjectChooser
|
||||||
onObjectsAtPoint={handleObjectsAtPoint}
|
onObjectsAtPoint={handleObjectsAtPoint}
|
||||||
onObjectSelect={handleObjectSelect}
|
onObjectSelect={handleObjectSelect}
|
||||||
@@ -1304,6 +1611,14 @@ function slotIsBusy(slot: Pick<SlotDTO, "state">): boolean {
|
|||||||
return slot.state === "BUSY";
|
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 {
|
function slotStateLabel(state: string): string {
|
||||||
switch (state) {
|
switch (state) {
|
||||||
case "AVAILABLE":
|
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 (booked∪selected)", () => {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Покрытие набора масок (booked∪selected): 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 { describe, expect, it } from "vitest";
|
||||||
import { chunk, selectionAfterCheck } from "./slotSelection";
|
import { chunk, selectionAfterCheck, toggleStripSelection } from "./slotSelection";
|
||||||
|
|
||||||
const keyAt = (i: number) => `k${i}`;
|
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", () => {
|
describe("chunk", () => {
|
||||||
it("бьёт массив на части заданного размера, последняя — остаток", () => {
|
it("бьёт массив на части заданного размера, последняя — остаток", () => {
|
||||||
expect(chunk([1, 2, 3, 4, 5], 2)).toEqual([[1, 2], [3, 4], [5]]);
|
expect(chunk([1, 2, 3, 4, 5], 2)).toEqual([[1, 2], [3, 4], [5]]);
|
||||||
|
|||||||
@@ -39,6 +39,29 @@ export function selectionAfterCheck(
|
|||||||
return next
|
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 Исходный массив.
|
* @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.00→0.009→0.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;
|
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). */
|
/** Слот покрытия заявки — кандидат на бронирование (slots-service: SlotDTO). */
|
||||||
export type SlotDTO = {
|
export type SlotDTO = {
|
||||||
cycle: number;
|
cycle: number;
|
||||||
@@ -29,6 +39,8 @@ export type SlotDTO = {
|
|||||||
state: SlotStatus;
|
state: SlotStatus;
|
||||||
latitude: number;
|
latitude: number;
|
||||||
longitude: number;
|
longitude: number;
|
||||||
|
/** Держатели BUSY-слота (обзор конфликтов, all=true). Пусто/нет для свободных и брони. */
|
||||||
|
holders?: SlotHolder[] | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Забронированный слот (slots-service: BookedSlotDTO) — ответ на POST booking/request. */
|
/** Забронированный слот (slots-service: BookedSlotDTO) — ответ на POST booking/request. */
|
||||||
|
|||||||
@@ -963,6 +963,81 @@
|
|||||||
gap: 0.35rem;
|
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 {
|
.tgu-slots-empty {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
@@ -1187,6 +1262,17 @@
|
|||||||
text-align: right;
|
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 {
|
.tgu-slots-table thead th {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -1219,6 +1305,52 @@
|
|||||||
background: rgba(104, 195, 189, 0.36);
|
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 {
|
.tgu-slots-badge {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 0.1rem 0.45rem;
|
padding: 0.1rem 0.45rem;
|
||||||
@@ -1243,6 +1375,29 @@
|
|||||||
color: #e98a8a;
|
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 {
|
.tgu-map-stage {
|
||||||
position: relative;
|
position: relative;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user