From 1f44c1a5bb11c6e45ebb3b6f40e7918055e87ecb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9=20=D0=A1=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D1=8C=D0=B5=D0=B2?= Date: Wed, 17 Jun 2026 09:06:25 +0300 Subject: [PATCH] =?UTF-8?q?feat(tgu-ui):=20#50=20=D1=81=D0=BB=D0=BE=D0=B9?= =?UTF-8?q?=20=D0=BF=D0=BE=D0=B4=D0=B4=D0=B5=D1=80=D0=B6=D0=BA=D0=B8=20?= =?UTF-8?q?=D1=80=D0=B5=D1=88=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=BF=D0=BE=20?= =?UTF-8?q?=D1=81=D0=BB=D0=BE=D1=82=D0=B0=D0=BC=20+=20=D0=B2=D1=8B=D0=B1?= =?UTF-8?q?=D0=BE=D1=80=20=D0=BF=D0=BE=D0=BB=D0=BE=D1=81=D0=B0=D0=BC=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Поверх ручного бронирования — информационный слой (достаточность судит оператор). 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-выбора) сбрасывается при смене видимого набора (фильтры/окно суток), не только сортировки. --- .../pcp-tgu-ui-service/src/api/slotsApi.ts | 20 - .../src/features/tgu-map-2d/Tgu2DMapView.tsx | 8 +- .../tgu-map-2d/canvas/drawCoverage.ts | 82 ++++ .../src/features/tgu-map-2d/model/mapTypes.ts | 6 + .../src/features/tgu-slots/CoveragePanel.tsx | 73 ++++ .../src/features/tgu-slots/SlotFilterBar.tsx | 38 ++ .../src/features/tgu-slots/TguSlotsTab.tsx | 401 ++++++++++++++++-- .../src/features/tgu-slots/coverage.test.ts | 201 +++++++++ .../src/features/tgu-slots/coverage.ts | 264 ++++++++++++ .../features/tgu-slots/slotSelection.test.ts | 25 +- .../src/features/tgu-slots/slotSelection.ts | 23 + .../src/features/tgu-slots/slotStrips.test.ts | 122 ++++++ .../src/features/tgu-slots/slotStrips.ts | 108 +++++ .../pcp-tgu-ui-service/src/model/slotTypes.ts | 12 + .../src/styles/tgu-planning.css | 155 +++++++ 15 files changed, 1473 insertions(+), 65 deletions(-) create mode 100644 services/pcp-tgu-ui-service/src/features/tgu-map-2d/canvas/drawCoverage.ts create mode 100644 services/pcp-tgu-ui-service/src/features/tgu-slots/CoveragePanel.tsx create mode 100644 services/pcp-tgu-ui-service/src/features/tgu-slots/coverage.test.ts create mode 100644 services/pcp-tgu-ui-service/src/features/tgu-slots/coverage.ts create mode 100644 services/pcp-tgu-ui-service/src/features/tgu-slots/slotStrips.test.ts create mode 100644 services/pcp-tgu-ui-service/src/features/tgu-slots/slotStrips.ts diff --git a/services/pcp-tgu-ui-service/src/api/slotsApi.ts b/services/pcp-tgu-ui-service/src/api/slotsApi.ts index cd6df86..f96dc5e 100644 --- a/services/pcp-tgu-ui-service/src/api/slotsApi.ts +++ b/services/pcp-tgu-ui-service/src/api/slotsApi.ts @@ -85,26 +85,6 @@ export async function bookRequestSlots( return response.json() as Promise } -/** - * Снимает все брони заявки (slots-service: DELETE /api/slots/booking/request). - * @param requestId Идентификатор заявки. - * - * @returns Сколько броней снято. - */ -export async function cancelRequestBooking(requestId: string): Promise { - const response = await apiFetch( - `${BASE}/booking/request?requestId=${encodeURIComponent(requestId)}`, - { method: 'DELETE', headers: { Accept: 'application/json' } }, - ) - - if (!response.ok) { - throw new Error(await readErrorMessage(response, 'Не удалось снять бронь')) - } - - const value = Number(await response.text()) - return Number.isFinite(value) ? value : 0 -} - /** * Снимает выбранные брони заявки по списку bookedSlotId (slots-service: DELETE * /api/slots/booking/batch). Один запрос = один чанк; крупный набор чанкует вызывающий diff --git a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/Tgu2DMapView.tsx b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/Tgu2DMapView.tsx index 0cfbf2d..c6be741 100644 --- a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/Tgu2DMapView.tsx +++ b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/Tgu2DMapView.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { drawBaseMap } from "./canvas/drawBaseMap"; import { drawComplexPlan } from "./canvas/drawComplexPlan"; +import { drawCoverage } from "./canvas/drawCoverage"; import { drawHeatmap } from "./canvas/drawHeatmap"; import { drawPlanWorks } from "./canvas/drawPlanWorks"; import { drawRequests } from "./canvas/drawRequests"; @@ -213,6 +214,11 @@ export function Tgu2DMapView({ if (layers.complexPlan) { drawComplexPlan(context, visiblePolygons.filter((polygon) => polygon.layer === "complexPlan"), projection, size); } + // След покрытия (вкладка «Слоты») — отдельный пасс поверх полос слотов; кольца приходят + // через scene.coverageRings (тумблер на стороне TguSlotsTab очищает их, когда слой выключен). + if (scene.coverageRings && scene.coverageRings.length > 0) { + drawCoverage(context, scene.coverageRings, projection, size); + } if (layers.planWorks) { drawPlanWorks(context, visiblePolygons.filter((polygon) => polygon.layer === "planWorks"), projection, size); } @@ -225,7 +231,7 @@ export function Tgu2DMapView({ }); return () => window.cancelAnimationFrame(frameId); - }, [handleTileLoad, layers, projection, redrawToken, scene.lines, scene.markers, size, tileVersion, view, visiblePolygons]); + }, [handleTileLoad, layers, projection, redrawToken, scene.coverageRings, scene.lines, scene.markers, size, tileVersion, view, visiblePolygons]); const screenPointFromEvent = useCallback((event: React.PointerEvent | React.MouseEvent): ScreenPoint => { const bounds = wrapperRef.current?.getBoundingClientRect(); diff --git a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/canvas/drawCoverage.ts b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/canvas/drawCoverage.ts new file mode 100644 index 0000000..6d50517 --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/canvas/drawCoverage.ts @@ -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(); +} diff --git a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/model/mapTypes.ts b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/model/mapTypes.ts index 82dd7b4..418af8a 100644 --- a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/model/mapTypes.ts +++ b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/model/mapTypes.ts @@ -76,5 +76,11 @@ export type Tgu2DMapScene = { polygons: MapPolygon[]; lines: MapLine[]; markers: MapMarker[]; + /** + * Кольца следа покрытия (вкладка «Слоты») — рисуются отдельным пассом drawCoverage заливкой, + * НЕ как MapPolygon: вне polygons/spatialIndex, поэтому не участвуют в hit-тесте/z-порядке. + * Пусто/не задано — слой не рисуется (тумблер выключен либо нечего показывать). + */ + coverageRings?: GeoPoint[][]; }; diff --git a/services/pcp-tgu-ui-service/src/features/tgu-slots/CoveragePanel.tsx b/services/pcp-tgu-ui-service/src/features/tgu-slots/CoveragePanel.tsx new file mode 100644 index 0000000..465b609 --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-slots/CoveragePanel.tsx @@ -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 ( +
+
+ Покрытие заявки + +
+ + {!available ? ( +
Покрытие недоступно — у заявки нет геометрии.
+ ) : ( + <> +
+
+ {hasSelection && gainPct > 0 && ( +
+ )} +
+
+ забронировано {bookedPct}% + {hasSelection && ( + · с выбором {withPct}% + )} + {lowPrecision && ( + + {" "} + · низкая точность + + )} +
+ + )} +
+ ); +} diff --git a/services/pcp-tgu-ui-service/src/features/tgu-slots/SlotFilterBar.tsx b/services/pcp-tgu-ui-service/src/features/tgu-slots/SlotFilterBar.tsx index a56e27b..304ab0b 100644 --- a/services/pcp-tgu-ui-service/src/features/tgu-slots/SlotFilterBar.tsx +++ b/services/pcp-tgu-ui-service/src/features/tgu-slots/SlotFilterBar.tsx @@ -1,6 +1,9 @@ import type { RevolutionSign } from "../../model/slotTypes"; import type { SlotStatusKey } from "./slotFilters"; +/** Сортировка таблицы слотов: время (порядок расчёта) · вклад (ранжирование по покрытию) · крен. */ +export type SlotSortMode = "time" | "gain" | "roll"; + type SlotFilterBarProps = { /** Счётчики статусов по окну суток (до статус-фильтра) — стабильны при переключении чипов. */ statusCounts: Record; @@ -12,6 +15,11 @@ type SlotFilterBarProps = { onToggleSat: (id: number) => void; branchFilter: ReadonlySet; onToggleBranch: (branch: RevolutionSign) => void; + /** Текущая сортировка таблицы (одиночный выбор). */ + sortMode: SlotSortMode; + onSortMode: (mode: SlotSortMode) => void; + /** Доступна ли сортировка «по вкладу» (есть геометрия заявки) — иначе чип заблокирован. */ + gainAvailable: boolean; /** Фильтры в дефолте (всё видно) — тогда «Сбросить» не показываем. */ isDefault: boolean; onReset: () => void; @@ -23,6 +31,12 @@ const STATUS_META: { key: SlotStatusKey; label: string; variant: string }[] = [ { key: "occupied", label: "недоступен", variant: " is-occupied" }, ]; +const SORT_META: { key: SlotSortMode; label: string }[] = [ + { key: "time", label: "время" }, + { key: "gain", label: "вклад" }, + { key: "roll", label: "крен" }, +]; + /** * Панель фильтров отображения слотов (видимость). Сужает И таблицу, И карту через единый * filteredSlots. Стоит над строкой выбора: сначала сужаешь, потом отбираешь. Чипы — @@ -38,6 +52,9 @@ export function SlotFilterBar({ onToggleSat, branchFilter, onToggleBranch, + sortMode, + onSortMode, + gainAvailable, isDefault, onReset, }: SlotFilterBarProps) { @@ -99,6 +116,27 @@ export function SlotFilterBar({ })}
+
+ Сортировка + {SORT_META.map(({ key, label }) => { + const active = sortMode === key; + const disabled = key === "gain" && !gainAvailable; + return ( + + ); + })} +
+ {!isDefault && ( + )} + + {slot.revolution} / {slot.revolutionSign === "DESC" ? "нисх" : "восх"} /{" "} {slot.cycle} @@ -1094,6 +1371,11 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) { {formatLocalDateTimeSeconds(parseLocal(slot.tn))} {slotDurationSec(slot)} с {slot.roll.toFixed(1)}° + {showGain && ( + + {booked ? "—" : `+${gainRanking?.pct.get(key) ?? 0}%`} + + )} {booked ? ( @@ -1114,14 +1396,35 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) { ) : ( - - {slotStateLabel(slot.state)} + + + {slotStateLabel(slot.state)} + + {slot.state === "BUSY" && + slot.holders?.map((holder) => ( + + {holder.name ?? holder.requestId} + {holder.importance != null && ( + + {holder.importance.toFixed(2)} + + )} + + ))} )} @@ -1130,7 +1433,7 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) { })} {padBottom > 0 && ( - + )} @@ -1254,7 +1557,11 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) { ): boolean { return slot.state === "BUSY"; } +/** Детерминированный оттенок (0..359) полосы по её StripId — для приглушённого рельса слева, + * чтобы соседние полосы визуально различались. Чистый хэш строки, без состояния. */ +function stripHue(stripId: string): number { + let h = 0; + for (let i = 0; i < stripId.length; i++) h = (h * 31 + stripId.charCodeAt(i)) >>> 0; + return h % 360; +} + function slotStateLabel(state: string): string { switch (state) { case "AVAILABLE": diff --git a/services/pcp-tgu-ui-service/src/features/tgu-slots/coverage.test.ts b/services/pcp-tgu-ui-service/src/features/tgu-slots/coverage.test.ts new file mode 100644 index 0000000..6fbc0cc --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-slots/coverage.test.ts @@ -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); + }); +}); diff --git a/services/pcp-tgu-ui-service/src/features/tgu-slots/coverage.ts b/services/pcp-tgu-ui-service/src/features/tgu-slots/coverage.ts new file mode 100644 index 0000000..3076a56 --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-slots/coverage.ts @@ -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; +} diff --git a/services/pcp-tgu-ui-service/src/features/tgu-slots/slotSelection.test.ts b/services/pcp-tgu-ui-service/src/features/tgu-slots/slotSelection.test.ts index 4437d15..0f0ca8f 100644 --- a/services/pcp-tgu-ui-service/src/features/tgu-slots/slotSelection.test.ts +++ b/services/pcp-tgu-ui-service/src/features/tgu-slots/slotSelection.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { chunk, selectionAfterCheck } from "./slotSelection"; +import { chunk, selectionAfterCheck, toggleStripSelection } from "./slotSelection"; const keyAt = (i: number) => `k${i}`; @@ -42,6 +42,29 @@ describe("selectionAfterCheck", () => { }); }); +describe("toggleStripSelection", () => { + it("добавляет все члены полосы, если выбраны не все", () => { + const next = toggleStripSelection(new Set(["a"]), ["a", "b", "c"]); + expect([...next].sort()).toEqual(["a", "b", "c"]); + }); + + it("снимает все члены, если все уже выбраны", () => { + const next = toggleStripSelection(new Set(["a", "b", "c", "x"]), ["a", "b", "c"]); + expect([...next]).toEqual(["x"]); + }); + + it("пустой набор членов — выбор без изменений", () => { + const next = toggleStripSelection(new Set(["x"]), []); + expect([...next]).toEqual(["x"]); + }); + + it("не мутирует исходное множество", () => { + const current = new Set(["a"]); + toggleStripSelection(current, ["a", "b"]); + expect([...current]).toEqual(["a"]); + }); +}); + describe("chunk", () => { it("бьёт массив на части заданного размера, последняя — остаток", () => { expect(chunk([1, 2, 3, 4, 5], 2)).toEqual([[1, 2], [3, 4], [5]]); diff --git a/services/pcp-tgu-ui-service/src/features/tgu-slots/slotSelection.ts b/services/pcp-tgu-ui-service/src/features/tgu-slots/slotSelection.ts index 8345d71..a3d62c6 100644 --- a/services/pcp-tgu-ui-service/src/features/tgu-slots/slotSelection.ts +++ b/services/pcp-tgu-ui-service/src/features/tgu-slots/slotSelection.ts @@ -39,6 +39,29 @@ export function selectionAfterCheck( return next } +/** + * Новое множество выбора после клика по чипу «полоса». Поведение зеркалит «выбрать видимые», + * но в рамках одной полосы: если ВСЕ её видимые выбираемые члены уже выбраны — снимаем их, + * иначе добавляем все. Скрытые (отфильтрованные) и BUSY-члены сюда не передаются — выбираем + * только то, что видно и можно бронировать (доверие счётчику «Выбрано»). Вход не мутируется. + * + * @param current Текущее множество выбранных ключей. + * @param memberKeys Ключи видимых выбираемых членов полосы. + */ +export function toggleStripSelection( + current: ReadonlySet, + memberKeys: readonly string[], +): Set { + 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 Исходный массив. diff --git a/services/pcp-tgu-ui-service/src/features/tgu-slots/slotStrips.test.ts b/services/pcp-tgu-ui-service/src/features/tgu-slots/slotStrips.test.ts new file mode 100644 index 0000000..ebaab99 --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-slots/slotStrips.test.ts @@ -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[][] { + const byStrip = new Map(); + 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"); + }); +}); diff --git a/services/pcp-tgu-ui-service/src/features/tgu-slots/slotStrips.ts b/services/pcp-tgu-ui-service/src/features/tgu-slots/slotStrips.ts new file mode 100644 index 0000000..c69474c --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-slots/slotStrips.ts @@ -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; + +/** Сравнение чисел с детерминированным порядком; 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. Полоса — максимальный 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( + slots: readonly T[], + keyOf: (slot: T) => string, +): Map { + const result = new Map(); + 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; +} diff --git a/services/pcp-tgu-ui-service/src/model/slotTypes.ts b/services/pcp-tgu-ui-service/src/model/slotTypes.ts index 6e58b00..cefcd3a 100644 --- a/services/pcp-tgu-ui-service/src/model/slotTypes.ts +++ b/services/pcp-tgu-ui-service/src/model/slotTypes.ts @@ -14,6 +14,16 @@ export type SlotCoverageStrategy = "GREEDY" | "CONTINUOUS"; * бронью на этом проходе КА: показываем для обзора, но забронировать нельзя. */ export type SlotStatus = "AVAILABLE" | "BOOKED" | "BUSY" | "PROCESSED" | string; +/** + * Держатель занятого (BUSY) слота — заявка, чья бронь блокирует слот (slots-service: SlotHolderDTO). + * name/importance могут отсутствовать, если request-service недоступен (деградация — показываем id). + */ +export type SlotHolder = { + requestId: string; + name?: string | null; + importance?: number | null; +}; + /** Слот покрытия заявки — кандидат на бронирование (slots-service: SlotDTO). */ export type SlotDTO = { cycle: number; @@ -29,6 +39,8 @@ export type SlotDTO = { state: SlotStatus; latitude: number; longitude: number; + /** Держатели BUSY-слота (обзор конфликтов, all=true). Пусто/нет для свободных и брони. */ + holders?: SlotHolder[] | null; }; /** Забронированный слот (slots-service: BookedSlotDTO) — ответ на POST booking/request. */ diff --git a/services/pcp-tgu-ui-service/src/styles/tgu-planning.css b/services/pcp-tgu-ui-service/src/styles/tgu-planning.css index 255ff5e..a5a0b7e 100644 --- a/services/pcp-tgu-ui-service/src/styles/tgu-planning.css +++ b/services/pcp-tgu-ui-service/src/styles/tgu-planning.css @@ -963,6 +963,81 @@ gap: 0.35rem; } +/* Панель покрытия над списком (D-D1). Цвет = слой coverage на карте (violet) — единый язык «покрыто». */ +.tgu-coverage { + padding: 0.45rem 0.55rem; + border-bottom: 1px solid var(--line); + background: var(--bg-panel); + font-size: 0.78rem; +} + +.tgu-coverage__head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + margin-bottom: 0.4rem; +} + +.tgu-coverage__title { + color: var(--text-muted); + font-weight: 600; +} + +.tgu-coverage__toggle { + display: inline-flex; + align-items: center; + gap: 0.3rem; + color: var(--text-dim); + cursor: pointer; + user-select: none; +} + +.tgu-coverage__toggle input { + accent-color: rgb(167, 139, 219); +} + +.tgu-coverage__bar { + position: relative; + display: flex; + height: 0.5rem; + border-radius: 999px; + overflow: hidden; + background: rgba(167, 139, 219, 0.12); + border: 1px solid var(--line); +} + +.tgu-coverage__seg--booked { + background: rgba(167, 139, 219, 0.65); +} + +.tgu-coverage__seg--sel { + background: rgba(167, 139, 219, 0.32); +} + +.tgu-coverage__legend { + margin-top: 0.3rem; + color: var(--text-dim); +} + +.tgu-coverage__booked { + color: rgb(186, 164, 230); + font-weight: 600; +} + +.tgu-coverage__sel { + color: var(--text-muted); +} + +.tgu-coverage__low { + color: var(--text-dim); + font-style: italic; +} + +.tgu-coverage__unavailable { + color: var(--text-dim); +} + .tgu-slots-empty { padding: 1rem; color: var(--text-dim); @@ -1187,6 +1262,17 @@ text-align: right; } +/* Колонка «Вклад» (+N%) — по правому краю, цвет совпадает со слоем покрытия (violet). */ +.tgu-slots-gain-col, +.tgu-slots-gain { + text-align: right; +} + +.tgu-slots-gain { + color: rgb(167, 139, 219); + font-weight: 600; +} + .tgu-slots-table thead th { position: sticky; top: 0; @@ -1219,6 +1305,52 @@ background: rgba(104, 195, 189, 0.36); } +/* Ячейка «КА»: номер КА + чип выбора полосы рядом. */ +.tgu-slots-sat { + display: inline-flex; + align-items: center; + gap: 0.4rem; +} + +/* Чип «полоса ×N» — выбрать всю полосу (слоты одного крена подряд) одним кликом. + Нейтральный по умолчанию; .is-on — когда вся полоса уже выбрана. */ +.tgu-slots-strip-chip { + display: inline-flex; + align-items: center; + padding: 0.02rem 0.4rem; + border-radius: 999px; + border: 1px solid var(--line); + background: #101214; + color: var(--text-muted); + font-size: 0.68rem; + line-height: 1.4; + cursor: pointer; + white-space: nowrap; +} + +.tgu-slots-strip-chip:hover { + border-color: var(--accent); + color: var(--text); +} + +.tgu-slots-strip-chip.is-on { + border-color: rgba(104, 195, 189, 0.58); + background: rgba(104, 195, 189, 0.16); + color: var(--accent-strong); +} + +/* Рельс полосы (сортировка «время», члены идут подряд): приглушённая цветная полоса слева, + оттенок детерминирован StripId (--strip-hue) — соседние полосы визуально различаются. + У активной строки рельс перекрывается акцентным (is-active), это норма. */ +.tgu-slots-table tbody tr.tgu-slots-row--strip { + box-shadow: inset 3px 0 0 hsl(var(--strip-hue, 0) 45% 55% / 0.55); +} + +/* Подсветка всех членов полосы под курсором (актуально в «крен»/«вклад», где они не подряд). */ +.tgu-slots-table tbody tr.is-strip-hover { + background: rgba(104, 195, 189, 0.12); +} + .tgu-slots-badge { display: inline-block; padding: 0.1rem 0.45rem; @@ -1243,6 +1375,29 @@ color: #e98a8a; } +/* Держатель BUSY-слота (M3): кто блокирует слот. Тот же приглушённо-красный регистр, что и + бейдж «недоступен» (--occupied), но мягче — это уточнение к статусу, не отдельный сигнал. */ +.tgu-slots-holder { + display: inline-flex; + align-items: center; + gap: 0.3rem; + padding: 0.1rem 0.4rem; + border-radius: 999px; + border: 1px solid rgba(214, 69, 69, 0.32); + background: rgba(214, 69, 69, 0.08); + color: #e98a8a; + font-size: 0.7rem; + max-width: 12rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tgu-slots-holder__imp { + color: var(--text-muted); + font-variant-numeric: tabular-nums; +} + .tgu-map-stage { position: relative; min-width: 0;