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

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

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

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

Смежный баг: anchorIndexRef (якорь Shift-выбора) сбрасывается при смене видимого
набора (фильтры/окно суток), не только сортировки.
This commit is contained in:
Дмитрий Соловьев
2026-06-17 09:06:25 +03:00
parent 20d9eb9579
commit 1f44c1a5bb
15 changed files with 1473 additions and 65 deletions
@@ -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>
);
}