feat(tgu-ui): выбор полосы слотов мышью на карте (Shift+drag)
This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user