Drag the plan at any zoom, not only above 100%

Owner's report 2026-08-04: «добавь возможность таскать план при любом
масштабе, а не только при более 100%, как сейчас (и в редакторах, и в
просмотре)».

_stagePointerMove moved the view only while `_zoom > 1`. That gate is
older than the infinite canvas and made sense under the old rule — the
content had to cover the scene, so at 100% or below there was literally
nowhere to go and a drag could only jitter. The infinite canvas removed
the edge and gave panning a slack of one screen past the content in
every direction (CANVAS.md §5), and from that moment the gate was not a
guard but a missing feature: at 100% you could see the arrow «home is
that way» light up from a wheel-zoom, and still not drag the plan an
inch. The zoom no longer takes part in the decision — `_clampView`
alone says how far you may walk, at 400% and at 33% alike.

The drag also stopped depending on `_view` being materialised: it reads
`_viewOr(baseVb)`, so the very first drag on a freshly opened space
pans instead of doing nothing.

Gesture ownership is unchanged, and that is the point of most of the
new smoke: `_stagePointerDown` still bails out on the room-resize
handles, device badges, openings, room labels and decor shapes, and on
a decor drawing tool that consumes the press; two fingers are still a
pinch. The one place where a drag had a rival is the kiosk, where a
horizontal swipe changes floors. It is now classified once per gesture,
on the first movement past 8px (`_panLock`): horizontal in the swipe
zone (kiosk, zoom <= 1, more than one space) = swipe and no pan,
everything else = pan. So the plan never slides out from under a swipe,
a vertical drag on a wall tablet pans as it does everywhere else, and
zoomed in — where swipeTarget already refuses — a horizontal drag pans.

demo/smoke_pan_any_zoom.mjs: a drag on empty scene moves the view at
100%, 50% and 1/3 in View and in every editor (all seven plan tools,
Devices, Background), and at 400% as before; the walk stops at the
PAN_SLACK limit and the home arrow appears; a resize handle resizes, a
device badge moves the device and an opening slides along its wall,
none of them panning a pixel; two fingers still zoom; the kiosk still
swipes floors through a gesture that has real pointermove events in it
(smoke_kiosk only ever sent down+up), a vertical drag there pans, and a
zoomed-in horizontal drag pans without changing the floor.

