fix(touch): separate pan, edge swipe and double fit (#691)

Issue: #691
User-Visible: yes
This commit is contained in:
Matysh
2026-09-28 16:27:45 +03:00
parent 3f17a1497d
commit 37fbb9824d
17 changed files with 665 additions and 150 deletions
+28 -9
View File
@@ -26,24 +26,43 @@ const res = await page.evaluate(async () => {
// 2) редакторы заблокированы
c._setMode('plan'); await c.updateComplete;
out.editorsBlocked = c._mode === 'view';
// 3) свайп при 1:1 переключает пространство (и точки показываются)
// 3) центральный drag остаётся pan, а свайп из внутренней 48 px зоны
// правого края при 1:1 переключает на соседнее пространство.
const s0 = c._space;
const stage = sr().querySelector('.stage');
stage.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true, pointerId: 1, pointerType: 'touch', isPrimary: true, button: 0, clientX: 600, clientY: 300 }));
stage.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, composed: true, pointerId: 1, pointerType: 'touch', isPrimary: true, button: 0, clientX: 450, clientY: 305 }));
const fire = (type, pointerId, clientX, clientY) => stage.dispatchEvent(new PointerEvent(type, {
bubbles: true, composed: true, cancelable: true, pointerId, pointerType: 'touch',
isPrimary: true, button: 0, buttons: type === 'pointerup' ? 0 : 1, clientX, clientY,
}));
const rect = stage.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
const centerView = JSON.stringify(c._viewOr(c._baseVb()));
fire('pointerdown', 1, centerX, centerY);
fire('pointermove', 1, centerX - 150, centerY + 5);
fire('pointerup', 1, centerX - 150, centerY + 5);
await c.updateComplete;
out.swipeSwitches = c._space !== s0;
out.centralDragPansWithoutSwitching = c._space === s0
&& JSON.stringify(c._viewOr(c._baseVb())) !== centerView && c._panLock === null;
const rightEdgeX = rect.right - 20;
fire('pointerdown', 2, rightEdgeX, centerY);
fire('pointermove', 2, rightEdgeX - 80, centerY + 2);
fire('pointerup', 2, rightEdgeX - 120, centerY + 3);
await c.updateComplete;
out.edgeSwipeSwitches = c._space !== s0;
out.dotsShown = !!sr().querySelector('.kioskdots');
// 4) при зуме свайп не переключает
c._zoom = 2; const s1 = c._space;
stage.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true, pointerId: 2, pointerType: 'touch', isPrimary: true, button: 0, clientX: 600, clientY: 300 }));
stage.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, composed: true, pointerId: 2, pointerType: 'touch', isPrimary: true, button: 0, clientX: 450, clientY: 305 }));
const leftEdgeX = rect.left + 20;
fire('pointerdown', 3, leftEdgeX, centerY);
fire('pointermove', 3, leftEdgeX + 80, centerY + 2);
fire('pointerup', 3, leftEdgeX + 120, centerY + 3);
await c.updateComplete;
out.noSwipeZoomed = c._space === s1;
// 5) двойной тап сбрасывает зум
for (const pointerId of [3, 4]) {
stage.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true, pointerId, pointerType: 'touch', isPrimary: true, button: 0, clientX: 500, clientY: 300 }));
stage.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, composed: true, pointerId, pointerType: 'touch', isPrimary: true, button: 0, clientX: 501, clientY: 300 }));
for (const pointerId of [4, 5]) {
fire('pointerdown', pointerId, centerX, centerY);
fire('pointerup', pointerId, centerX + 1, centerY);
}
const resetStarted = performance.now();
while (c._cameraTransition?.active) {
+57 -16
View File
@@ -2,8 +2,9 @@
// audit DEV-1DA1-02 (P2).
//
// `_stagePointerMove` locks the gesture on the first movement past 8 px
// (`_panLock`: 'swipe' if it is horizontal enough inside the swipe zone, 'pan'
// otherwise) and the plan then follows the finger. But `_stagePointerUp` used
// (`_panLock`: 'swipe' only for a sufficiently horizontal inward move that
// started inside the 48 px strip of an edge with a neighbour, 'pan' otherwise).
// A pan then follows the finger. But `_stagePointerUp` used
// to ignore that lock and ask `swipeTarget()` again, from the raw start→end
// vector alone. A CURVED gesture — a small vertical lead-in that locks 'pan',
// then a long horizontal sweep — therefore panned under the finger and still
@@ -12,7 +13,7 @@
//
// The lock is now final: with `_panLock === 'pan'` the floor never changes, no
// matter what the overall vector looks like. The two straight gestures keep
// their old behaviour, and so does the motionless double tap (no movement,
// their intended behaviour, and so does the motionless double tap (no movement,
// no lock — the swipe path is never even reached by it).
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
@@ -29,6 +30,10 @@ const out = await page.evaluate(async () => {
await k.updateComplete;
const sr = k.shadowRoot || k.renderRoot;
const stage = sr.querySelector('.stage');
const rect = stage.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
const rightEdgeX = rect.right - 20;
const fire = (type, id, x, y) => stage.dispatchEvent(new PointerEvent(type, {
bubbles: true, composed: true, cancelable: true, pointerId: id,
pointerType: 'touch', isPrimary: true, button: 0, clientX: x, clientY: y,
@@ -36,8 +41,10 @@ const out = await page.evaluate(async () => {
o.kioskHasSeveralSpaces = k._model.length > 1;
const home = async () => {
k._commitSpace(k._model[0].id, true);
const vb = k._baseVb();
k._applyView(1, vb[0] + vb[2] / 2, vb[1] + vb[3] / 2);
k.requestUpdate();
await k.updateComplete;
};
/**
@@ -67,42 +74,76 @@ const out = await page.evaluate(async () => {
// ---- 1. the auditor's curved pan: vertical lead-in, horizontal ending --
// (500,300) → (502,312) locks 'pan' → curve left to (350,304) → release.
// dx = -150, dy = +4: swipeTarget() would happily call that a swipe.
const curvedPan = await play(51, [500, 300], [[502, 312], [460, 310], [400, 306], [350, 304]]);
const curvedPan = await play(51, [centerX, centerY], [
[centerX + 2, centerY + 12], [centerX - 40, centerY + 10],
[centerX - 100, centerY + 6], [centerX - 150, centerY + 4],
]);
o.curvedPanLocksPan = curvedPan.lockAfterLeadIn === 'pan';
o.curvedPanKeepsTheLock = curvedPan.lockBeforeRelease === 'pan';
o.curvedPanActuallyPans = curvedPan.moved === true;
o.curvedPanKeepsTheFloor = curvedPan.s1 === curvedPan.s0;
// the same trajectory the other way round — a swipe to the right would have
// been the previous floor, so the bug is symmetric and so is the fix
const curvedPanRight = await play(52, [400, 300], [[402, 312], [460, 308], [520, 305], [560, 304]]);
const curvedPanRight = await play(52, [centerX, centerY], [
[centerX + 2, centerY + 12], [centerX + 60, centerY + 8],
[centerX + 120, centerY + 5], [centerX + 160, centerY + 4],
]);
o.curvedPanRightLocksPan = curvedPanRight.lockAfterLeadIn === 'pan';
o.curvedPanRightKeepsTheFloor = curvedPanRight.s1 === curvedPanRight.s0;
// a long diagonal that ends up dominated by x, but started as a pan
const diagonalPan = await play(53, [500, 300], [[496, 316], [420, 340], [330, 350], [260, 352]]);
const diagonalPan = await play(53, [centerX, centerY], [
[centerX - 4, centerY + 16], [centerX - 80, centerY + 40],
[centerX - 170, centerY + 50], [centerX - 240, centerY + 52],
]);
o.diagonalPanLocksPan = diagonalPan.lockAfterLeadIn === 'pan';
o.diagonalPanKeepsTheFloor = diagonalPan.s1 === diagonalPan.s0;
// Even when a drag starts in the active edge strip, a vertical first move
// owns it as pan forever; a later horizontal tail cannot reclassify release.
const edgeCurvedPan = await play(62, [rightEdgeX, centerY], [
[rightEdgeX - 2, centerY + 14], [rightEdgeX - 80, centerY + 10],
[rightEdgeX - 170, centerY + 5],
]);
o.edgeCurvedPanLocksPan = edgeCurvedPan.lockAfterLeadIn === 'pan';
o.edgeCurvedPanActuallyPans = edgeCurvedPan.moved === true;
o.edgeCurvedPanKeepsTheFloor = edgeCurvedPan.s1 === edgeCurvedPan.s0;
// ---- 2. a gesture locked as a SWIPE keeps its own semantics ------------
// horizontal lead-in locks 'swipe'; the plan must not slide under it, even
// when the trajectory then bends vertically and the final vector no longer
// qualifies — the floor simply stays, and nothing pans
const curvedSwipe = await play(54, [600, 300], [[540, 302], [536, 380], [545, 500]]);
const curvedSwipe = await play(54, [rightEdgeX, centerY], [
[rightEdgeX - 60, centerY + 2], [rightEdgeX - 64, centerY + 80],
[rightEdgeX - 55, centerY + 200],
]);
o.curvedSwipeLocksSwipe = curvedSwipe.lockAfterLeadIn === 'swipe';
o.curvedSwipeNeverPans = curvedSwipe.moved === false;
o.curvedSwipeThatDiesChangesNothing = curvedSwipe.s1 === curvedSwipe.s0;
// a swipe that bends but still ends as a swipe does switch the floor
const bentSwipe = await play(55, [600, 300], [[540, 302], [470, 330], [420, 340]]);
const bentSwipe = await play(55, [rightEdgeX, centerY], [
[rightEdgeX - 60, centerY + 2], [rightEdgeX - 130, centerY + 30],
[rightEdgeX - 180, centerY + 40],
]);
o.bentSwipeLocksSwipe = bentSwipe.lockAfterLeadIn === 'swipe';
o.bentSwipeNeverPans = bentSwipe.moved === false;
o.bentSwipeStillSwitches = bentSwipe.s1 !== bentSwipe.s0;
// ---- 3. the straight gestures are exactly as they were -----------------
const straightSwipe = await play(56, [600, 300], [[540, 302], [480, 305], [450, 305]]);
const straightSwipe = await play(56, [rightEdgeX, centerY], [
[rightEdgeX - 60, centerY + 2], [rightEdgeX - 120, centerY + 5],
[rightEdgeX - 150, centerY + 5],
]);
o.straightSwipeSwitches = straightSwipe.s1 !== straightSwipe.s0;
o.straightSwipeDoesNotPan = straightSwipe.moved === false;
const straightPan = await play(57, [450, 200], [[452, 260], [454, 330]]);
const straightPan = await play(57, [centerX, centerY - 100], [
[centerX + 2, centerY - 40], [centerX + 4, centerY + 30],
]);
o.straightPanPans = straightPan.moved === true;
o.straightPanKeepsTheFloor = straightPan.s1 === straightPan.s0;
const centralHorizontalPan = await play(61, [centerX, centerY], [
[centerX - 60, centerY + 2], [centerX - 150, centerY + 5],
]);
o.centralHorizontalDragPans = centralHorizontalPan.moved === true;
o.centralHorizontalDragKeepsTheFloor = centralHorizontalPan.s1 === centralHorizontalPan.s0;
// ---- 4. a motionless double tap still resets the zoom ------------------
// no movement means no lock at all, so nothing above can reach this path
@@ -110,8 +151,8 @@ const out = await page.evaluate(async () => {
k._applyView(2);
await k.updateComplete;
for (const id of [58, 60]) {
fire('pointerdown', id, 500, 300);
fire('pointerup', id, 501, 300);
fire('pointerdown', id, centerX, centerY);
fire('pointerup', id, centerX + 1, centerY);
}
const resetStarted = performance.now();
do { await new Promise((resolve) => requestAnimationFrame(resolve)); }
@@ -125,11 +166,11 @@ const out = await page.evaluate(async () => {
await k.updateComplete;
const zoomed = await (async () => {
const s0 = k._space;
fire('pointerdown', 59, 600, 300);
fire('pointermove', 59, 540, 302);
fire('pointerdown', 59, rightEdgeX, centerY);
fire('pointermove', 59, rightEdgeX - 60, centerY + 2);
const lock = k._panLock;
fire('pointermove', 59, 480, 305);
fire('pointerup', 59, 480, 305);
fire('pointermove', 59, rightEdgeX - 120, centerY + 5);
fire('pointerup', 59, rightEdgeX - 120, centerY + 5);
await k.updateComplete;
return { lock, same: k._space === s0 };
})();
+56 -13
View File
@@ -45,6 +45,8 @@ const out = await page.evaluate(async () => {
}
await c.updateComplete;
};
const waitMs = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
const waitRoomDecision = () => waitMs(370);
const pointer = (target, type, id, x = 500, y = 300, extra = {}) =>
target.dispatchEvent(new PointerEvent(type, {
bubbles: true, composed: true, cancelable: true, pointerId: id,
@@ -66,8 +68,17 @@ const out = await page.evaluate(async () => {
};
localStorage.setItem('houseplan_card_zoom_v1', JSON.stringify({ 'fit-floor': 1 }));
const beforeDelayedRoomFit = JSON.stringify({ zoom: c._zoom, view: c._view });
pointer(roomNode(), 'pointerdown', 15201);
pointer(roomNode(), 'pointerup', 15201);
result.singleRoomTapWaitsForSecondTap = !c._cameraTransition?.active
&& c._roomFocus === null
&& JSON.stringify({ zoom: c._zoom, view: c._view }) === beforeDelayedRoomFit;
await waitMs(120);
result.singleRoomTapStillWaitsInsideWindow = !c._cameraTransition?.active
&& c._roomFocus === null
&& JSON.stringify({ zoom: c._zoom, view: c._view }) === beforeDelayedRoomFit;
await waitMs(250);
const tweenStarted = c._cameraTransition?.active === true;
const syncFrames = [];
if (tweenStarted) {
@@ -108,6 +119,7 @@ const out = await page.evaluate(async () => {
const settled = JSON.stringify(c._view);
pointer(roomNode(), 'pointerdown', 15202);
pointer(roomNode(), 'pointerup', 15202);
await waitRoomDecision();
await waitCamera();
result.repeatedFitIsNoOp = !c._cameraTransition.active && JSON.stringify(c._view) === settled;
@@ -158,10 +170,27 @@ const out = await page.evaluate(async () => {
result.areaLinkSuppressesRoomFit = JSON.stringify(c._view) === beforeLink
&& c._roomPointer === null && c._roomFocus === null;
c._clearRoomFocus(true);
pointer(roomNode(), 'pointerdown', 152041);
pointer(roomNode(), 'pointerup', 152041);
pointer(ownerProbe, 'pointerdown', 152042);
pointer(ownerProbe, 'pointerup', 152042);
await waitRoomDecision();
result.interactiveSecondTapCancelsPendingRoomFit = c._roomFocus === null
&& !c._cameraTransition.active;
c._clearRoomFocus(true);
pointer(roomNode(), 'pointerdown', 152043);
pointer(roomNode(), 'pointerup', 152043);
c._clearRoomFocus(true);
await waitRoomDecision();
result.lifecycleCleanupCancelsPendingRoomFit = c._roomFocus === null
&& !c._cameraTransition.active;
const stage = root.querySelector('.stage');
const base = c._baseVb();
const moveAway = async () => {
c._doubleFit.clear();
c._clearRoomFocus(true);
c._applyView(2, base[0] + base[2] * 0.65, base[1] + base[3] * 0.4);
await c.updateComplete;
};
@@ -211,14 +240,14 @@ const out = await page.evaluate(async () => {
result.cancelDisarmsThePreviousTap = c._zoom === 2 && !c._cameraTransition.active;
c._mode = 'decor';
c._doubleFit.clear();
c._clearRoomFocus(true);
pointer(stage, 'pointerdown', 44909, 400, 500);
pointer(stage, 'pointerup', 44909, 400, 500);
pointer(stage, 'pointerdown', 44910, 400, 500);
pointer(stage, 'pointerup', 44910, 400, 500);
result.editorBackgroundDoesNotFit = c._zoom === 2 && !c._cameraTransition.active;
c._mode = 'view';
c._doubleFit.clear();
c._clearRoomFocus(true);
pointer(roomNode(), 'pointerdown', 15205, 500, 300);
pointer(roomNode(), 'pointermove', 15205, 520, 300);
@@ -228,6 +257,7 @@ const out = await page.evaluate(async () => {
pointer(roomNode(), 'pointerdown', 15206, 500, 300);
pointer(roomNode(), 'pointerup', 15206, 500, 300);
await waitRoomDecision();
await waitCamera();
c.style.width = '760px';
c._lastValidStageSize = [1000, c._stageEl.clientHeight];
@@ -246,6 +276,7 @@ const out = await page.evaluate(async () => {
await c.updateComplete;
pointer(roomNode(), 'pointerdown', 15207);
pointer(roomNode(), 'pointerup', 15207);
await waitRoomDecision();
await waitCamera();
const isoMargins = margins();
result.isoUsesProjectedBounds = c._effectiveProjection() === 'iso'
@@ -267,19 +298,31 @@ const out = await page.evaluate(async () => {
pointerType: 'touch', isPrimary: true, button: 0,
buttons: type === 'pointerdown' ? 1 : 0, clientX: 450, clientY: 300,
}));
for (const id of [15208, 15209]) {
kioskPointer(kioskRoom, 'pointerdown', id);
kioskPointer(kioskRoom, 'pointerup', id);
const started = performance.now();
while (kiosk._cameraTransition.active && performance.now() - started < 1200) {
await new Promise((resolve) => requestAnimationFrame(resolve));
}
kioskPointer(kioskRoom, 'pointerdown', 15208);
kioskPointer(kioskRoom, 'pointerup', 15208);
const kioskSingleWaits = kiosk._roomFocus === null && !kiosk._cameraTransition.active;
await waitRoomDecision();
while (kiosk._cameraTransition.active) {
await new Promise((resolve) => requestAnimationFrame(resolve));
}
const beforeKioskBackground = JSON.stringify({ zoom: kiosk._zoom, view: kiosk._view });
result.kioskSingleRoomTapFitsAfterDelay = kioskSingleWaits
&& kiosk._roomFocus?.roomId === 'room-a';
kiosk._clearRoomFocus(true);
kiosk._applyView(2);
const beforeKioskPair = JSON.stringify({ zoom: kiosk._zoom, view: kiosk._view });
kioskPointer(kioskRoom, 'pointerdown', 15209);
kioskPointer(kioskRoom, 'pointerup', 15209);
const kioskRoomPairHasNoIntermediateMotion = !kiosk._cameraTransition.active
&& JSON.stringify({ zoom: kiosk._zoom, view: kiosk._view }) === beforeKioskPair;
kioskPointer(kiosk._stageEl, 'pointerdown', 15210);
kioskPointer(kiosk._stageEl, 'pointerup', 15210);
result.kioskRoomTapDoesNotEnterDoubleTap = kiosk._roomFocus?.roomId === 'room-a'
&& JSON.stringify({ zoom: kiosk._zoom, view: kiosk._view }) === beforeKioskBackground;
result.kioskRoomThenBackgroundUsesDoubleFit = kioskRoomPairHasNoIntermediateMotion
&& kiosk._cameraTransition.state?.reason === 'double-tap'
&& kiosk._roomFocus === null;
while (kiosk._cameraTransition.active) {
await new Promise((resolve) => requestAnimationFrame(resolve));
}
kiosk._clearRoomFocus(true);
kiosk._applyView(2);
for (const id of [15211, 15212]) {
+21 -15
View File
@@ -263,7 +263,7 @@ produce a zero-sized SVG `viewBox`.
* **Zoom out** — `MIN_ZOOM = 1/3`: you can see three times the content
frame and no further. Empty space beyond that is not information.
* **Discrete camera motion** (#82) — wheel, `−`/`+`, Fit all, the home arrow
and the free-background double-click/tap in View or kiosk interpolate the
and the plan-surface double-click/tap in View or kiosk interpolate the
existing exact camera target for
160–220 ms with `cubic-bezier(0.2, 0.7, 0.2, 1)`. Zoom is logarithmic and
the world centre is linear; the final frame is the same clamped `viewBox`
@@ -291,10 +291,12 @@ produce a zero-sized SVG `viewBox`.
bails out on them), and a drawing tool that consumes the press —
decor line/rect/ellipse/text — bails out too. Two fingers are always
a pinch, never a pan. On a **kiosk** screen at swipe zoom (`≤ 1`,
more than one space) a *horizontal* drag belongs to the floor swipe:
the gesture is classified once, on the first movement past 8 px, and
keeps that role until the finger lifts (`_panLock`), so the plan
never slides under a swipe and a vertical drag still pans.
more than one space), a floor swipe may start only inside the inner
**48 CSS px** strip of the left/right stage edge that has a previous/next
space. The first movement past the 8 px Manhattan boundary becomes a swipe
only when it points inward and `|dx| > 1.5 × |dy|`; equality, the wrong
direction, a start outside the strip and an edge without a neighbour all
become ordinary pan. The decision remains final until release (`_panLock`).
* **Room fit (#152).** A clean primary click/tap on the browser-painted room
target fits that room's final floor plus its visible boundary wall body into
the middle 80% of the stage. Devices, openings/actions and the HA Area link
@@ -303,15 +305,19 @@ produce a zero-sized SVG `viewBox`.
does not write the per-space zoom preference. Its session-only room intent
is reapplied atomically on a stable stage resize, then cleared by manual
camera input, Fit all/home, mode/space/projection changes, structural
adoption, hidden state or disconnect. In kiosk, room-owned taps never enter
the free-background double-tap sequence.
* **Free-background fit (#449).** Two clean primary clicks/taps within 350 ms
on the stage background invoke the same Fit all command in View and kiosk.
Room, device, vacuum, opening, link and control paths disarm the sequence;
so do pan, pinch, swipe, long press, cancellation, editor/mode/space/projection
changes and lifecycle adoption. The new recognizer adds no timer or render on
the first tap. Mouse, touch and pen sequences are separate; editors do not
expose this shortcut.
adoption, hidden state or disconnect. A pointer tap waits up to 350 ms before
applying room fit: this lets a second clean plan tap select Fit all without
first animating into the room. Keyboard activation remains immediate.
* **Plan-surface fit (#449, #691).** Two clean primary clicks/taps within 350 ms
on stage background, room fill or a non-interactive room label invoke the
same Fit all command in View and kiosk. The two taps may use different plan
surfaces or rooms, but must use the same pointer modality and space. A single
room tap still fits that room once its 350 ms decision window expires; a
single background tap stays passive. Device, vacuum, opening, stair, link and
control paths cancel both the sequence and any pending room fit; so do pan,
pinch, swipe, long press, cancellation, editor/mode/space/projection changes
and lifecycle adoption. Mouse, touch and pen share this contract but do not
pair with one another; editors do not expose the shortcut.
* **The lock is final, at the release too** (audit DEV-1DA1-02). The
release used to ask `swipeTarget()` again from the raw start→end
vector, ignoring the lock — so a *curved* gesture (a short vertical
@@ -321,7 +327,7 @@ produce a zero-sized SVG `viewBox`.
'pan'` now means no floor change, whatever the overall vector ends up
looking like; only a gesture locked as `swipe` may reach
`swipeTarget()`, and it never pans on the way. A motionless tap locks
nothing, so a clean free-background double-tap remains available.
nothing, so a clean plan-surface double-tap remains available.
* **"Home is that way" arrow** — when the content frame is entirely
outside the current view, a small pointer appears at the view edge
in the frame's direction. Clicking it fits the content. Cheap
+7
View File
@@ -2,6 +2,13 @@
## Unreleased
- Touch navigation now keeps ordinary one-finger drags as pan and switches
kiosk spaces only from a 48 px inner edge with an available neighbour. Two
clean taps on background, a room fill or a passive room label fit the whole
plan without first jumping into the room; a single room tap still fits after
the 350 ms decision window, consistently for touch, mouse and pen
([#691](https://github.com/Matysh/houseplan-card/issues/691)).
- Stair outlines, treads, trapezoids and direction arrows now share one 3.6 cm
physical line weight. It scales with the plan instead of staying fixed in
screen pixels, remains uniform when a stair is resized or rotated, and uses
+8
View File
@@ -8,6 +8,14 @@
## Не выпущено
- Навигация на сенсорных экранах теперь сохраняет обычный drag одним пальцем
для панорамирования, а пространство в киоске переключается только жестом из
внутренней 48-пиксельной зоны края, за которым есть соседнее пространство.
Два чистых тапа по фону, заливке комнаты или пассивной подписи вписывают весь
план без промежуточного прыжка в комнату; одиночный тап по комнате по-прежнему
вписывает её после окна выбора 350 мс — одинаково для touch, мыши и пера
([#691](https://github.com/Matysh/houseplan-card/issues/691)).
- Контур, ступени, трапеция и стрелка направления лестницы теперь используют
единую физическую толщину 3,6 см. Она масштабируется вместе с планом, а не
остаётся постоянной в экранных пикселях, не искажается при изменении размера
+6 -2
View File
@@ -39,8 +39,12 @@ On phones, tablets, wall panels and HA Companion apps, the ordinary View must:
- support convenient pan, pinch zoom and space switching;
- treat a linked stair as a floor-navigation target only after a clean tap;
pan, pinch, long press, swipe and cancellation tails must stay inert;
- fit the whole plan after two clean taps on free scene background, while one
tap on a room keeps the immediate room-fit action;
- reserve floor switching for an inward horizontal gesture that starts in the
48 CSS px strip of an edge with a neighbouring space; a drag elsewhere and
an unavailable edge must pan the plan;
- fit the whole plan after two clean taps on background, room fill or a passive
room label. A single room tap fits only after the 350 ms second-tap window,
with no intermediate camera motion; keyboard room activation stays immediate;
- provide a touch path for essential information that desktop exposes through
hover;
- open and close View dialogs without clipping their essential content or
+10 -8
View File
@@ -433,9 +433,9 @@ precise drawing, Resize, keyboard modifiers and double-click properties.
| Scenario | Mouse | Touch View | Touch editors | Keyboard |
|---|---|---|---|---|
| Zoom and pan | Wheel; drag empty space; `−`/`+`; double-click free background to Fit all | Pinch; drag; double-tap free background to Fit all | Available but precision is not guaranteed; no Fit-all double-tap | — |
| Room | A clean click fits the room with 10% margins | One tap fits the room; repeated room taps never become Fit all | — | `Enter`/`Space` on a visible room label |
| Change space | Click a tab | Tap; kiosk swipe at 1:1 | Tap a tab | — |
| Zoom and pan | Wheel; drag empty space; `−`/`+`; double-click background/room to Fit all | Pinch; drag; double-tap background/room to Fit all | Available but precision is not guaranteed; no Fit-all double-tap | — |
| Room | One clean click fits the room after the 350 ms double-click window | One clean tap fits the room after the 350 ms double-tap window | — | Immediate `Enter`/`Space` on a visible room label |
| Change space | Click a tab | Tap; in kiosk at 1:1, swipe inward from the 48 px edge that has a neighbour | Tap a tab | — |
| Device | Click/double-click per mode | Tap; safe actions equal desktop | Drag/properties are best effort | `Esc` closes the top surface |
| Walls drawing or precise drag | Full contract | Not applicable | Best effort; use desktop for Resize and exact nodes | `Shift` changes magnet/angle; `Esc` finishes a Walls chain or cancels the current precise drag |
| Editor history | Undo/Redo controls | Not applicable | Controls may work; no gesture guarantee | `Ctrl/Cmd+Z`, `Ctrl/Cmd+Shift+Z`, `Ctrl+Y` |
@@ -443,7 +443,7 @@ precise drawing, Resize, keyboard modifiers and double-click properties.
Important details:
- wheel, `−`/`+`, **Fit all**, the return arrow and a free-background
- wheel, `−`/`+`, **Fit all**, the return arrow and a plan-surface
double-click/tap in View or kiosk use a short smooth camera transition;
rapid wheel events update one destination instead of building a queue;
- pinch and pan remain directly under the fingers; reduced motion makes every
@@ -454,8 +454,10 @@ Important details:
- each space keeps its own local View viewport. Editor pan/zoom is a working
view and does not replace it;
- a hint appears when objects lie far away from the main plan;
- above 1:1, a horizontal kiosk gesture pans instead of changing space. Any
manual kiosk operation pauses auto-cycle for 60 seconds;
- a kiosk space swipe starts only in the inner 48 CSS px of an edge with a
neighbouring space, points inward and stays strongly horizontal. Elsewhere,
at an unavailable edge and above 1:1 the gesture pans. Any manual kiosk
operation pauses auto-cycle for 60 seconds;
- `Shift` keeps positional grid snapping. For room walls it locks the current
segment to the nearest 45° direction; in Background it creates a square or
circle, allows independent axes for ordinary decor, unlocks furniture
@@ -1714,9 +1716,9 @@ cycle: 30
| Card header | Hidden |
| Editors | Unavailable |
| Height | `100dvh` |
| Swipe | Cycles spaces at 1:1 |
| Swipe | At 1:1, switches to an existing neighbour from the inner 48 px edge |
| Pinch/pan | Zooms and moves the plan |
| Double-tap free background | Fits all content |
| Double-tap background or room | Fits all content without an intermediate room jump |
| Hold empty space for 3 seconds | Opens per-display icon/text sizing |
| `cycle` | Automatically advances; any interaction pauses it for 60 seconds |
+4 -3
View File
@@ -95,13 +95,14 @@ support. User documentation recommends desktop for creation and maintenance.
## View — display and device interaction only
Allowed: pan/zoom (wheel, pinch, buttons, double-click/tap on free background
to Fit all), switching spaces, device tap
Allowed: pan/zoom (wheel, pinch, buttons, double-click/tap on background, room
fill or passive room label to Fit all), switching spaces, device tap
(info / more-info / toggle per settings), long-press → info card, lock-badge tap
→ door/lock info card (openings themselves are inert in View; the card carries an
explicit Unlock/Lock button — the only way to operate a lock from the card;
plan-icon taps never toggle locks),
room-card link icon → HA area, clean room click/tap → room fit, room hover
room-card link icon → HA area, clean single room click/tap → delayed room fit
(350 ms decision window; keyboard activation is immediate), room hover
highlight, hover tooltips (name, clean-floor area, temperature, signal).
Room, device, opening and shared-control hover is a mouse-only transient layer:
+12 -12
View File
@@ -3,7 +3,7 @@
"fixture": "synthetic-only",
"chromium": "151.0.7922.34",
"oxipng": null,
"sourceFingerprint": "fa82f6c7697f8d2ec7ab4fc2c255b9f6e5a16b3636f5823f85d93819a85d93d7",
"sourceFingerprint": "4955edd1dd8d30dad8ae5b4eb12ba23943124646e541ea491287d01550c94870",
"captureScriptSha256": "e2d14d89de793011615662af7c83c433768d4761f6cec7f0b3860c797494299e",
"command": "npm run build && node demo/docs/capture.mjs",
"scenarios": {
@@ -15,7 +15,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "fa82f6c7697f8d2ec7ab4fc2c255b9f6e5a16b3636f5823f85d93819a85d93d7",
"sourceSha256": "4955edd1dd8d30dad8ae5b4eb12ba23943124646e541ea491287d01550c94870",
"imageSha256": "de1ee062cfffa375f66ff74509ad9ddbf6aabc5c5440733fe1ff0ea5a40e96c7"
},
"view-touch": {
@@ -26,7 +26,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "fa82f6c7697f8d2ec7ab4fc2c255b9f6e5a16b3636f5823f85d93819a85d93d7",
"sourceSha256": "4955edd1dd8d30dad8ae5b4eb12ba23943124646e541ea491287d01550c94870",
"imageSha256": "f9dd5b54f86b814c00bc84149af84485f052b103cdcc9263b5c50bf5d32c69d6"
},
"space-create": {
@@ -37,7 +37,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "fa82f6c7697f8d2ec7ab4fc2c255b9f6e5a16b3636f5823f85d93819a85d93d7",
"sourceSha256": "4955edd1dd8d30dad8ae5b4eb12ba23943124646e541ea491287d01550c94870",
"imageSha256": "aab4811bba3afa6cc849cb9d3989d126b8c327b69a2f8f853e55fc077e0e9a1b"
},
"room-contour-close": {
@@ -48,7 +48,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "fa82f6c7697f8d2ec7ab4fc2c255b9f6e5a16b3636f5823f85d93819a85d93d7",
"sourceSha256": "4955edd1dd8d30dad8ae5b4eb12ba23943124646e541ea491287d01550c94870",
"imageSha256": "96a7e691ad8167103199a58b48b31ae2fc4f03e9616b65a5096dc8d0b9b51da0"
},
"plan-context-tray": {
@@ -59,7 +59,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "fa82f6c7697f8d2ec7ab4fc2c255b9f6e5a16b3636f5823f85d93819a85d93d7",
"sourceSha256": "4955edd1dd8d30dad8ae5b4eb12ba23943124646e541ea491287d01550c94870",
"imageSha256": "ebb89bacfecd16178534e1f02fed8bb7540072a691ec4d4b14407c0b8ec4455e"
},
"device-editor": {
@@ -70,7 +70,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "fa82f6c7697f8d2ec7ab4fc2c255b9f6e5a16b3636f5823f85d93819a85d93d7",
"sourceSha256": "4955edd1dd8d30dad8ae5b4eb12ba23943124646e541ea491287d01550c94870",
"imageSha256": "b40f843d7bcab902a6f697cc76735e6d3351a95443fe11ba761f0e39613120bc"
},
"device-display-preview": {
@@ -81,7 +81,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "fa82f6c7697f8d2ec7ab4fc2c255b9f6e5a16b3636f5823f85d93819a85d93d7",
"sourceSha256": "4955edd1dd8d30dad8ae5b4eb12ba23943124646e541ea491287d01550c94870",
"imageSha256": "835e9f1b9446072a2caa2421b06a669eac9e5c0e3e6cf94106dd6cc2dd0d8e2b"
},
"background-editor": {
@@ -92,7 +92,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "fa82f6c7697f8d2ec7ab4fc2c255b9f6e5a16b3636f5823f85d93819a85d93d7",
"sourceSha256": "4955edd1dd8d30dad8ae5b4eb12ba23943124646e541ea491287d01550c94870",
"imageSha256": "98a8c735165912b49b4254d2f975a61891e5737b0a9c16c627f08d60853fa8a7"
},
"room-card": {
@@ -103,7 +103,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "fa82f6c7697f8d2ec7ab4fc2c255b9f6e5a16b3636f5823f85d93819a85d93d7",
"sourceSha256": "4955edd1dd8d30dad8ae5b4eb12ba23943124646e541ea491287d01550c94870",
"imageSha256": "d47988b03e6a078508b27b5612b6eafe8ecc33c891b589ddda1cc7c71f43b7c8"
},
"device-info": {
@@ -114,7 +114,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "fa82f6c7697f8d2ec7ab4fc2c255b9f6e5a16b3636f5823f85d93819a85d93d7",
"sourceSha256": "4955edd1dd8d30dad8ae5b4eb12ba23943124646e541ea491287d01550c94870",
"imageSha256": "a10e6044376b087ba6800a31a229e4166a6797820104860f85a4659d89ee8045"
},
"pdf-export": {
@@ -125,7 +125,7 @@
},
"theme": "light",
"language": "en",
"sourceSha256": "fa82f6c7697f8d2ec7ab4fc2c255b9f6e5a16b3636f5823f85d93819a85d93d7",
"sourceSha256": "4955edd1dd8d30dad8ae5b4eb12ba23943124646e541ea491287d01550c94870",
"imageSha256": "788a1ff32d6ceb51c9fb47ea37922ab15aeddf3fdcf3686bfcb0709634c41f0e"
}
},
+8 -1
View File
@@ -380,8 +380,15 @@ export const LOW_HEADROOM_WARNING_BYTES = 15_000;
* eager View, иначе браузер растягивает кешированный pattern и оставляет стены
* размытыми в статике. Новый центр оставляет 587 Б сверху и 1 413 Б до нижней
* границы полосы; общий бюджет 301 066 Б не меняется.
*
* 2026-09-28, #691: 300 300 → 301 000 (замер 300 370). Арбитраж touch-pan
* против краевого переключения пространства и единый 350 мс plan-tap
* контроллер нужны непосредственно в View/kiosk до загрузки редактора.
* Таймер и recognizer вынесены из монолита карточки в room-fit.ts, а редакторы
* не получили нового кода. Центр оставляет 630 Б сверху и 1 370 Б до нижней
* границы полосы; общий бюджет 301 066 Б не меняется.
*/
export const INITIAL_VIEW_GZIP_CEILING = 300_300;
export const INITIAL_VIEW_GZIP_CEILING = 301_000;
export const INITIAL_VIEW_CEILING_BAND = 2_000;
/**
+51 -13
View File
@@ -3401,8 +3401,8 @@ const MUTANT_DEFINITIONS = [
+ 'is released over the original room (#152 AC7)',
patches: [{
file: 'src/houseplan-card.ts',
find: ' this._roomPointer = null;\n this._doubleFit.clear();\n this._suppressClick = true;',
replace: ' this._doubleFit.clear();\n this._suppressClick = true;',
find: ' this._roomPointer = null;\n this._clearPlanTapSequence();\n this._suppressClick = true;',
replace: ' this._clearPlanTapSequence();\n this._suppressClick = true;',
}, {
file: 'src/room-fit.ts',
find: ' if (!candidate || blocked || candidate.pointerId !== pointerId',
@@ -9405,14 +9405,14 @@ const MUTANT_DEFINITIONS = [
}],
},
{
id: 'double-fit-free-background-owner-removed',
guard: 'node --test --test-name-pattern="#449 only" test/room-fit.test.mjs',
because: 'room, device and opening owners must never become a half of the free-background '
+ 'double-fit sequence (#449 AC4–AC5)',
id: 'double-fit-plan-owner-guard-removed',
guard: 'node --test --test-name-pattern="primary clean View press" test/room-fit.test.mjs',
because: 'background and room surfaces are the only valid halves of the plan double-fit; '
+ 'devices, openings and other interactive owners must remain excluded (#691 AC6)',
patches: [{
file: 'src/room-fit.ts',
find: " if (!modality || input.mode !== 'view' || input.owner.kind !== 'background'",
replace: " if (!modality || input.mode !== 'view' || false",
find: " || (input.owner.kind !== 'background' && input.owner.kind !== 'room')",
replace: ' || false // mutant: interactive owners become plan taps',
}],
},
{
@@ -9428,13 +9428,13 @@ const MUTANT_DEFINITIONS = [
},
{
id: 'double-fit-editor-mode-enabled',
guard: 'node --test --test-name-pattern="#449 only" test/room-fit.test.mjs',
guard: 'node --test --test-name-pattern="primary clean View press" test/room-fit.test.mjs',
because: 'the shortcut belongs only to View and kiosk; editor double-click contracts must '
+ 'remain untouched (#449 AC7)',
patches: [{
file: 'src/room-fit.ts',
find: "!modality || input.mode !== 'view' || input.owner.kind !== 'background'",
replace: "!modality || input.owner.kind !== 'background'",
find: " if (!modality || input.mode !== 'view'\n || (input.owner.kind !== 'background' && input.owner.kind !== 'room')",
replace: " if (!modality\n || (input.owner.kind !== 'background' && input.owner.kind !== 'room')",
}],
},
{
@@ -9444,8 +9444,46 @@ const MUTANT_DEFINITIONS = [
+ 'room-focus cleanup, rather than resetting only camera zoom (#449 AC1, AC9)',
patches: [{
file: 'src/houseplan-card.ts',
find: " if (doubleFit) this._fitAll('double-tap');",
replace: " if (doubleFit) this._resetZoom('double-tap');",
find: " fitAll: () => this._fitAll('double-tap'),",
replace: " fitAll: () => this._resetZoom('double-tap'), // mutant: bypass canonical fit-all",
}],
},
{
id: 'space-swipe-edge-origin-guard-removed',
guard: 'node --test --test-name-pattern="edge swipe is available" test/logic.test.mjs',
because: 'a central drag and an edge without a neighbour must pan; only the 48 px strip '
+ 'beside an existing ordered neighbour may own a kiosk space swipe (#691 AC1–AC3)',
patches: [{
file: 'src/logic.ts',
find: ' const previous = index > 0 && leftDistance <= edgePx;\n'
+ ' const next = index < spaceIds.length - 1 && rightDistance <= edgePx;',
replace: ' const previous = index > 0; // mutant: every x is the left edge\n'
+ ' const next = index < spaceIds.length - 1;',
}],
},
{
id: 'space-swipe-release-ignores-final-owner',
guard: 'node --test --test-name-pattern="release obeys the final" test/logic.test.mjs',
because: 'an edge drag classified as pan by its first meaningful movement must not switch '
+ 'spaces when its final vector later looks horizontal (#691 AC3)',
patches: [{
file: 'src/logic.ts',
find: " if (owner !== 'swipe') return null;",
replace: " if (owner === 'swipe') return null; // mutant: pan vectors are reclassified",
}],
},
{
id: 'double-fit-interactive-pending-cancel-removed',
guard: 'node --test --test-name-pattern="controller owns one fake-clock" test/room-fit.test.mjs',
because: 'an interactive pointerdown must report cancellation to the card so its pending '
+ '350 ms room-fit timer cannot fire underneath the independent action (#691 AC6, AC8)',
patches: [{
file: 'src/room-fit.ts',
find: ' if (!this.recognizer.clearNonPlan(event)) return false;\n'
+ ' this.cancelPending();\n'
+ ' return true;',
replace: ' if (!this.recognizer.clearNonPlan(event)) return false;\n'
+ ' return true; // mutant: interactive intent leaves the pending timer armed',
}],
},
{
+51 -42
View File
@@ -26,13 +26,16 @@ import { compileIconRules, iconFor, type IconRule, type CompiledIconRule } from
import {
snapToGrid, samePoint, pointInPolygon, segmentCm, formatLength, roomEdges, roomPoly,
paperRoomShapes, islandsOf, distToSegment, outlineWithout, segmentAngle, is45, isExact45Vector,
swipeTarget, clampScale, roomFillModeOf, roomGlowOf, openingAmount, openingEntityReferences,
beginSpaceSwipePointer, classifySpaceDrag, spaceSwipeTargetForOwner, clampScale,
roomFillModeOf, roomGlowOf,
openingAmount, openingEntityReferences,
averageLqi, fitView, declump, safeUrl, floorsOf, type FloorInfo, stateIcon, diffNewDevices,
isControllable, spaceDisplayOf, resolveEffectiveRoomFill, fillColorsOf, customFillOf,
roomCustomFillOf, DEFAULT_CUSTOM_FILL, type FillColors, type FillColorEntry,
type ResolvedRoomFill, runServiceFor, stageBgOf, showRoomTooltipOf, volumetricViewOf, type SpaceDisplay,
referencedContentUrls, normalizeDeviceDisplay, isAlarmCapable, displayIsNeutral,
type DeviceDisplayMode, liveText, liveTextReference, hassValue, decorTextScale, decorTextLines,
type DeviceDisplayMode, type SpaceSwipePointerStart, liveText, liveTextReference, hassValue,
decorTextScale, decorTextLines,
DECOR_TEXT_BASE,
} from './logic';
import { type SafeResizePlan, type SafeResizeResolution } from './resize';
@@ -224,8 +227,7 @@ import {
type CameraState, type CameraTransitionReason, type CameraTransitionState,
} from './viewport-transition'; import { measuredCardHeaderHeight, settleSoftStageLayout } from './boot-soft-layout';
import {
acceptedRoomFitGesture, DoubleFitGestureRecognizer, roomFitCameraTarget,
roomFitClampFrame,
PlanTapGestureController, roomFitCameraTarget, roomFitClampFrame,
roomFitGeometryBounds, roomFitOwnerFromPath, STAGE_TAP_DISTANCE_PX,
type RoomFitGestureCandidate,
} from './room-fit';
@@ -1899,15 +1901,21 @@ export class HouseplanCard extends LitElement {
/** Session-only View intent. It is deliberately absent from warm/LS/config state. */
private _roomFocus: { spaceId: string; roomId: string } | null = null;
/** Pointer owner captured from the actually painted event path. */
private _roomPointer: RoomFitGestureCandidate | null = null; private readonly _doubleFit = new DoubleFitGestureRecognizer();
private _roomPointer: RoomFitGestureCandidate | null = null;
private readonly _planTaps = new PlanTapGestureController({
current: () => ({ spaceId: this._space, enabled: this._doubleFitEnabled }),
fitAll: () => this._fitAll('double-tap'),
fitRoom: (roomId) => this._fitRoom(roomId),
});
private readonly _deviceHits = new DeviceHitController(); private _deviceHitScrollUnsub?: () => void;
private _pointers = new Map<number, { x: number; y: number }>();
private _panStart: { sx: number; sy: number; vx: number; vy: number } | null = null;
/**
* What the current one-finger drag turned out to be, decided ONCE on the
* first real movement and held until the finger lifts (see
* `_stagePointerMove`). Only the kiosk has two candidates — a horizontal
* drag there is the floor swipe; everywhere else a drag always pans.
* `_stagePointerMove`). Only the kiosk has two candidates — an inward,
* sufficiently horizontal drag that starts inside the active 48 px edge
* strip is a floor swipe; every other drag pans.
*/
private _panLock: 'pan' | 'swipe' | null = null;
private _pinchStart: { dist: number; zoom: number } | null = null;
@@ -2290,7 +2298,7 @@ export class HouseplanCard extends LitElement {
private _kioskHoldTimer?: number;
private _cycleTimer?: number;
private _cyclePausedUntil = 0;
private _swipeStart: { x: number; y: number; id: number } | null = null;
private _swipeStart: SpaceSwipePointerStart | null = null;
/** Live tab reorder: which tab is held, where it started, where it would land. */
private _tabDrag: {
@@ -5528,7 +5536,7 @@ export class HouseplanCard extends LitElement {
this._pinchStart = null;
this._swipeStart = null;
this._roomPointer = null;
this._doubleFit.clear();
this._clearPlanTapSequence();
this._deviceHits.clearPointers();
}
@@ -6114,8 +6122,12 @@ export class HouseplanCard extends LitElement {
this._resetZoom();
}
private _clearPlanTapSequence(): void {
this._planTaps.clear();
}
private _clearRoomFocus(pointer = false): void {
this._doubleFit.clear();
this._clearPlanTapSequence();
this._roomFocus = null;
if (pointer) this._roomPointer = null;
}
@@ -6160,7 +6172,7 @@ export class HouseplanCard extends LitElement {
/** Apply or retarget one room command without introducing a second RAF owner. */
private _fitRoom(roomId: string, animate = true): boolean {
if (this._mode !== 'view') return false;
this._doubleFit.clear();
this._clearPlanTapSequence();
const space = this._spaceModel();
const room = space?.rooms.find((item) => item.id === roomId);
if (!space || !room) {
@@ -6658,11 +6670,13 @@ export class HouseplanCard extends LitElement {
}
private _stagePointerDown(ev: PointerEvent): void {
this._bootSoftCancel(); const roomId = ev.isPrimary && ev.button === 0 && this._mode === 'view' ? roomFitOwnerFromPath(ev.composedPath()) : null;
this._bootSoftCancel();
const roomId = ev.isPrimary && ev.button === 0 && this._mode === 'view'
? roomFitOwnerFromPath(ev.composedPath()) : null;
this._roomPointer = roomId
? { pointerId: ev.pointerId, spaceId: this._space, roomId }
: null;
this._doubleFit.pointerDown(ev, this._space, this._doubleFitEnabled);
this._planTaps.pointerDown(ev, this._space, this._doubleFitEnabled);
// The gesture that starts here freezes the animated frame and keeps it on
// screen — so the shown zoom becomes the saved one (#396 AC1).
this._cancelCameraTransition(false, true);
@@ -6674,13 +6688,17 @@ export class HouseplanCard extends LitElement {
if (this._kiosk) {
this._cyclePausedUntil = Date.now() + 60000;
if (this._pointers.size === 0) {
this._swipeStart = { x: ev.clientX, y: ev.clientY, id: ev.pointerId };
const rect = this._stageEl?.getBoundingClientRect();
this._swipeStart = rect ? beginSpaceSwipePointer(
this._swipeZone, ev.clientX, ev.clientY, ev.pointerId, rect.left, rect.width,
this._model.map((space) => space.id), this._space,
) : null;
// long-press on EMPTY stage opens the per-screen size popover
if (!(ev.target as HTMLElement).closest?.('.dev, .roomlabel, .oplock')) {
clearTimeout(this._kioskHoldTimer);
this._kioskHoldTimer = window.setTimeout(() => {
this._roomPointer = null;
this._doubleFit.clear();
this._clearPlanTapSequence();
this._kioskDialog = true;
this._swipeStart = null;
}, 3000);
@@ -6812,7 +6830,7 @@ export class HouseplanCard extends LitElement {
const ddy = ev.clientY - this._panStart.sy;
if (Math.abs(ddx) + Math.abs(ddy) > 4) {
this._roomPointer = null;
this._doubleFit.clear();
this._clearPlanTapSequence();
this._suppressClick = true;
clearTimeout(this._holdTimer);
if (this._tool === 'opening') {
@@ -6830,7 +6848,9 @@ export class HouseplanCard extends LitElement {
// make a drag meaningless — `_clampView` alone decides how far you may
// walk, at 400% and at 33% alike.
if (this._panLock === null && Math.abs(ddx) + Math.abs(ddy) > STAGE_TAP_DISTANCE_PX) {
this._panLock = this._swipeZone && Math.abs(ddx) > Math.abs(ddy) * 1.5 ? 'swipe' : 'pan';
const edge = this._swipeStart?.id === ev.pointerId ? this._swipeStart.edge : null;
const owner = classifySpaceDrag(edge, ddx, ddy, STAGE_TAP_DISTANCE_PX);
this._panLock = owner === 'swipe' ? 'swipe' : 'pan';
if (this._panLock === 'pan') { this._activateSafeDayCycleOutline(); this._clearRoomFocus(); }
}
const stage = this._stageEl;
@@ -6869,17 +6889,12 @@ export class HouseplanCard extends LitElement {
this._deviceHits.release(ev.pointerId);
this._flushHa();
this._editorRuntime?._cancelPointerMove('markup-hover');
const acceptedRoom = acceptedRoomFitGesture(
this._roomPointer,
ev.pointerId,
this._space,
this._roomPointer && this._mode === 'view' ? roomFitOwnerFromPath(ev.composedPath()) : null,
this._suppressClick || !!this._pinchStart || this._panLock !== null
|| this._holdFired || this._touchSequenceMultitouch,
const blockedRoomTap = this._suppressClick || !!this._pinchStart || this._panLock !== null
|| this._holdFired || this._touchSequenceMultitouch;
const acceptedRoom = this._planTaps.pointerUp(
ev, this._roomPointer, this._space, this._doubleFitEnabled, blockedRoomTap,
);
if (this._roomPointer?.pointerId === ev.pointerId) this._roomPointer = null;
const doubleFit = this._doubleFit.pointerUp(ev, this._space, this._doubleFitEnabled, this._suppressClick || !!this._pinchStart || this._panLock !== null || this._holdFired);
if (doubleFit) this._fitAll('double-tap');
if (this._kiosk) {
clearTimeout(this._kioskHoldTimer);
const ss = this._swipeStart;
@@ -6887,19 +6902,11 @@ export class HouseplanCard extends LitElement {
if (!acceptedRoom && ss && ss.id === ev.pointerId) {
const dx = ev.clientX - ss.x;
const dy = ev.clientY - ss.y;
// The lock is FINAL (audit DEV-1DA1-02). `_stagePointerMove` decided
// once, on the first movement worth the name, whether this gesture is
// a swipe or a pan — and the release may not overturn it. Until this
// the release asked `swipeTarget()` again from the raw start→end
// vector, so a CURVED gesture (a small vertical lead-in that locks
// 'pan', then a long horizontal sweep) dragged the plan under the
// finger and still landed on another storey when it lifted. A pan is
// a pan to the end: no floor change, whatever the overall vector
// happens to look like. A motionless tap never locks anything, so it
// remains eligible for the shared free-background double-fit recognizer.
const target = this._panLock === 'pan'
? null
: swipeTarget(dx, dy, this._zoom, this._model.map((m) => m.id), this._space);
// Release obeys the first-movement owner: a curved pan never becomes a
// floor change merely because its final vector looks horizontal.
const target = spaceSwipeTargetForOwner(
this._panLock, dx, dy, this._zoom, this._model.map((m) => m.id), this._space,
);
if (target) {
// the plan follows the finger: swiping left brings the next one in
// from the right, so the current one leaves to the left
@@ -6948,7 +6955,6 @@ export class HouseplanCard extends LitElement {
if (this._suppressClick) setTimeout(() => (this._suppressClick = false), 0);
}
if (this._pointers.size === 0 && !acceptedRoom) this._finishViewportGesture();
if (acceptedRoom) this._fitRoom(acceptedRoom);
}
private _clickRoom(r: RoomCfg): void {
@@ -7171,7 +7177,9 @@ export class HouseplanCard extends LitElement {
if (this._touchClickGuard.handleActivation(ev, this._suppressClick)) return;
const pointer = ev as PointerEvent;
this._notePointer(pointer);
if (ev.type === 'pointercancel' || ev.type === 'lostpointercapture') this._doubleFit.clear(); else if (ev.type === 'pointerdown') this._doubleFit.clearOutside(pointer);
if (ev.type === 'pointercancel' || ev.type === 'lostpointercapture') {
this._clearPlanTapSequence();
} else if (ev.type === 'pointerdown') this._planTaps.clearNonPlan(pointer);
if (ev.type === 'pointerdown') this._touchClickGuard.pointerDown(
pointer.pointerId, pointer.pointerType,
);
@@ -7721,7 +7729,8 @@ export class HouseplanCard extends LitElement {
this._deviceHits.cancel(ev.pointerId);
this._flushHa();
this._editorRuntime?._cancelPointerMove('markup-hover');
if (this._roomPointer?.pointerId === ev.pointerId) this._roomPointer = null; this._doubleFit.clear();
if (this._roomPointer?.pointerId === ev.pointerId) this._roomPointer = null;
this._clearPlanTapSequence();
this._stairsView.pointerCancel(ev);
if (this._editorRuntime?.stairs.pointerCancel(ev)) return;
if (this._editorRuntime) return this._editorRuntime._stagePointerCancel(ev);
+83
View File
@@ -1998,6 +1998,89 @@ export function migratePdfUrls<T extends { url: string }>(
// ---------------- kiosk gestures ----------------
export const SPACE_SWIPE_EDGE_PX = 48;
export type SpaceSwipeEdge = 'previous' | 'next';
export type SpaceDragOwner = 'pending' | 'pan' | 'swipe';
export interface SpaceSwipePointerStart {
x: number;
y: number;
id: number;
edge: SpaceSwipeEdge;
}
/**
* The only places where a kiosk drag may become a space swipe. A missing
* neighbour deliberately leaves the edge to ordinary pan, so the first and
* last spaces never acquire a dead strip. Screen coordinates keep this
* independent from plan zoom, DPR and viewBox geometry.
*/
export function spaceSwipeEdgeAt(
clientX: number,
stageLeft: number,
stageWidth: number,
spaceIds: readonly string[],
current: string,
edgePx = SPACE_SWIPE_EDGE_PX,
): SpaceSwipeEdge | null {
if (!Number.isFinite(clientX) || !Number.isFinite(stageLeft)
|| !Number.isFinite(stageWidth) || stageWidth <= 0
|| !Number.isFinite(edgePx) || edgePx <= 0) return null;
const offset = clientX - stageLeft;
if (offset < 0 || offset > stageWidth) return null;
const index = spaceIds.indexOf(current);
if (index < 0) return null;
const leftDistance = offset;
const rightDistance = stageWidth - offset;
const previous = index > 0 && leftDistance <= edgePx;
const next = index < spaceIds.length - 1 && rightDistance <= edgePx;
if (previous && next) return leftDistance <= rightDistance ? 'previous' : 'next';
if (previous) return 'previous';
if (next) return 'next';
return null;
}
export function beginSpaceSwipePointer(
enabled: boolean,
clientX: number,
clientY: number,
pointerId: number,
stageLeft: number,
stageWidth: number,
spaceIds: readonly string[],
current: string,
): SpaceSwipePointerStart | null {
if (!enabled) return null;
const edge = spaceSwipeEdgeAt(clientX, stageLeft, stageWidth, spaceIds, current);
return edge ? { x: clientX, y: clientY, id: pointerId, edge } : null;
}
/** One irreversible classification after the existing eight-pixel boundary. */
export function classifySpaceDrag(
edge: SpaceSwipeEdge | null,
dx: number,
dy: number,
minPx = 8,
): SpaceDragOwner {
if (!Number.isFinite(dx) || !Number.isFinite(dy)
|| !Number.isFinite(minPx) || minPx < 0) return 'pan';
if (Math.abs(dx) + Math.abs(dy) <= minPx) return 'pending';
const inward = edge === 'previous' ? dx > 0 : edge === 'next' && dx < 0;
return inward && Math.abs(dx) > Math.abs(dy) * 1.5 ? 'swipe' : 'pan';
}
/** Release may consult the vector only after the first-movement owner is final. */
export function spaceSwipeTargetForOwner(
owner: 'pan' | 'swipe' | null,
dx: number,
dy: number,
zoom: number,
spaceIds: string[],
current: string,
): string | null {
if (owner !== 'swipe') return null;
return swipeTarget(dx, dy, zoom, spaceIds, current);
}
/**
* Kiosk swipe: which neighbouring space a horizontal gesture selects.
* Only fires at 1:1 zoom (owner's decision — when zoomed the gesture pans),
+109 -9
View File
@@ -36,6 +36,7 @@ export interface DoubleFitPointerCandidate {
modality: DoubleFitPointerModality;
x: number;
y: number;
roomId?: string;
}
export interface DoubleFitTapSequence {
@@ -77,6 +78,7 @@ export interface DoubleFitPointerUpInput {
export interface DoubleFitResult {
sequence: DoubleFitTapSequence | null;
trigger: boolean;
roomId?: string;
}
interface DoubleFitPointerEventLike {
@@ -114,9 +116,9 @@ const pathMatches = (node: RoomFitPathNode, selector: string): boolean => {
};
/**
* One browser-path authority for room fit and the free-background shortcut.
* The first independently interactive owner wins; only a path which reaches
* this card's stage without one is free background.
* One browser-path authority for room fit and the plan-surface shortcut.
* The first independently interactive owner wins; otherwise a room surface or
* the stage background may contribute one clean tap.
*/
export function planGestureOwnerFromPath(path: readonly unknown[]): PlanGestureOwner {
for (const raw of path) {
@@ -152,7 +154,8 @@ export function beginDoubleFitPointer(
input: DoubleFitPointerDownInput,
): DoubleFitPointerCandidate | null {
const modality = pointerModality(input.pointerType);
if (!modality || input.mode !== 'view' || input.owner.kind !== 'background'
if (!modality || input.mode !== 'view'
|| (input.owner.kind !== 'background' && input.owner.kind !== 'room')
|| input.blocked || !input.isPrimary || input.button !== 0
|| !Number.isFinite(input.x) || !Number.isFinite(input.y)) return null;
return {
@@ -161,6 +164,7 @@ export function beginDoubleFitPointer(
modality,
x: input.x,
y: input.y,
...(input.owner.kind === 'room' ? { roomId: input.owner.roomId } : {}),
};
}
@@ -170,7 +174,10 @@ export function completeDoubleFitPointer(
candidate: DoubleFitPointerCandidate | null,
input: DoubleFitPointerUpInput,
): DoubleFitResult {
if (!candidate || input.mode !== 'view' || input.owner.kind !== 'background'
const sameOwner = !!candidate && (candidate.roomId
? input.owner.kind === 'room' && input.owner.roomId === candidate.roomId
: input.owner.kind === 'background');
if (!candidate || input.mode !== 'view' || !sameOwner
|| input.blocked || candidate.pointerId !== input.pointerId
|| candidate.spaceId !== input.spaceId
|| !Number.isFinite(input.x) || !Number.isFinite(input.y)
@@ -189,6 +196,7 @@ export function completeDoubleFitPointer(
return {
sequence: { at: input.now, spaceId: input.spaceId, modality: candidate.modality },
trigger: false,
...(candidate.roomId ? { roomId: candidate.roomId } : {}),
};
}
@@ -202,10 +210,14 @@ export class DoubleFitGestureRecognizer {
this.sequence = null;
}
clearOutside(event: DoubleFitPointerEventLike): void {
clearNonPlan(event: DoubleFitPointerEventLike): boolean {
let owner: PlanGestureOwner = { kind: 'outside' };
try { owner = planGestureOwnerFromPath(event.composedPath()); } catch { /* fail closed */ }
if (owner.kind === 'outside') this.clear();
if (owner.kind !== 'background' && owner.kind !== 'room') {
this.clear();
return true;
}
return false;
}
pointerDown(event: DoubleFitPointerEventLike, spaceId: string, enabled: boolean): void {
@@ -226,7 +238,7 @@ export class DoubleFitGestureRecognizer {
enabled: boolean,
blocked: boolean,
now = Date.now(),
): boolean {
): DoubleFitResult {
let owner: PlanGestureOwner = { kind: 'outside' };
try { owner = planGestureOwnerFromPath(event.composedPath()); } catch { /* fail closed */ }
const result = completeDoubleFitPointer(this.sequence, this.pointer, {
@@ -235,7 +247,95 @@ export class DoubleFitGestureRecognizer {
});
this.pointer = null;
this.sequence = result.sequence;
return result.trigger;
return result;
}
}
export interface PlanTapTimerPort {
set(callback: () => void, delayMs: number): unknown;
clear(handle: unknown): void;
}
export interface PlanTapGestureActions {
current(): { spaceId: string; enabled: boolean };
fitAll(): void;
fitRoom(roomId: string): void;
}
const browserPlanTapTimers: PlanTapTimerPort = {
set: (callback, delayMs) => globalThis.setTimeout(callback, delayMs),
clear: (handle) => globalThis.clearTimeout(handle as number),
};
/** One owner for the double-fit recognizer and its sole delayed room command. */
export class PlanTapGestureController {
private readonly recognizer = new DoubleFitGestureRecognizer();
private pending: { spaceId: string; roomId: string } | null = null;
private timer: unknown;
constructor(
private readonly actions: PlanTapGestureActions,
private readonly timers: PlanTapTimerPort = browserPlanTapTimers,
) {}
cancelPending(): void {
if (this.timer !== undefined) this.timers.clear(this.timer);
this.timer = undefined;
this.pending = null;
}
clear(): void {
this.cancelPending();
this.recognizer.clear();
}
clearNonPlan(event: DoubleFitPointerEventLike): boolean {
if (!this.recognizer.clearNonPlan(event)) return false;
this.cancelPending();
return true;
}
pointerDown(event: DoubleFitPointerEventLike, spaceId: string, enabled: boolean): void {
this.cancelPending();
this.recognizer.pointerDown(event, spaceId, enabled);
}
pointerUp(
event: DoubleFitPointerEventLike,
roomCandidate: RoomFitGestureCandidate | null,
spaceId: string,
enabled: boolean,
blocked: boolean,
now = Date.now(),
): string | null {
const acceptedRoom = acceptedRoomFitGesture(
roomCandidate, event.pointerId, spaceId,
roomCandidate && enabled ? roomFitOwnerFromPath(event.composedPath()) : null,
blocked,
);
const result = this.recognizer.pointerUp(event, spaceId, enabled, blocked, now);
if (result.trigger) this.actions.fitAll();
else if (result.roomId && acceptedRoom === result.roomId) {
this.schedule(spaceId, result.roomId);
} else if (acceptedRoom) this.actions.fitRoom(acceptedRoom);
return acceptedRoom;
}
private schedule(spaceId: string, roomId: string): void {
this.cancelPending();
const pending = { spaceId, roomId };
this.pending = pending;
this.timer = this.timers.set(() => {
this.timer = undefined;
if (this.pending !== pending) return;
this.pending = null;
const current = this.actions.current();
if (current.spaceId !== pending.spaceId || !current.enabled) {
this.recognizer.clear();
return;
}
this.actions.fitRoom(pending.roomId);
}, DOUBLE_FIT_WINDOW_MS);
}
}
+36 -1
View File
@@ -10,7 +10,7 @@ import {
sharedBoundary, distToSegment,
outlineWithout,
alignGuides, segmentAngle, is45,
swipeTarget, clampScale,
swipeTarget, spaceSwipeEdgeAt, classifySpaceDrag, spaceSwipeTargetForOwner, clampScale,
migratePdfUrls,
roomFillModeOf, roomGlowOf, customFillOf, roomCustomFillOf, roomTempRangeOf,
contentUrl, chunk, referencedContentUrls, MAX_SIGN_PATHS,
@@ -1042,6 +1042,41 @@ test('swipeTarget: kiosk swipe rules', () => {
assert.equal(swipeTarget(-100, 0, 1, ['f1'], 'f1'), null);
});
test('#691 edge swipe is available only beside an existing ordered neighbour', () => {
const ids = ['f1', 'f2', 'garden'];
assert.equal(spaceSwipeEdgeAt(100, 100, 900, ids, 'f2'), 'previous');
assert.equal(spaceSwipeEdgeAt(148, 100, 900, ids, 'f2'), 'previous');
assert.equal(spaceSwipeEdgeAt(149, 100, 900, ids, 'f2'), null);
assert.equal(spaceSwipeEdgeAt(952, 100, 900, ids, 'f2'), 'next');
assert.equal(spaceSwipeEdgeAt(951, 100, 900, ids, 'f2'), null);
assert.equal(spaceSwipeEdgeAt(100, 100, 900, ids, 'f1'), null);
assert.equal(spaceSwipeEdgeAt(952, 100, 900, ids, 'f1'), 'next');
assert.equal(spaceSwipeEdgeAt(100, 100, 900, ids, 'garden'), 'previous');
assert.equal(spaceSwipeEdgeAt(952, 100, 900, ids, 'garden'), null);
assert.equal(spaceSwipeEdgeAt(130, 100, 80, ids, 'f2'), 'previous');
assert.equal(spaceSwipeEdgeAt(160, 100, 80, ids, 'f2'), 'next');
assert.equal(spaceSwipeEdgeAt(NaN, 100, 900, ids, 'f2'), null);
assert.equal(spaceSwipeEdgeAt(99, 100, 900, ids, 'f2'), null);
});
test('#691 edge swipe keeps the 8 px and strict 1.5 direction boundaries', () => {
assert.equal(classifySpaceDrag('previous', 8, 0), 'pending');
assert.equal(classifySpaceDrag('previous', 9, 6), 'pan');
assert.equal(classifySpaceDrag('previous', 10, 6), 'swipe');
assert.equal(classifySpaceDrag('next', -10, 6), 'swipe');
assert.equal(classifySpaceDrag('next', 10, 0), 'pan');
assert.equal(classifySpaceDrag(null, -100, 0), 'pan');
assert.equal(classifySpaceDrag('next', NaN, 0), 'pan');
});
test('#691 release obeys the final drag owner instead of reclassifying the vector', () => {
const ids = ['f1', 'f2', 'f3'];
assert.equal(spaceSwipeTargetForOwner('pan', -100, 0, 1, ids, 'f1'), null);
assert.equal(spaceSwipeTargetForOwner(null, -100, 0, 1, ids, 'f1'), null);
assert.equal(spaceSwipeTargetForOwner('swipe', -100, 0, 1, ids, 'f1'), 'f2');
assert.equal(spaceSwipeTargetForOwner('swipe', 100, 0, 1, ids, 'f2'), 'f1');
});
test('clampScale', () => {
assert.equal(clampScale(2), 2);
assert.equal(clampScale(9), 3);
+118 -6
View File
@@ -5,6 +5,7 @@ import {
beginDoubleFitPointer,
completeDoubleFitPointer,
DoubleFitGestureRecognizer,
PlanTapGestureController,
DOUBLE_FIT_WINDOW_MS,
planGestureOwnerFromPath,
roomFitCameraTarget,
@@ -157,6 +158,7 @@ test('#152 release accepts only the same pointer, space and painted room', () =>
});
const background = { kind: 'background' };
const roomOwner = { kind: 'room', roomId: 'room-a' };
const down = (overrides = {}) => beginDoubleFitPointer({
pointerId: 1,
pointerType: 'mouse',
@@ -186,10 +188,14 @@ const up = (sequence, candidate, overrides = {}) => completeDoubleFitPointer(
},
);
test('#449 only a primary clean View press on free stage background becomes a candidate', () => {
test('#691 only a primary clean View press on a plan surface becomes a candidate', () => {
assert.deepEqual(down(), {
pointerId: 1, spaceId: 'floor-a', modality: 'mouse', x: 100, y: 100,
});
assert.deepEqual(down({ owner: roomOwner }), {
pointerId: 1, spaceId: 'floor-a', modality: 'mouse', x: 100, y: 100,
roomId: 'room-a',
});
assert.equal(down({ pointerType: 'touch' }).modality, 'touch');
assert.equal(down({ pointerType: 'pen' }).modality, 'pen');
for (const rejected of [
@@ -197,7 +203,6 @@ test('#449 only a primary clean View press on free stage background becomes a ca
{ isPrimary: false },
{ button: 2 },
{ mode: 'plan' },
{ owner: { kind: 'room', roomId: 'room-a' } },
{ owner: { kind: 'interactive' } },
{ owner: { kind: 'outside' } },
{ blocked: true },
@@ -216,6 +221,32 @@ test('#449 two clean taps within 350 ms trigger once and clear before the comman
});
});
test('#691 a first room tap arms delayed room-fit and any second plan tap fits all', () => {
const roomCandidate = down({ owner: roomOwner });
const first = up(null, roomCandidate, { owner: roomOwner });
assert.deepEqual(first, {
sequence: { at: 1_000, spaceId: 'floor-a', modality: 'mouse' },
trigger: false,
roomId: 'room-a',
});
const roomToBackground = up(first.sequence, down({ pointerId: 2 }), {
pointerId: 2, now: 1_200,
});
assert.deepEqual(roomToBackground, { sequence: null, trigger: true });
const backgroundFirst = up(null, down(), { now: 2_000 });
const backgroundToRoom = up(
backgroundFirst.sequence,
down({ pointerId: 3, owner: roomOwner }),
{ pointerId: 3, owner: roomOwner, now: 2_100 },
);
assert.deepEqual(backgroundToRoom, { sequence: null, trigger: true });
assert.deepEqual(up(null, roomCandidate, {
owner: { kind: 'room', roomId: 'room-b' },
}), { sequence: null, trigger: false });
});
test('#449 an expired tap becomes the new first tap and modalities never mix', () => {
const old = { at: 1_000, spaceId: 'floor-a', modality: 'mouse' };
const expired = up(old, down(), { now: 1_000 + DOUBLE_FIT_WINDOW_MS + 1 });
@@ -260,12 +291,93 @@ test('#449 recognizer instances keep independent transient sequences', () => {
const first = new DoubleFitGestureRecognizer();
const second = new DoubleFitGestureRecognizer();
first.pointerDown(event(1), 'floor-a', true);
assert.equal(first.pointerUp(event(1), 'floor-a', true, false, 1_000), false);
assert.equal(first.pointerUp(event(1), 'floor-a', true, false, 1_000).trigger, false);
second.pointerDown(event(2), 'floor-a', true);
assert.equal(second.pointerUp(event(2), 'floor-a', true, false, 1_100), false);
assert.equal(second.pointerUp(event(2), 'floor-a', true, false, 1_100).trigger, false);
first.pointerDown(event(3), 'floor-a', true);
assert.equal(first.pointerUp(event(3), 'floor-a', true, false, 1_200), true);
assert.equal(first.pointerUp(event(3), 'floor-a', true, false, 1_200).trigger, true);
second.clear();
second.pointerDown(event(4), 'floor-a', true);
assert.equal(second.pointerUp(event(4), 'floor-a', true, false, 1_250), false);
assert.equal(second.pointerUp(event(4), 'floor-a', true, false, 1_250).trigger, false);
});
test('#691 an interactive or outside pointerdown disarms the plan sequence', () => {
const stage = pathNode(['.stage']);
const interactive = pathNode(['.dev']);
const event = (pointerId, path = [stage]) => ({
pointerId, pointerType: 'touch', isPrimary: true, button: 0,
clientX: 10, clientY: 10, composedPath: () => path,
});
const recognizer = new DoubleFitGestureRecognizer();
recognizer.pointerDown(event(1), 'floor-a', true);
recognizer.pointerUp(event(1), 'floor-a', true, false, 1_000);
assert.equal(recognizer.clearNonPlan(event(2, [interactive, stage])), true);
recognizer.pointerDown(event(3), 'floor-a', true);
assert.equal(recognizer.pointerUp(event(3), 'floor-a', true, false, 1_100).trigger, false);
assert.equal(recognizer.clearNonPlan(event(4, [stage])), false);
});
test('#691 controller owns one fake-clock room timer and cancels it on second intent', () => {
const stage = pathNode(['.stage']);
const room = pathNode(['.roomlabel[data-id]'], 'room-a');
const interactive = pathNode(['.dev']);
const pointer = (pointerId, path, pointerType = 'touch') => ({
pointerId, pointerType, isPrimary: true, button: 0,
clientX: 20, clientY: 30, composedPath: () => path,
});
let nextTimer = 1;
const timers = new Map();
const delays = [];
const timerPort = {
set(callback, delayMs) {
const id = nextTimer++;
timers.set(id, callback);
delays.push(delayMs);
return id;
},
clear(handle) { timers.delete(handle); },
};
const calls = [];
let current = { spaceId: 'floor-a', enabled: true };
const controller = new PlanTapGestureController({
current: () => current,
fitAll: () => calls.push('all'),
fitRoom: (roomId) => calls.push(`room:${roomId}`),
}, timerPort);
controller.pointerDown(pointer(1, [room, stage]), 'floor-a', true);
assert.equal(controller.pointerUp(
pointer(1, [room, stage]),
{ pointerId: 1, spaceId: 'floor-a', roomId: 'room-a' },
'floor-a', true, false, 1_000,
), 'room-a');
assert.deepEqual(calls, []);
assert.deepEqual(delays, [DOUBLE_FIT_WINDOW_MS]);
assert.equal(timers.size, 1);
controller.pointerDown(pointer(2, [stage]), 'floor-a', true);
assert.equal(timers.size, 0, 'a second clean pointer cancels pending room motion');
controller.pointerUp(pointer(2, [stage]), null, 'floor-a', true, false, 1_100);
assert.deepEqual(calls, ['all']);
assert.equal(timers.size, 0);
calls.length = 0;
controller.pointerDown(pointer(3, [room, stage]), 'floor-a', true);
controller.pointerUp(
pointer(3, [room, stage]),
{ pointerId: 3, spaceId: 'floor-a', roomId: 'room-a' },
'floor-a', true, false, 2_000,
);
assert.equal(controller.clearNonPlan(pointer(4, [interactive, stage])), true);
assert.equal(timers.size, 0, 'interactive intent cancels the pending room command');
controller.pointerDown(pointer(5, [room, stage]), 'floor-a', true);
controller.pointerUp(
pointer(5, [room, stage]),
{ pointerId: 5, spaceId: 'floor-a', roomId: 'room-a' },
'floor-a', true, false, 3_000,
);
current = { spaceId: 'floor-b', enabled: true };
for (const callback of [...timers.values()]) callback();
assert.deepEqual(calls, [], 'a stale-space timer is inert');
});