feat(tgu-ui): выбор полосы слотов мышью на карте (Shift+drag)

This commit is contained in:
Дмитрий Соловьев
2026-06-17 11:31:21 +03:00
parent 61a2c92d28
commit 653c930dd6
5 changed files with 239 additions and 4 deletions
+12
View File
@@ -120,6 +120,18 @@
возможна подстройка по фактическим `roll`/`tn`. возможна подстройка по фактическим `roll`/`tn`.
- Бэкенд/DTO не тронуты. Правило выполнимости (манёвренность КА) по-прежнему it.2. - Бэкенд/DTO не тронуты. Правило выполнимости (манёвренность КА) по-прежнему it.2.
Доработки (2026-06-17):
- **Shift+клик ближе к якорю «сжимает» выделение.** `selectStripRange` теперь задаёт выбор полосы
РОВНО диапазоном [from..to]: члены полосы вне диапазона снимаются, выбор в других полосах сохраняется.
Якорь при Shift+клике не двигается (остаётся первым кликом) — можно тянуть конец туда-обратно.
- **Жест выбора полосы мышью на карте (Shift+drag).** Слоты на карте уже кликабельные полигоны
(`slot:${key}` в spatial-index). Карта на жесте отдаёт наверх ключи слотов под курсором
(`onStripAnchor`/`onStripDrag`/`onStripCommit`), а полосу/диапазон считает `TguSlotsTab`
(`pickStripAnchor` — самый ранний по времени слот под курсором = начало; `pickStripEnd` — самый
поздний член ТОЙ ЖЕ полосы под курсором = конец; за границу полосы выделение не уходит). Обычный
drag = панорама, Shift+drag по слоту = выбор полосы (без отдельной кнопки-режима). Подсветка
диапазона живая — через `markedObjectIds` (SVG-оверлей `is-marked`), отдельный слой превью не нужен.
## Решение — Iteration 2 (проверка выполнимости, отдельно) ## Решение — Iteration 2 (проверка выполнимости, отдельно)
- Бэкенд: read-only `POST /api/slots/booking/validate` (набор слотов → violations), - Бэкенд: read-only `POST /api/slots/booking/validate` (набор слотов → violations),
переиспользует по-КА `mmi`/`maxSurveyDuration`/`maxChainLength`/`ROLL_EPSILON` из каталога. переиспользует по-КА `mmi`/`maxSurveyDuration`/`maxChainLength`/`ROLL_EPSILON` из каталога.
@@ -38,6 +38,16 @@ type Tgu2DMapViewProps = {
enableObjectChooser?: boolean; enableObjectChooser?: boolean;
/** Несколько объектов под точкой клика — список передаётся наверх (выбор делает оператор справа). */ /** Несколько объектов под точкой клика — список передаётся наверх (выбор делает оператор справа). */
onObjectsAtPoint?: (selections: Tgu2DMapSelection[]) => void; onObjectsAtPoint?: (selections: Tgu2DMapSelection[]) => void;
/**
* Жест «выбор полосы» мышью: Shift+перетаскивание по слотам. Карта остаётся «глупой» — отдаёт
* наверх ключи слотов под курсором (id вида `slot:${key}`), а группировку в полосу и диапазон
* считает владелец выбора (TguSlotsTab). onStripAnchor — Shift+нажатие на слот (начало жеста),
* onStripDrag — движение (текущие слоты под курсором), onStripCommit — отпускание (конец жеста).
* Если жест не задан или Shift зажат над пустым местом — работает обычная панорама.
*/
onStripAnchor?: (slotKeysUnderCursor: string[]) => void;
onStripDrag?: (slotKeysUnderCursor: string[]) => void;
onStripCommit?: () => void;
pickMode?: boolean; pickMode?: boolean;
onPickPoint?: (point: { lat: number; lon: number }) => void; onPickPoint?: (point: { lat: number; lon: number }) => void;
targets?: Tgu2DMapTarget[]; targets?: Tgu2DMapTarget[];
@@ -92,6 +102,9 @@ export function Tgu2DMapView({
focusToken, focusToken,
enableObjectChooser = false, enableObjectChooser = false,
onObjectsAtPoint, onObjectsAtPoint,
onStripAnchor,
onStripDrag,
onStripCommit,
pickMode = false, pickMode = false,
onPickPoint, onPickPoint,
targets = [], targets = [],
@@ -106,6 +119,8 @@ export function Tgu2DMapView({
const canvasRef = useRef<HTMLCanvasElement | null>(null); const canvasRef = useRef<HTMLCanvasElement | null>(null);
const dragRef = useRef<DragState | undefined>(undefined); const dragRef = useRef<DragState | undefined>(undefined);
const hasDraggedRef = useRef(false); const hasDraggedRef = useRef(false);
// Активен жест «выбор полосы» (Shift+drag по слотам): пока true — панорама и обычный клик подавлены.
const stripGestureRef = useRef(false);
const hoverRef = useRef<MapPolygon | undefined>(undefined); const hoverRef = useRef<MapPolygon | undefined>(undefined);
const hoverMarkerRef = useRef<MapMarker | undefined>(undefined); const hoverMarkerRef = useRef<MapMarker | undefined>(undefined);
const selectedMarkerRef = useRef<MapMarker | undefined>(undefined); const selectedMarkerRef = useRef<MapMarker | undefined>(undefined);
@@ -268,12 +283,39 @@ export function Tgu2DMapView({
[activeMarkers, polygonIndex, projection, screenPointFromEvent] [activeMarkers, polygonIndex, projection, screenPointFromEvent]
); );
// Ключи слотов (id `slot:${key}`) под экранной точкой — для жеста выбора полосы. Брони вне окна
// (`booked:${key}`) и прочие слои не берём: полосой выбираем только кандидатов-слоты.
const slotKeysAtPoint = useCallback(
(screenPoint: ScreenPoint): string[] => {
const geoPoint = projection.unproject(screenPoint);
const keys: string[] = [];
for (const polygon of hitTestAllPolygons(polygonIndex, geoPoint)) {
if (polygon.id.startsWith("slot:")) keys.push(polygon.id.slice("slot:".length));
}
return keys;
},
[polygonIndex, projection]
);
const onPointerDown = (event: React.PointerEvent) => { const onPointerDown = (event: React.PointerEvent) => {
// Нажатие на кнопки управления картой (зум, линейка, «Сброс») не должно запускать перетаскивание // Нажатие на кнопки управления картой (зум, линейка, «Сброс») не должно запускать перетаскивание
// и захват указателя: setPointerCapture на контейнере иначе перехватывает жест, и кнопки внутри // и захват указателя: setPointerCapture на контейнере иначе перехватывает жест, и кнопки внутри
// карты перестают «кликаться» (родная причина того, что +/−/□ и «Сброс» не срабатывали). // карты перестают «кликаться» (родная причина того, что +/−/□ и «Сброс» не срабатывали).
if ((event.target as HTMLElement).closest("button")) return; if ((event.target as HTMLElement).closest("button")) return;
const screenPoint = screenPointFromEvent(event); const screenPoint = screenPointFromEvent(event);
// Shift+нажатие на слот → жест выбора полосы (вместо панорамы). Над пустым местом Shift не мешает.
if (event.shiftKey && onStripAnchor) {
const keys = slotKeysAtPoint(screenPoint);
if (keys.length > 0) {
event.preventDefault(); // не даём Shift+drag начать выделение текста / перетаскивание
stripGestureRef.current = true;
hasDraggedRef.current = false;
dragRef.current = undefined;
wrapperRef.current?.setPointerCapture(event.pointerId);
onStripAnchor(keys);
return;
}
}
hasDraggedRef.current = false; hasDraggedRef.current = false;
dragRef.current = { dragRef.current = {
startX: screenPoint.x, startX: screenPoint.x,
@@ -286,6 +328,14 @@ export function Tgu2DMapView({
const DRAG_THRESHOLD = 4; const DRAG_THRESHOLD = 4;
const onPointerMove = (event: React.PointerEvent) => { const onPointerMove = (event: React.PointerEvent) => {
if (stripGestureRef.current) {
// Жест выбора полосы: тянем — отдаём слоты под курсором наверх (там клампится по границе полосы).
hasDraggedRef.current = true;
const screenPoint = screenPointFromEvent(event);
setCursorGeo(projection.unproject(screenPoint));
onStripDrag?.(slotKeysAtPoint(screenPoint));
return;
}
const drag = dragRef.current; const drag = dragRef.current;
if (!drag) { if (!drag) {
updateHover(event); updateHover(event);
@@ -316,6 +366,14 @@ export function Tgu2DMapView({
}; };
const onPointerUp = (event: React.PointerEvent) => { const onPointerUp = (event: React.PointerEvent) => {
if (stripGestureRef.current) {
stripGestureRef.current = false;
// hasDraggedRef оставляем true — иначе следующий onClick выбрал бы слот «сверху» жеста.
hasDraggedRef.current = true;
wrapperRef.current?.releasePointerCapture(event.pointerId);
onStripCommit?.();
return;
}
dragRef.current = undefined; dragRef.current = undefined;
wrapperRef.current?.releasePointerCapture(event.pointerId); wrapperRef.current?.releasePointerCapture(event.pointerId);
}; };
@@ -39,7 +39,7 @@ import {
} from "./coverage"; } from "./coverage";
import { applySlotFilters, slotStatusKey, type SlotStatusKey } from "./slotFilters"; import { applySlotFilters, slotStatusKey, type SlotStatusKey } from "./slotFilters";
import { selectionAfterCheck, selectStripRange } from "./slotSelection"; import { selectionAfterCheck, selectStripRange } from "./slotSelection";
import { groupStrips } from "./slotStrips"; import { groupStrips, pickStripAnchor, pickStripEnd, type PolosaId } from "./slotStrips";
import { runChunked } from "./runChunked"; import { runChunked } from "./runChunked";
import { ChunkProgress } from "./ChunkProgress"; import { ChunkProgress } from "./ChunkProgress";
@@ -298,10 +298,61 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
// времени (виток не граница). idByKey — id полосы по ключу слота; membersOf — члены полосы по времени. // времени (виток не граница). idByKey — id полосы по ключу слота; membersOf — члены полосы по времени.
const polosa = useMemo(() => groupStrips(displaySlots, slotKey), [displaySlots]); const polosa = useMemo(() => groupStrips(displaySlots, slotKey), [displaySlots]);
// Состояние жеста «выбор полосы мышью на карте» (Shift+drag). Держим в ref: правки идут на каждом
// движении указателя, перерисовка не нужна. members — члены полосы якоря без BUSY (их не бронируем).
const mapStripRef = useRef<{ anchorKey: string; anchorId: PolosaId; members: string[] } | null>(null);
// Shift+нажатие по слотам на карте: якорь полосы = самый ранний по времени выбираемый слот под
// курсором. Выбор этой полосы сбрасываем к одному якорю (далее drag растянет), как и в таблице.
const handleStripAnchor = useCallback(
(keys: string[]) => {
const tnMsOf = (key: string) => {
const slot = slotByKey.get(key);
return slot ? parseLocal(slot.tn) : Number.NaN;
};
const selectable = keys.filter((key) => {
const slot = slotByKey.get(key);
return slot && !slotIsBusy(slot);
});
const anchor = pickStripAnchor(selectable, tnMsOf);
const anchorId = anchor ? polosa.idByKey.get(anchor) : undefined;
if (!anchor || !anchorId) {
mapStripRef.current = null;
return;
}
const members = (polosa.membersOf.get(anchorId) ?? []).filter((key) => {
const slot = slotByKey.get(key);
return !(slot && slotIsBusy(slot));
});
mapStripRef.current = { anchorKey: anchor, anchorId, members };
setAnchorKey(anchor);
setSelectedKeys((current) => selectStripRange(current, members, anchor, anchor));
},
[polosa, slotByKey]
);
// Движение указателя в жесте: конец полосы = самый поздний по времени член ТОЙ ЖЕ полосы под
// курсором (граница полосы соблюдается). Никого из полосы под курсором — оставляем прежний конец.
const handleStripDrag = useCallback(
(keys: string[]) => {
const gesture = mapStripRef.current;
if (!gesture) return;
const end = pickStripEnd(polosa, gesture.anchorId, keys);
if (!end) return;
setSelectedKeys((current) => selectStripRange(current, gesture.members, gesture.anchorKey, end));
},
[polosa]
);
const handleStripCommit = useCallback(() => {
mapStripRef.current = null;
}, []);
// Якорь жеста привязан к ВИДИМОМУ набору: смена фильтров/окна суток/брони делает прежний якорь // Якорь жеста привязан к ВИДИМОМУ набору: смена фильтров/окна суток/брони делает прежний якорь
// неактуальным → сбрасываем (как при смене сортировки), иначе Shift выделил бы не ту полосу. // неактуальным → сбрасываем (как при смене сортировки), иначе Shift выделил бы не ту полосу.
useEffect(() => { useEffect(() => {
setAnchorKey(null); setAnchorKey(null);
mapStripRef.current = null;
}, [filteredSlots]); }, [filteredSlots]);
// Загрузка заявок интервала (на карту); вызывается на входе, при смене диапазона и из фильтра. // Загрузка заявок интервала (на карту); вызывается на входе, при смене диапазона и из фильтра.
@@ -1205,8 +1256,8 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
</span> </span>
)} )}
</span> </span>
<span className="tgu-slots-selbar__sub" title="Кликните первый слот, затем Shift+клик по последнему — выделится полоса между ними. Выйти за полосу (другой КА/крен/разрыв по времени) нельзя."> <span className="tgu-slots-selbar__sub" title="Таблица: кликните первый слот, затем Shift+клик по последнему — выделится полоса между ними. На карте: Shift+зажать на слоте и протянуть мышью вдоль полосы. Выйти за полосу (другой КА/крен/разрыв по времени) нельзя.">
Полоса: клик + Shift+клик Полоса: клик+Shift в таблице · Shift+тянуть на карте
</span> </span>
<div className="tgu-slots-selbar__actions"> <div className="tgu-slots-selbar__actions">
<button <button
@@ -1533,6 +1584,9 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
enableObjectChooser enableObjectChooser
onObjectsAtPoint={handleObjectsAtPoint} onObjectsAtPoint={handleObjectsAtPoint}
onObjectSelect={handleObjectSelect} onObjectSelect={handleObjectSelect}
onStripAnchor={hasRequest ? handleStripAnchor : undefined}
onStripDrag={hasRequest ? handleStripDrag : undefined}
onStripCommit={hasRequest ? handleStripCommit : undefined}
selectedObjectId={hasRequest ? activeSlotId : activeHitId} selectedObjectId={hasRequest ? activeSlotId : activeHitId}
markedObjectIds={hasRequest ? markedSlotIds : undefined} markedObjectIds={hasRequest ? markedSlotIds : undefined}
focusPoint={focusPoint} focusPoint={focusPoint}
@@ -1,5 +1,11 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { ROLL_EPSILON, groupStrips, type StripGrouping } from "./slotStrips"; import {
ROLL_EPSILON,
groupStrips,
pickStripAnchor,
pickStripEnd,
type StripGrouping,
} from "./slotStrips";
/** Минимальный слот для группировки. tn задаём как локальную ISO-строку (как отдаёт бэкенд). */ /** Минимальный слот для группировки. tn задаём как локальную ISO-строку (как отдаёт бэкенд). */
type S = { type S = {
@@ -127,3 +133,51 @@ describe("groupStrips", () => {
expect(g.idByKey.get("1:2:1")).toBe("polosa:1:1:1"); expect(g.idByKey.get("1:2:1")).toBe("polosa:1:1:1");
}); });
}); });
describe("pickStripAnchor", () => {
const tn = (m: Record<string, number>) => (key: string) => m[key] ?? Number.NaN;
it("берёт самый ранний по времени слот под курсором", () => {
const got = pickStripAnchor(["b", "a", "c"], tn({ a: 200, b: 100, c: 300 }));
expect(got).toBe("b");
});
it("равное время (веер кренов в один момент) → тай-брейк по ключу", () => {
const got = pickStripAnchor(["b", "a"], tn({ a: 100, b: 100 }));
expect(got).toBe("a");
});
it("слоты без валидного времени игнорируются", () => {
const got = pickStripAnchor(["bad", "x"], tn({ x: 500 }));
expect(got).toBe("x");
});
it("пустой / полностью невалидный вход → null", () => {
expect(pickStripAnchor([], tn({}))).toBeNull();
expect(pickStripAnchor(["bad"], tn({}))).toBeNull();
});
});
describe("pickStripEnd", () => {
// Полоса 1:1:1 = [1:1:1, 1:2:1, 1:3:1] по времени; другой КА 2 — отдельная полоса.
const g = groupStrips(
[slot(1, 1, 5, 0), slot(1, 2, 5, 1), slot(1, 3, 5, 2), slot(2, 1, 5, 0)],
keyOf,
);
const anchorId = g.idByKey.get("1:1:1")!;
it("берёт самого позднего по времени члена полосы под курсором", () => {
expect(pickStripEnd(g, anchorId, ["1:1:1", "1:3:1"])).toBe("1:3:1");
expect(pickStripEnd(g, anchorId, ["1:1:1", "1:2:1"])).toBe("1:2:1");
});
it("игнорирует ключи не из своей полосы (граница полосы)", () => {
expect(pickStripEnd(g, anchorId, ["2:1:1"])).toBeNull();
expect(pickStripEnd(g, anchorId, ["1:2:1", "2:1:1"])).toBe("1:2:1");
});
it("никого из полосы под курсором → null (клампим к прежнему концу)", () => {
expect(pickStripEnd(g, anchorId, [])).toBeNull();
expect(pickStripEnd(g, "polosa:нет", ["1:1:1"])).toBeNull();
});
});
@@ -149,3 +149,60 @@ export function groupStrips<T extends Stripable>(
return { idByKey, membersOf }; return { idByKey, membersOf };
} }
/**
* Якорь полосы для жеста на карте: из ключей слотов под курсором берём самый ранний по времени
* это «первый слот полосы» (правило оператора: при наложении начало = первый по времени). Слоты без
* валидного времени игнорируем; тай-брейк при равном времени (веер кренов в один момент) по ключу
* (детерминизм). Пустой/полностью невалидный вход null.
*
* @param candidateKeys Ключи слотов под курсором (порядок не важен).
* @param tnMsOf Время начала слота в мс (как parseLocal(tn)); NaN слот пропускаем.
*/
export function pickStripAnchor(
candidateKeys: readonly string[],
tnMsOf: (key: string) => number,
): string | null {
let best: string | null = null;
let bestTn = Number.POSITIVE_INFINITY;
for (const key of candidateKeys) {
const tn = tnMsOf(key);
if (Number.isNaN(tn)) continue;
if (best === null || tn < bestTn || (tn === bestTn && key < best)) {
best = key;
bestTn = tn;
}
}
return best;
}
/**
* Конец полосы для жеста на карте: из ключей под курсором берём члена полосы anchorId, самого
* ПОЗДНЕГО по времени (правило оператора: при наложении конец = последний по времени). Время = индекс
* в membersOf (он уже отсортирован по времени), поэтому берём максимальный индекс. Ключи не из этой
* полосы игнорируем так выделение не уезжает за границу полосы. Никого из полосы под курсором null
* (вызывающий оставляет прежний конец «клампим» к последнему валидному).
*
* @param grouping Разметка полос (из groupStrips).
* @param anchorId Полоса якоря.
* @param candidateKeys Ключи слотов под курсором.
*/
export function pickStripEnd(
grouping: StripGrouping,
anchorId: PolosaId,
candidateKeys: readonly string[],
): string | null {
const members = grouping.membersOf.get(anchorId);
if (!members) return null;
let best: string | null = null;
let bestIdx = -1;
for (const key of candidateKeys) {
if (grouping.idByKey.get(key) !== anchorId) continue;
const idx = members.indexOf(key);
if (idx > bestIdx) {
bestIdx = idx;
best = key;
}
}
return best;
}