Before the fix 25 of its checks are red, including every editor at
every zoom.
This commit is contained in:
houseplan-dev
2026-08-04 00:43:05 +03:00
parent 2c947f4f7a
commit fd72330549
8 changed files with 446 additions and 14 deletions
File diff suppressed because one or more lines are too long
+123
View File
@@ -0,0 +1,123 @@
// «Открыть/закрыть» on a curtain whose cover entity is NOT the primary one
// (owner's report 2026-08-04: «в настройках "открыть\закрыть", а по нажатию
// по-прежнему инфо-карточка»).
//
// The device below is his, entity for entity: an Aqara «Roller shade driver
// E1» ships the `cover.*` HIDDEN by the integration and a perfectly visible
// `switch.*_reverse_direction` beside it. `primaryEntity` ranks visible above
// hidden and `switch` above `cover`, so the marker's primary was the service
// switch — the tap then resolved on the domain `switch` and the action the
// user had explicitly chosen degraded to the info card, while the dialog kept
// OFFERING the action, because that check already looked at every entity of
// the device. The tap now finds the cover the same way the climate
// temperature finds the thermostat: among all of them.
//
// End to end: the action is chosen in the dialog and SAVED through the normal
// path, and the click is done on the marker the card rebuilt from that config.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const out = await page.evaluate(async () => {
const o = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const calls = [];
// ---- the owner's curtain driver, joined to the demo house --------------
const ENTS = {
// hidden by the integration — exactly as z2m ships it
'cover.office_curtain': { entity_id: 'cover.office_curtain', device_id: 'd_curtain', platform: 'demo', hidden: true },
'sensor.office_curtain_battery': { entity_id: 'sensor.office_curtain_battery', device_id: 'd_curtain', platform: 'demo', entity_category: 'diagnostic' },
'switch.office_curtain_reverse': { entity_id: 'switch.office_curtain_reverse', device_id: 'd_curtain', platform: 'demo' },
'sensor.office_curtain_motor_state': { entity_id: 'sensor.office_curtain_motor_state', device_id: 'd_curtain', platform: 'demo' },
};
const coverState = (state, attrs = {}) => ({
entity_id: 'cover.office_curtain', state,
attributes: { friendly_name: 'Office curtain', ...attrs },
});
const push = async (state, attrs = {}) => {
c.hass = {
...c.hass,
devices: { ...c.hass.devices,
d_curtain: { id: 'd_curtain', name: 'Office curtain', model: 'Roller shade driver E1',
area_id: 'bedroom', identifiers: [['demo', 'd_curtain']], entry_type: null, via_device_id: null } },
entities: { ...c.hass.entities, ...ENTS },
states: { ...c.hass.states,
'cover.office_curtain': coverState(state, attrs),
'sensor.office_curtain_battery': { entity_id: 'sensor.office_curtain_battery', state: '84', attributes: { device_class: 'battery', unit_of_measurement: '%' } },
'switch.office_curtain_reverse': { entity_id: 'switch.office_curtain_reverse', state: 'off', attributes: { friendly_name: 'Reverse direction' } },
'sensor.office_curtain_motor_state': { entity_id: 'sensor.office_curtain_motor_state', state: 'stopped', attributes: {} },
},
callService: async (dom, svc, data) => { calls.push([dom, svc, data]); return {}; },
};
c._regSignature = '';
c._maybeRebuildDevices();
c.requestUpdate();
await c.updateComplete;
};
await push('closed', { device_class: 'curtain' });
const curtain = () => c._devices.find((x) => x.bindingRef === 'd_curtain');
o.deviceIsOnThePlan = !!curtain();
// the premise of the whole report: the cover is NOT the primary entity
o.primaryIsNotTheCover = curtain()?.primary === 'switch.office_curtain_reverse';
o.coverIsAmongTheEntities = (curtain()?.entities || []).includes('cover.office_curtain');
// ---- the dialog offers the action and SAVES it -------------------------
c._setMode('devices'); await c.updateComplete;
c._openMarkerDialog(curtain()); await c.updateComplete;
const optionValues = () => [...sr().querySelectorAll('.dialog option')].map((e) => e.value);
o.dialogOffersCover = optionValues().includes('cover');
c._markerDialog = { ...c._markerDialog, tapAction: 'cover' };
await c.updateComplete;
await c._saveMarker();
await c.updateComplete;
const saved = (c._serverCfg.markers || []).find((m) => m.binding === 'device:d_curtain');
o.markerSaved = saved?.tap_action === 'cover';
// ---- and the tap on THAT marker opens the curtain -----------------------
c._setMode('view'); c._regSignature = ''; c._maybeRebuildDevices();
c.requestUpdate(); await c.updateComplete;
const tap = async () => {
c._clickDevice({ stopPropagation() {} }, curtain());
await c.updateComplete;
await new Promise((r) => setTimeout(r, 10));
};
o.tapActionSurvivedTheRebuild = curtain()?.tapAction === 'cover';
calls.length = 0;
await tap();
o.closedOpensTheCover = JSON.stringify(calls[calls.length - 1] || [])
=== JSON.stringify(['cover', 'open_cover', { entity_id: 'cover.office_curtain' }]);
o.noInfoCard = !c._infoCard;
c._infoCard = null;
await push('open', { device_class: 'curtain' });
calls.length = 0;
await tap();
o.openClosesTheCover = JSON.stringify(calls[calls.length - 1] || [])
=== JSON.stringify(['cover', 'close_cover', { entity_id: 'cover.office_curtain' }]);
await push('opening', { device_class: 'curtain' });
calls.length = 0;
await tap();
o.travellingStops = (calls[calls.length - 1] || [])[1] === 'stop_cover';
// the service switch, which used to be the whole story, is never touched
o.serviceSwitchNeverCalled = !calls.some((x) => String(x[2]?.entity_id).startsWith('switch.'));
// ---- the guarded classes still degrade, read off the COVER --------------
await push('closed', { device_class: 'garage' });
calls.length = 0;
await tap();
o.garageCallsNothing = calls.length === 0;
o.garageShowsInfo = !!c._infoCard;
c._infoCard = null; await c.updateComplete;
c._setMode('devices'); await c.updateComplete;
c._openMarkerDialog(curtain()); await c.updateComplete;
o.dialogHidesCoverForGarage = !optionValues().includes('cover');
c._markerDialog = null;
c._setMode('view'); await c.updateComplete;
return o;
});
checkAll(out);
await finish(browser, out);
+253
View File
@@ -0,0 +1,253 @@
// Panning at ANY zoom (owner's report 2026-08-04: «добавь возможность таскать
// план при любом масштабе, а не только при более 100%»).
//
// Until this fix `_stagePointerMove` moved the view only while `_zoom > 1`:
// the rule was inherited from the old bounded canvas, where a plan smaller
// than the scene had nowhere to go. The infinite canvas removed that edge —
// there is always somewhere to go, and an editor extending a plan outwards
// needs exactly that. So a drag on empty scene now pans at 100 %, at 50 % and
// at 800 %, in view mode and in every editor; how far you may walk is decided
// by `_clampView` (PAN_SLACK: about a screen past the content) and not by the
// zoom. The tools keep the pointer they already owned — a resize handle, a
// device badge, an opening — and the kiosk keeps its floor swipe.
import { launch, check, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const out = {};
// ---- a drag on empty scene pans, at every zoom and in every mode ---------
Object.assign(out, await page.evaluate(async () => {
const c = window.__card;
const o = {};
const pd = (id, x, y) => c._stagePointerDown({ pointerId: id, clientX: x, clientY: y, target: c._stageEl, preventDefault() {} });
const pm = (id, x, y) => c._stagePointerMove({ pointerId: id, clientX: x, clientY: y });
const pu = (id, x, y) => c._stagePointerUp({ pointerId: id, clientX: x, clientY: y });
let pid = 100;
/** Drag 80x60 px across the middle of the scene; did the view follow? */
const drag = async (zoom) => {
// always start from the plan in the middle: the clamp is a real limit, and
// a dozen drags in a row would otherwise walk into it
const vb = c._baseVb();
c._applyView(zoom, vb[0] + vb[2] / 2, vb[1] + vb[3] / 2);
await c.updateComplete;
const v0 = { ...c._viewOr(c._baseVb()) };
const id = ++pid;
pd(id, 300, 300);
pm(id, 340, 330);
pm(id, 380, 360);
const v1 = { ...c._viewOr(c._baseVb()) };
pu(id, 380, 360);
// dragging down-right pulls the viewport up-left, and the zoom is untouched
return Math.abs(v1.x - v0.x) > 1 && Math.abs(v1.y - v0.y) > 1
&& v1.x < v0.x && v1.y < v0.y && Math.abs(c._zoom - zoom) < 1e-9;
};
const inMode = async (name, setup) => {
await setup();
await c.updateComplete;
o['pan_' + name + '_at100'] = await drag(1);
o['pan_' + name + '_at50'] = await drag(0.5);
o['pan_' + name + '_at33'] = await drag(1 / 3);
};
await inMode('view', async () => { c._setMode('view'); });
for (const tool of ['draw', 'merge', 'split', 'resize', 'opening', 'openwall', 'delroom']) {
await inMode('plan_' + tool, async () => { c._setMode('plan'); c._tool = tool; });
}
await inMode('devices', async () => { c._setMode('devices'); });
await inMode('decor', async () => { c._setMode('decor'); c._decorTool = 'select'; });
c._setMode('view');
c._applyView(1);
await c.updateComplete;
return o;
}));
// ---- 400 % still pans (the behaviour that already worked) ----------------
out.panStillWorksZoomedIn = await page.evaluate(async () => {
const c = window.__card;
c._setMode('view'); c._applyView(4); await c.updateComplete;
const v0 = { ...c._view };
c._stagePointerDown({ pointerId: 7, clientX: 300, clientY: 300, target: c._stageEl, preventDefault() {} });
c._stagePointerMove({ pointerId: 7, clientX: 380, clientY: 360 });
const moved = Math.abs(c._view.x - v0.x) > 1;
c._stagePointerUp({ pointerId: 7, clientX: 380, clientY: 360 });
c._applyView(1);
return moved;
});
// ---- the clamp: you may walk about a screen off, never further ------------
Object.assign(out, await page.evaluate(async () => {
const c = window.__card;
const sr = c.shadowRoot || c.renderRoot;
const o = {};
c._setMode('view');
const vb = c._baseVb();
c._applyView(1, vb[0] + vb[2] / 2, vb[1] + vb[3] / 2); // at 100 % the view IS fitView
await c.updateComplete;
const fit = { ...c._view };
c._stagePointerDown({ pointerId: 8, clientX: 700, clientY: 400, target: c._stageEl, preventDefault() {} });
c._stagePointerMove({ pointerId: 8, clientX: -40000, clientY: 400 }); // shove it into the void
const far = { ...c._view };
c._stagePointerUp({ pointerId: 8, clientX: -40000, clientY: 400 });
o.panLeavesTheContent = far.x > fit.x + fit.w * 0.5;
o.panStopsAtTheSlack = far.x <= fit.x + fit.w + 1;
c.requestUpdate(); await c.updateComplete;
o.homeArrowOffersTheWayBack = !!sr.querySelector('.homearrow');
c._applyView(1, vb[0] + vb[2] / 2, vb[1] + vb[3] / 2);
c.requestUpdate(); await c.updateComplete;
// the same at 50 %: the screen holds more, the walk is still finite
const fit2 = { ...c._view };
c._applyView(0.5, vb[0] + vb[2] / 2, vb[1] + vb[3] / 2); await c.updateComplete;
c._stagePointerDown({ pointerId: 9, clientX: 700, clientY: 400, target: c._stageEl, preventDefault() {} });
c._stagePointerMove({ pointerId: 9, clientX: -40000, clientY: 400 });
const far2 = { ...c._view };
c._stagePointerUp({ pointerId: 9, clientX: -40000, clientY: 400 });
o.panStopsAtTheSlackZoomedOut = far2.x <= fit2.x + fit2.w + 1 && far2.x > fit2.x;
c._applyView(1, vb[0] + vb[2] / 2, vb[1] + vb[3] / 2); await c.updateComplete;
return o;
}));
// ---- the tools still own their pointer ------------------------------------
const ev = (type, id, x, y) => ({ type, id, x, y });
Object.assign(out, await page.evaluate(async () => {
const c = window.__card;
const sr = c.shadowRoot || c.renderRoot;
const o = {};
const fire = (el, type, id, x, y) => el.dispatchEvent(new PointerEvent(type, {
bubbles: true, composed: true, cancelable: true, pointerId: id, clientX: x, clientY: y,
}));
// a) resize handle: the wall moves, the view does not
c._setMode('plan'); c._tool = 'resize'; c._applyView(1); await c.updateComplete;
const h = sr.querySelector('.rszhandle');
o.resizeHandlePresent = !!h;
if (h) {
const v0 = { ...c._viewOr(c._baseVb()) };
const r = h.getBoundingClientRect();
fire(h, 'pointerdown', 21, r.left + r.width / 2, r.top + r.height / 2);
o.resizeHandleTakesThePointer = !!c._rszDrag && c._panStart === null;
fire(h, 'pointermove', 21, r.left + r.width / 2 + 40, r.top + r.height / 2 + 40);
const v1 = { ...c._viewOr(c._baseVb()) };
o.resizeHandleDoesNotPan = Math.abs(v1.x - v0.x) < 0.5 && Math.abs(v1.y - v0.y) < 0.5;
o.resizeHandleResizes = !!c._rszDrag && c._rszDrag.moved === true;
c._rszCancelDrag?.();
fire(h, 'pointerup', 21, r.left + r.width / 2 + 40, r.top + r.height / 2 + 40);
}
c._rszSel = null;
// b) a device badge in the devices editor drags the DEVICE
c._setMode('devices'); c._applyView(1); await c.updateComplete;
const dev = sr.querySelector('.devlayer .dev');
o.devicePresent = !!dev;
if (dev) {
const v0 = { ...c._viewOr(c._baseVb()) };
const r = dev.getBoundingClientRect();
fire(dev, 'pointerdown', 22, r.left + r.width / 2, r.top + r.height / 2);
o.deviceTakesThePointer = !!c._drag && c._panStart === null;
fire(dev, 'pointermove', 22, r.left + r.width / 2 + 40, r.top + r.height / 2 + 30);
c._stagePointerMove({ pointerId: 22, clientX: r.left + 60, clientY: r.top + 50 });
const v1 = { ...c._viewOr(c._baseVb()) };
o.deviceDragDoesNotPan = Math.abs(v1.x - v0.x) < 0.5 && Math.abs(v1.y - v0.y) < 0.5;
o.deviceActuallyMoved = !!c._drag && c._drag.moved === true;
fire(dev, 'pointerup', 22, r.left + r.width / 2 + 40, r.top + r.height / 2 + 30);
// _pointerUp releases _drag on the next macrotask, and _stagePointerDown
// refuses to start a gesture while a device drag is live
await new Promise((r2) => setTimeout(r2, 20));
o.deviceDragReleased = c._drag === null;
await c.updateComplete;
}
// c) an opening drags along its wall, the view stays put
const cfg = JSON.parse(JSON.stringify(c._serverCfg));
cfg.spaces[0].openings = [{ id: 'o_pan', type: 'window', x: 0.3, y: 0.14, angle: 0, length: 0.1 }];
c._serverCfg = cfg; c._modelCache = null; c._frame = null;
c._setMode('plan'); c._tool = 'draw'; c._applyView(1);
c.requestUpdate(); await c.updateComplete;
const op = sr.querySelector('.op-hit');
o.openingPresent = !!op;
if (op) {
const v0 = { ...c._viewOr(c._baseVb()) };
const r = op.getBoundingClientRect();
fire(op, 'pointerdown', 23, r.left + r.width / 2, r.top + r.height / 2);
o.openingTakesThePointer = !!c._opDrag && c._panStart === null;
fire(op, 'pointermove', 23, r.left + r.width / 2 + 50, r.top + r.height / 2);
const v1 = { ...c._viewOr(c._baseVb()) };
o.openingDragDoesNotPan = Math.abs(v1.x - v0.x) < 0.5 && Math.abs(v1.y - v0.y) < 0.5;
fire(op, 'pointerup', 23, r.left + r.width / 2 + 50, r.top + r.height / 2);
await c.updateComplete;
}
// d) a drawing tool is still a drawing tool: two fingers pinch, they do not pan
c._setMode('plan'); c._tool = 'draw'; c._applyView(1); await c.updateComplete;
const z0 = c._zoom;
c._stagePointerDown({ pointerId: 31, clientX: 300, clientY: 300, target: c._stageEl, preventDefault() {} });
c._stagePointerDown({ pointerId: 32, clientX: 400, clientY: 300, target: c._stageEl, preventDefault() {} });
c._stagePointerMove({ pointerId: 31, clientX: 250, clientY: 300 });
c._stagePointerMove({ pointerId: 32, clientX: 450, clientY: 300 });
o.pinchStillZooms = c._zoom > z0 * 1.5;
c._stagePointerUp({ pointerId: 31, clientX: 250, clientY: 300 });
c._stagePointerUp({ pointerId: 32, clientX: 450, clientY: 300 });
c._path = [];
c._setMode('view'); c._applyView(1); await c.updateComplete;
return o;
}));
// ---- the kiosk keeps its floor swipe ---------------------------------------
Object.assign(out, await page.evaluate(async () => {
const c0 = window.__card;
const o = {};
const k = document.createElement('houseplan-card');
k.setConfig({ type: 'custom:houseplan-card', kiosk: true, cycle: 0 });
k.hass = c0.hass;
document.body.appendChild(k);
k.style.cssText = 'position:fixed;left:0;top:0;width:900px;height:700px;z-index:99';
await new Promise((r) => setTimeout(r, 500));
k.hass = { ...c0.hass };
await k.updateComplete;
const sr = k.shadowRoot || k.renderRoot;
const stage = sr.querySelector('.stage');
const fire = (type, id, x, y) => stage.dispatchEvent(new PointerEvent(type, {
bubbles: true, composed: true, cancelable: true, pointerId: id, clientX: x, clientY: y,
}));
k._applyView(1); await k.updateComplete;
const s0 = k._space;
const v0 = { ...k._viewOr(k._baseVb()) };
// a REAL swipe: down, several moves, up — the moves are what used to be
// impossible to add to smoke_kiosk without hijacking the gesture
fire('pointerdown', 41, 600, 300);
fire('pointermove', 41, 540, 302);
fire('pointermove', 41, 480, 305);
fire('pointermove', 41, 450, 305);
const midPan = Math.abs(k._viewOr(k._baseVb()).x - v0.x);
fire('pointerup', 41, 450, 305);
await k.updateComplete;
o.kioskSwipeStillSwitchesFloors = k._space !== s0;
o.kioskHorizontalDragIsNotAPan = midPan < 1;
// …and the plan is not left hanging half-panned after the switch
o.kioskViewCenteredAfterSwipe = Math.abs(k._viewOr(k._baseVb()).x - v0.x) < 2000;
// a VERTICAL drag is nobody's swipe, so it pans like everywhere else
const s1 = k._space;
const v1 = { ...k._viewOr(k._baseVb()) };
fire('pointerdown', 42, 450, 200);
fire('pointermove', 42, 452, 260);
fire('pointermove', 42, 454, 330);
const panned = Math.abs(k._viewOr(k._baseVb()).y - v1.y) > 1;
fire('pointerup', 42, 454, 330);
await k.updateComplete;
o.kioskVerticalDragPans = panned;
o.kioskVerticalDragKeepsFloor = k._space === s1;
// zoomed in, a horizontal drag pans (no swipe up there — unchanged rule)
k._applyView(2); await k.updateComplete;
const v2 = { ...k._view };
const s2 = k._space;
fire('pointerdown', 43, 600, 300);
fire('pointermove', 43, 540, 302);
fire('pointermove', 43, 480, 305);
const pannedZoomed = Math.abs(k._view.x - v2.x) > 1;
fire('pointerup', 43, 480, 305);
await k.updateComplete;
o.kioskZoomedHorizontalPans = pannedZoomed;
o.kioskZoomedDoesNotSwipe = k._space === s2;
k.remove();
return o;
}));
await finish(browser, checkAll(out));
File diff suppressed because one or more lines are too long
+2 -2
View File
File diff suppressed because one or more lines are too long
+20 -2
View File
@@ -38,7 +38,7 @@ coordinate system.
| `space.view_box` | the frame; everything was clamped into it | an OPTIONAL hint for the very first frame; used only when there is nothing to frame |
| "fit" rectangle | `view_box` (or the content bbox in view mode) | always the **content frame** (§4) |
| Zoom out floor | `ZOOM_MIN = 0.4` (fraction of `view_box`) | 3x the content frame (`MIN_ZOOM = 1/3`) |
| Pan bounds | content must cover the scene | content frame + one screen of slack in each direction |
| Pan bounds | content must cover the scene, and only above 100% zoom | content frame + one screen of slack in each direction, at any zoom |
| Icon size | % of `view_box`, i.e. grew with zoom | % of `iconUnit` — still grows with zoom (§6) |
| Validation range | `+/-4` | `+/-5000` (§3) |
@@ -154,9 +154,26 @@ was silently excluded from the frame).
* **Zoom in** — unchanged, `ZOOM_MAX = 8`.
* **Zoom out** — `MIN_ZOOM = 1/3`: you can see three times the content
frame and no further. Empty space beyond that is not information.
* **Pan** — bounded by the content frame inflated by
* **Pan** — available at **every zoom**, in view mode and in every
editor, and bounded by the content frame inflated by
`PAN_SLACK = 1.0` of `max(view, frame)` on each side. You can walk
off the plan (there is no edge), but not into infinity.
Until 2026-08-04 a drag moved the view only while `zoom > 1`: on the
old bounded canvas a plan smaller than the scene genuinely had
nowhere to go, so the gate was harmless. With no edge left it was
simply a missing feature, and the owner reported it as one
(«таскать план при любом масштабе»). The zoom no longer takes part
in the decision — `_clampView` alone says how far you may walk.
* **Who owns the pointer.** A drag pans only when it starts on empty
scene: the room-resize handles, device badges, openings, room labels
and the decor shapes take the pointer first (`_stagePointerDown`
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.
* **"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
@@ -259,6 +276,7 @@ fits `all`.
| grid `<rect>` over `vb` | the grid ends with the square | rect follows the view (§7) |
| grid pitch fixed | fine at 1 canvas wide | `gridLevels()` (§7) |
| `_clampView` pinned content over the scene | you cannot pan past the edge | §5 pan slack |
| `_stagePointerMove` panned only while `zoom > 1` | below 100% the content already covered the scene, so a drag had nowhere to go | **removed** — §5, panning at every zoom |
| `ZOOM_MIN = 0.4` | fraction of the square | `MIN_ZOOM = 1/3` of the content frame (§5) |
| `_decorMoveUpdate` clamp `-0.25 .. 1.25` | decor may hang a quarter past the edge | clamp widened to the sane range (`+/-CANVAS_LIMIT`) — corruption insurance, not a frame |
| static card `aspect-ratio` + `viewBox` from `space.vb` | the static card frames the square | `spaceFrame()` — same content frame as the full card |
+7
View File
@@ -994,6 +994,13 @@ require hands on real hardware — they remain for the human pass.
- [ ] **Pan has slack, not walls**: you can pan a full screen past the plan in
every direction; when the plan is fully off screen a small arrow points
home and one click fits it back
- [ ] **Pan at ANY zoom** (owner's report 2026-08-04): dragging empty scene
moves the view at 100 %, at 50 % and at the zoom-out floor — in View and
in all three editors, with every plan tool selected. The tools keep the
pointer they own (a resize handle resizes, a device badge in the Devices
editor moves the device, an opening slides along its wall — none of them
pan), two fingers still pinch, and on a kiosk screen a horizontal drag
is still the floor swipe (a vertical one pans) [auto: smoke_pan_any_zoom]
- [ ] **Adaptive grid** (§7): in the Plan editor zoomed far out the grid does
not merge into a grey wash — fine dots thin out, every 5th/10th node
stays bigger; zoomed in the grid is the usual one and snapping still
+37 -6
View File
@@ -345,6 +345,13 @@ class HouseplanCard extends LitElement {
private _viewModeSnap: { space: string; zoom: number; cx?: number; cy?: number } | null = null;
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.
*/
private _panLock: 'pan' | 'swipe' | null = null;
private _pinchStart: { dist: number; zoom: number } | null = null;
private _suppressClick = false;
private _roViewport?: ResizeObserver;
@@ -2179,15 +2186,27 @@ class HouseplanCard extends LitElement {
const v = this._viewOr(this._baseVb());
if (this._pointers.size === 1) {
this._panStart = { sx: ev.clientX, sy: ev.clientY, vx: v.x, vy: v.y };
this._panLock = null; // undecided until the finger moves
this._suppressClick = false;
} else if (this._pointers.size === 2) {
const pts = [...this._pointers.values()];
const dist = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y);
this._pinchStart = { dist, zoom: this._zoom };
this._panStart = null;
this._panLock = null;
}
}
/**
* Is a horizontal drag reserved for the floor swipe right now? Only on a
* kiosk screen, only at the zoom `swipeTarget` accepts, and only when there
* is more than one space to swipe between — everywhere else nothing
* competes with panning.
*/
private get _swipeZone(): boolean {
return this._kiosk && this._zoom <= 1.001 && this._model.length > 1;
}
private _stagePointerMove(ev: PointerEvent): void {
if (this._decorDraft?.pid === ev.pointerId) {
this._decorDraft = { ...this._decorDraft, b: this._snap(this._svgPoint(ev)) };
@@ -2221,14 +2240,25 @@ class HouseplanCard extends LitElement {
this._suppressClick = true;
clearTimeout(this._holdTimer);
}
if (this._zoom > 1 && this._view) {
const stage = this._stageEl!;
const v = this._view;
const fit = fitView(this._baseVb(), this._stageAspect());
// Which gesture is this? Decided once, on the first movement worth the
// name, and kept for the rest of the drag so the plan cannot flip-flop
// under the finger. Panning is NOT gated on the zoom any more (owner,
// 2026-08-04: «таскать план при любом масштабе»): on an infinite canvas
// there is no "the content already covers the scene" state that would
// 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) > 8) {
this._panLock = this._swipeZone && Math.abs(ddx) > Math.abs(ddy) * 1.5 ? 'swipe' : 'pan';
}
const stage = this._stageEl;
if (this._panLock === 'pan' && stage) {
const vb = this._baseVb();
const v = this._viewOr(vb);
const fit = fitView(vb, this._stageAspect());
this._view = this._clampView(
{
x: this._panStart.vx - (ddx / stage.clientWidth) * v.w,
y: this._panStart.vy - (ddy / stage.clientHeight) * v.h,
x: this._panStart.vx - (ddx / (stage.clientWidth || 1)) * v.w,
y: this._panStart.vy - (ddy / (stage.clientHeight || 1)) * v.h,
w: v.w,
h: v.h,
},
@@ -2277,6 +2307,7 @@ class HouseplanCard extends LitElement {
if (this._pointers.size < 2) this._pinchStart = null;
if (this._pointers.size === 0) {
this._panStart = null;
this._panLock = null;
// reset click suppression on the next tick (so that a click right after a pan does not fire)
setTimeout(() => (this._suppressClick = false), 0);
}