feat(tgu-ui): выбор полосы слотов мышью на карте (Shift+drag)
This commit is contained in:
@@ -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` из каталога.
|
||||
|
||||
@@ -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<HTMLCanvasElement | null>(null);
|
||||
const dragRef = useRef<DragState | undefined>(undefined);
|
||||
const hasDraggedRef = useRef(false);
|
||||
// Активен жест «выбор полосы» (Shift+drag по слотам): пока true — панорама и обычный клик подавлены.
|
||||
const stripGestureRef = useRef(false);
|
||||
const hoverRef = useRef<MapPolygon | undefined>(undefined);
|
||||
const hoverMarkerRef = useRef<MapMarker | undefined>(undefined);
|
||||
const selectedMarkerRef = useRef<MapMarker | undefined>(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);
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="tgu-slots-selbar__sub" title="Кликните первый слот, затем Shift+клик по последнему — выделится полоса между ними. Выйти за полосу (другой КА/крен/разрыв по времени) нельзя.">
|
||||
Полоса: клик + Shift+клик
|
||||
<span className="tgu-slots-selbar__sub" title="Таблица: кликните первый слот, затем Shift+клик по последнему — выделится полоса между ними. На карте: Shift+зажать на слоте и протянуть мышью вдоль полосы. Выйти за полосу (другой КА/крен/разрыв по времени) нельзя.">
|
||||
Полоса: клик+Shift в таблице · Shift+тянуть на карте
|
||||
</span>
|
||||
<div className="tgu-slots-selbar__actions">
|
||||
<button
|
||||
@@ -1533,6 +1584,9 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
|
||||
enableObjectChooser
|
||||
onObjectsAtPoint={handleObjectsAtPoint}
|
||||
onObjectSelect={handleObjectSelect}
|
||||
onStripAnchor={hasRequest ? handleStripAnchor : undefined}
|
||||
onStripDrag={hasRequest ? handleStripDrag : undefined}
|
||||
onStripCommit={hasRequest ? handleStripCommit : undefined}
|
||||
selectedObjectId={hasRequest ? activeSlotId : activeHitId}
|
||||
markedObjectIds={hasRequest ? markedSlotIds : undefined}
|
||||
focusPoint={focusPoint}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
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-строку (как отдаёт бэкенд). */
|
||||
type S = {
|
||||
@@ -127,3 +133,51 @@ describe("groupStrips", () => {
|
||||
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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Якорь полосы для жеста на карте: из ключей слотов под курсором берём самый ранний по времени —
|
||||
* это «первый слот полосы» (правило оператора: при наложении начало = первый по времени). Слоты без
|
||||
* валидного времени игнорируем; тай-брейк при равном времени (веер кренов в один момент) — по ключу
|
||||
* (детерминизм). Пустой/полностью невалидный вход → 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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user