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