diff --git a/docs/tasks/TASK_slot-strip-selection.md b/docs/tasks/TASK_slot-strip-selection.md index c1f7a6a..28e26b5 100644 --- a/docs/tasks/TASK_slot-strip-selection.md +++ b/docs/tasks/TASK_slot-strip-selection.md @@ -120,6 +120,18 @@ возможна подстройка по фактическим `roll`/`tn`. - Бэкенд/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 (проверка выполнимости, отдельно) - Бэкенд: read-only `POST /api/slots/booking/validate` (набор слотов → violations), переиспользует по-КА `mmi`/`maxSurveyDuration`/`maxChainLength`/`ROLL_EPSILON` из каталога. 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 c6be741..947eab0 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 @@ -38,6 +38,16 @@ type Tgu2DMapViewProps = { enableObjectChooser?: boolean; /** Несколько объектов под точкой клика — список передаётся наверх (выбор делает оператор справа). */ 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; onPickPoint?: (point: { lat: number; lon: number }) => void; targets?: Tgu2DMapTarget[]; @@ -92,6 +102,9 @@ export function Tgu2DMapView({ focusToken, enableObjectChooser = false, onObjectsAtPoint, + onStripAnchor, + onStripDrag, + onStripCommit, pickMode = false, onPickPoint, targets = [], @@ -106,6 +119,8 @@ export function Tgu2DMapView({ const canvasRef = useRef(null); const dragRef = useRef(undefined); const hasDraggedRef = useRef(false); + // Активен жест «выбор полосы» (Shift+drag по слотам): пока true — панорама и обычный клик подавлены. + const stripGestureRef = useRef(false); const hoverRef = useRef(undefined); const hoverMarkerRef = useRef(undefined); const selectedMarkerRef = useRef(undefined); @@ -268,12 +283,39 @@ export function Tgu2DMapView({ [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) => { // Нажатие на кнопки управления картой (зум, линейка, «Сброс») не должно запускать перетаскивание // и захват указателя: setPointerCapture на контейнере иначе перехватывает жест, и кнопки внутри // карты перестают «кликаться» (родная причина того, что +/−/□ и «Сброс» не срабатывали). if ((event.target as HTMLElement).closest("button")) return; 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; dragRef.current = { startX: screenPoint.x, @@ -286,6 +328,14 @@ export function Tgu2DMapView({ const DRAG_THRESHOLD = 4; 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; if (!drag) { updateHover(event); @@ -316,6 +366,14 @@ export function Tgu2DMapView({ }; 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; wrapperRef.current?.releasePointerCapture(event.pointerId); }; diff --git a/services/pcp-tgu-ui-service/src/features/tgu-slots/TguSlotsTab.tsx b/services/pcp-tgu-ui-service/src/features/tgu-slots/TguSlotsTab.tsx index fc11bd0..6007bce 100644 --- a/services/pcp-tgu-ui-service/src/features/tgu-slots/TguSlotsTab.tsx +++ b/services/pcp-tgu-ui-service/src/features/tgu-slots/TguSlotsTab.tsx @@ -39,7 +39,7 @@ import { } from "./coverage"; import { applySlotFilters, slotStatusKey, type SlotStatusKey } from "./slotFilters"; import { selectionAfterCheck, selectStripRange } from "./slotSelection"; -import { groupStrips } from "./slotStrips"; +import { groupStrips, pickStripAnchor, pickStripEnd, type PolosaId } from "./slotStrips"; import { runChunked } from "./runChunked"; import { ChunkProgress } from "./ChunkProgress"; @@ -298,10 +298,61 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) { // времени (виток не граница). idByKey — id полосы по ключу слота; membersOf — члены полосы по времени. 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 выделил бы не ту полосу. useEffect(() => { setAnchorKey(null); + mapStripRef.current = null; }, [filteredSlots]); // Загрузка заявок интервала (на карту); вызывается на входе, при смене диапазона и из фильтра. @@ -1205,8 +1256,8 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) { )} - - Полоса: клик + Shift+клик + + Полоса: клик+Shift в таблице · Shift+тянуть на карте