feat: fit all on double background tap

Issue: #449
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-09-04 18:33:03 +03:00
parent b39f99b3a4
commit 05ef318133
28 changed files with 687 additions and 243 deletions
+8 -7
View File
@@ -22,21 +22,22 @@ const res = await page.evaluate(async () => {
// 3) свайп при 1:1 переключает пространство (и точки показываются)
const s0 = c._space;
const stage = sr().querySelector('.stage');
stage.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true, pointerId: 1, clientX: 600, clientY: 300 }));
stage.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, composed: true, pointerId: 1, clientX: 450, clientY: 305 }));
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 }));
await c.updateComplete;
out.swipeSwitches = 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, clientX: 600, clientY: 300 }));
stage.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, composed: true, pointerId: 2, clientX: 450, clientY: 305 }));
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 }));
await c.updateComplete;
out.noSwipeZoomed = c._space === s1;
// 5) двойной тап сбрасывает зум
c._lastTap = Date.now() - 100;
stage.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true, pointerId: 3, clientX: 500, clientY: 300 }));
stage.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, composed: true, pointerId: 3, clientX: 501, clientY: 300 }));
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 }));
}
const resetStarted = performance.now();
while (c._cameraTransition?.active) {
if (performance.now() - resetStarted > 1000) throw new Error('double-tap camera transition did not settle');
+6 -4
View File
@@ -30,7 +30,8 @@ const out = await page.evaluate(async () => {
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,
bubbles: true, composed: true, cancelable: true, pointerId: id,
pointerType: 'touch', isPrimary: true, button: 0, clientX: x, clientY: y,
}));
o.kioskHasSeveralSpaces = k._model.length > 1;
@@ -108,9 +109,10 @@ const out = await page.evaluate(async () => {
await home();
k._applyView(2);
await k.updateComplete;
k._lastTap = Date.now() - 100;
fire('pointerdown', 58, 500, 300);
fire('pointerup', 58, 501, 300);
for (const id of [58, 60]) {
fire('pointerdown', id, 500, 300);
fire('pointerup', id, 501, 300);
}
const resetStarted = performance.now();
do { await new Promise((resolve) => requestAnimationFrame(resolve)); }
while (k._cameraTransition.active && performance.now() - resetStarted < 1000);
+71 -6
View File
@@ -147,6 +147,68 @@ const out = await page.evaluate(async () => {
result.areaLinkSuppressesRoomFit = JSON.stringify(c._view) === beforeLink
&& c._roomPointer === null && c._roomFocus === null;
const stage = root.querySelector('.stage');
const base = c._baseVb();
const moveAway = async () => {
c._doubleFit.clear();
c._applyView(2, base[0] + base[2] * 0.65, base[1] + base[3] * 0.4);
await c.updateComplete;
};
await moveAway();
c._fitAll('fit');
await waitCamera();
const toolbarTarget = JSON.stringify({ zoom: c._zoom, view: c._view });
await moveAway();
c._showFar = false;
c._frame = null;
c._roomFocus = { spaceId: 'fit-floor', roomId: 'outer' };
const beforeFirstTap = JSON.stringify({ zoom: c._zoom, view: c._view });
let firstTapUpdates = 0;
const requestUpdate = c.requestUpdate;
c.requestUpdate = (...args) => { firstTapUpdates++; return requestUpdate.apply(c, args); };
pointer(stage, 'pointerdown', 44901, 400, 500);
pointer(stage, 'pointerup', 44901, 401, 500);
c.requestUpdate = requestUpdate;
result.firstBackgroundTapIsPassive = firstTapUpdates === 0
&& JSON.stringify({ zoom: c._zoom, view: c._view }) === beforeFirstTap;
pointer(stage, 'pointerdown', 44902, 650, 500);
pointer(stage, 'pointerup', 44902, 651, 500);
result.normalViewUsesDoubleTapReason = c._cameraTransition.state?.reason === 'double-tap';
await waitCamera();
result.normalViewDoubleClickMatchesFitAll = c._showFar === true && c._roomFocus === null
&& JSON.stringify({ zoom: c._zoom, view: c._view }) === toolbarTarget;
await moveAway();
pointer(stage, 'pointerdown', 44903, 400, 500, { pointerType: 'touch' });
pointer(stage, 'pointerup', 44903, 400, 500, { pointerType: 'touch' });
pointer(stage, 'pointerdown', 44904, 500, 500, { pointerType: 'pen' });
pointer(stage, 'pointerup', 44904, 500, 500, { pointerType: 'pen' });
result.mixedModalitiesDoNotPair = c._zoom === 2 && !c._cameraTransition.active;
pointer(stage, 'pointerdown', 44905, 550, 500, { pointerType: 'pen' });
pointer(stage, 'pointerup', 44905, 550, 500, { pointerType: 'pen' });
await waitCamera();
result.penPairUsesSameFitAll = JSON.stringify({ zoom: c._zoom, view: c._view }) === toolbarTarget;
await moveAway();
pointer(stage, 'pointerdown', 44906, 400, 500, { pointerType: 'touch' });
pointer(stage, 'pointerup', 44906, 400, 500, { pointerType: 'touch' });
pointer(stage, 'pointerdown', 44907, 400, 500, { pointerType: 'touch' });
pointer(stage, 'pointercancel', 44907, 400, 500, { pointerType: 'touch' });
pointer(stage, 'pointerdown', 44908, 400, 500, { pointerType: 'touch' });
pointer(stage, 'pointerup', 44908, 400, 500, { pointerType: 'touch' });
result.cancelDisarmsThePreviousTap = c._zoom === 2 && !c._cameraTransition.active;
c._mode = 'decor';
c._doubleFit.clear();
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();
pointer(roomNode(), 'pointerdown', 15205, 500, 300);
pointer(roomNode(), 'pointermove', 15205, 520, 300);
pointer(roomNode(), 'pointerup', 15205, 520, 300);
@@ -195,7 +257,6 @@ const out = await page.evaluate(async () => {
pointerType: 'touch', isPrimary: true, button: 0,
buttons: type === 'pointerdown' ? 1 : 0, clientX: 450, clientY: 300,
}));
kiosk._lastTap = 152;
for (const id of [15208, 15209]) {
kioskPointer(kioskRoom, 'pointerdown', id);
kioskPointer(kioskRoom, 'pointerup', id);
@@ -204,13 +265,17 @@ const out = await page.evaluate(async () => {
await new Promise((resolve) => requestAnimationFrame(resolve));
}
}
result.kioskRoomTapDoesNotEnterDoubleTap = kiosk._lastTap === 152
&& kiosk._roomFocus?.roomId === 'room-a';
kiosk._clearRoomFocus(true);
kiosk._applyView(2);
kiosk._lastTap = Date.now() - 100;
const beforeKioskBackground = JSON.stringify({ zoom: kiosk._zoom, view: kiosk._view });
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;
kiosk._clearRoomFocus(true);
kiosk._applyView(2);
for (const id of [15211, 15212]) {
kioskPointer(kiosk._stageEl, 'pointerdown', id);
kioskPointer(kiosk._stageEl, 'pointerup', id);
}
const resetStarted = performance.now();
while (kiosk._cameraTransition.active && performance.now() - resetStarted < 1200) {
await new Promise((resolve) => requestAnimationFrame(resolve));
+1 -1
View File
@@ -112,7 +112,7 @@ const out = await page.evaluate(async () => {
directBaseline(2);
if (reason === 'home') card._fitAll('home');
else if (reason === 'fit') card._fitAll('fit');
else card._resetZoom('double-tap');
else card._fitAll('double-tap');
reasons[reason] = {
liveReason: card._cameraTransition.state?.reason,
samples: (await settle()).map((sample) => sample.zoom),
+77 -77
View File
@@ -1,169 +1,169 @@
{
"schema": 1,
"fingerprint": "53ac71b2f6ce21ea7cf23cc2a1d9d4f35897a012eca18d4aac6ace3994022019",
"fingerprint": "9e1ebf9d6211c5c1c2104827caef16f417dbe2080f54fe178465ecd45392ebf4",
"entry": "houseplan-card.js",
"initialViewFiles": [
"houseplan-assets/houseplan-card-BgNWi9NC.js",
"houseplan-assets/houseplan-card-CbNfZuwV.js",
"houseplan-card.js"
],
"initialViewGzipBytes": 295066,
"initialViewGzipBytes": 295743,
"lazyFiles": [
"houseplan-assets/backdrop-pick-DA2OhjB4.js",
"houseplan-assets/de-DYVDL4UZ.js",
"houseplan-assets/editor-gOvMD27j.js",
"houseplan-assets/fr-BQuWNyOh.js",
"houseplan-assets/houseplan-editor-runtime-BObeFI2c.js",
"houseplan-assets/houseplan-onboarding-runtime-CBT0JFO5.js",
"houseplan-assets/hp-zigbee-topology-overlay-Dp23y7v4.js",
"houseplan-assets/zigbee-topology-Dm2vd-Wv.js",
"houseplan-assets/zigbee-topology-runtime-CZFgtAgE.js"
"houseplan-assets/backdrop-pick-gAnjnfml.js",
"houseplan-assets/de-BfSjabHI.js",
"houseplan-assets/editor-5wAcwrls.js",
"houseplan-assets/fr-DbOQViNr.js",
"houseplan-assets/houseplan-editor-runtime-C_UMF4-O.js",
"houseplan-assets/houseplan-onboarding-runtime-bT_K0eL-.js",
"houseplan-assets/hp-zigbee-topology-overlay-Bd0lZvQO.js",
"houseplan-assets/zigbee-topology-BrocYSC0.js",
"houseplan-assets/zigbee-topology-runtime-DEZ5gnWy.js"
],
"lazyGzipBytes": 234149,
"lazyGzipBytes": 234154,
"lazyEditorFiles": [
"houseplan-assets/backdrop-pick-DA2OhjB4.js",
"houseplan-assets/editor-gOvMD27j.js",
"houseplan-assets/houseplan-editor-runtime-BObeFI2c.js",
"houseplan-assets/zigbee-topology-Dm2vd-Wv.js"
"houseplan-assets/backdrop-pick-gAnjnfml.js",
"houseplan-assets/editor-5wAcwrls.js",
"houseplan-assets/houseplan-editor-runtime-C_UMF4-O.js",
"houseplan-assets/zigbee-topology-BrocYSC0.js"
],
"lazyEditorGzipBytes": 173953,
"lazyEditorGzipBytes": 173952,
"lazyOnboardingFiles": [
"houseplan-assets/backdrop-pick-DA2OhjB4.js",
"houseplan-assets/houseplan-onboarding-runtime-CBT0JFO5.js"
"houseplan-assets/backdrop-pick-gAnjnfml.js",
"houseplan-assets/houseplan-onboarding-runtime-bT_K0eL-.js"
],
"lazyOnboardingGzipBytes": 14235,
"lazyLocaleFiles": [
"houseplan-assets/de-DYVDL4UZ.js",
"houseplan-assets/fr-BQuWNyOh.js"
"houseplan-assets/de-BfSjabHI.js",
"houseplan-assets/fr-DbOQViNr.js"
],
"lazyLocaleGzipBytes": 49100,
"lazyLocaleGzipBytes": 49108,
"files": [
{
"path": "houseplan-assets/backdrop-pick-DA2OhjB4.js",
"sha256": "311e9077a8c1df7a1e141916b32b8d41f1f89ab89c7dcb28da442b653b72b014",
"path": "houseplan-assets/backdrop-pick-gAnjnfml.js",
"sha256": "52b3433b1539bec5e3f8c21e1c1838309fd12525b50a7c2962115eb7bd19252c",
"rawBytes": 21418,
"gzipBytes": 7281,
"gzipBytes": 7282,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-BgNWi9NC.js"
"houseplan-assets/houseplan-card-CbNfZuwV.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/de-DYVDL4UZ.js",
"sha256": "16ab3d88b362b395401ca37a85dc1e5eb2df459db43754d9a9669fe381ccd68a",
"path": "houseplan-assets/de-BfSjabHI.js",
"sha256": "935601149e583465818c09ad28a20749d838e684c10f3ea81e20c03fda462f84",
"rawBytes": 82182,
"gzipBytes": 24820,
"gzipBytes": 24824,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/editor-gOvMD27j.js",
"sha256": "dd0ead80dc7fb402a56e8a4f63cd2bd1c483673616d45bf2c30234b20c7fedef",
"path": "houseplan-assets/editor-5wAcwrls.js",
"sha256": "00ca47ff24c22288b2671f40523f8b5f90411693dc77629fbcd7a4a7d5c20797",
"rawBytes": 3826,
"gzipBytes": 1580,
"gzipBytes": 1581,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-BgNWi9NC.js"
"houseplan-assets/houseplan-card-CbNfZuwV.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/fr-BQuWNyOh.js",
"sha256": "d25b21cf34fda70e65edea471a4643343591ca36fe273878423fd74969a17649",
"path": "houseplan-assets/fr-DbOQViNr.js",
"sha256": "4b8d70e5683ab8e606d3930490d9ad664a69bb9cda993c835c029c0c9926ca05",
"rawBytes": 84312,
"gzipBytes": 24280,
"gzipBytes": 24284,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-card-BgNWi9NC.js",
"sha256": "1c1e542ec2eed4382b3b9cc5e1f7bf36e5289273fa3c4b78b1dc49243d31336e",
"rawBytes": 1047873,
"gzipBytes": 294272,
"path": "houseplan-assets/houseplan-card-CbNfZuwV.js",
"sha256": "25a8afcf0b132250733e7ae48a4499c86ea03cabf5d63c035e37517a379ffa0c",
"rawBytes": 1050357,
"gzipBytes": 294948,
"isEntry": false,
"imports": [],
"dynamicImports": [
"houseplan-assets/de-DYVDL4UZ.js",
"houseplan-assets/editor-gOvMD27j.js",
"houseplan-assets/fr-BQuWNyOh.js",
"houseplan-assets/houseplan-editor-runtime-BObeFI2c.js",
"houseplan-assets/houseplan-onboarding-runtime-CBT0JFO5.js",
"houseplan-assets/hp-zigbee-topology-overlay-Dp23y7v4.js"
"houseplan-assets/de-BfSjabHI.js",
"houseplan-assets/editor-5wAcwrls.js",
"houseplan-assets/fr-DbOQViNr.js",
"houseplan-assets/houseplan-editor-runtime-C_UMF4-O.js",
"houseplan-assets/houseplan-onboarding-runtime-bT_K0eL-.js",
"houseplan-assets/hp-zigbee-topology-overlay-Bd0lZvQO.js"
]
},
{
"path": "houseplan-assets/houseplan-editor-runtime-BObeFI2c.js",
"sha256": "de43beb5ec24ab5477cf8aed38574a8625e2b79cb44a10269a6235f2573f7fdd",
"path": "houseplan-assets/houseplan-editor-runtime-C_UMF4-O.js",
"sha256": "650fc40ae2dde575ba0dd83b9c859379909e33d7fd08f552392aa15c3dc50103",
"rawBytes": 608951,
"gzipBytes": 160248,
"gzipBytes": 160245,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-DA2OhjB4.js",
"houseplan-assets/houseplan-card-BgNWi9NC.js",
"houseplan-assets/zigbee-topology-Dm2vd-Wv.js"
"houseplan-assets/backdrop-pick-gAnjnfml.js",
"houseplan-assets/houseplan-card-CbNfZuwV.js",
"houseplan-assets/zigbee-topology-BrocYSC0.js"
],
"dynamicImports": [
"houseplan-assets/zigbee-topology-runtime-CZFgtAgE.js"
"houseplan-assets/zigbee-topology-runtime-DEZ5gnWy.js"
]
},
{
"path": "houseplan-assets/houseplan-onboarding-runtime-CBT0JFO5.js",
"sha256": "040689090c5232b0fda016cf5866a21d0203523015dac062b6b7d072b9c7f188",
"path": "houseplan-assets/houseplan-onboarding-runtime-bT_K0eL-.js",
"sha256": "a7f2df65ad94be71c82e7bee582485046d0bdb910e225caeb35be6e15cd9b843",
"rawBytes": 28088,
"gzipBytes": 6954,
"gzipBytes": 6953,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-DA2OhjB4.js",
"houseplan-assets/houseplan-card-BgNWi9NC.js"
"houseplan-assets/backdrop-pick-gAnjnfml.js",
"houseplan-assets/houseplan-card-CbNfZuwV.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/hp-zigbee-topology-overlay-Dp23y7v4.js",
"sha256": "ee382f9dc514051bc974bde8d0c2238c137a2713f7fbad1a46f9660f78140242",
"rawBytes": 6012,
"gzipBytes": 2381,
"path": "houseplan-assets/hp-zigbee-topology-overlay-Bd0lZvQO.js",
"sha256": "6fb52a0c295bfc3843594893b57d107439a2d5c0e312e9939117268b58481088",
"rawBytes": 6007,
"gzipBytes": 2379,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-BgNWi9NC.js",
"houseplan-assets/zigbee-topology-Dm2vd-Wv.js",
"houseplan-assets/zigbee-topology-runtime-CZFgtAgE.js"
"houseplan-assets/houseplan-card-CbNfZuwV.js",
"houseplan-assets/zigbee-topology-BrocYSC0.js",
"houseplan-assets/zigbee-topology-runtime-DEZ5gnWy.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/zigbee-topology-Dm2vd-Wv.js",
"sha256": "ef26620e3d4ee2fe40f461498ec074996a602445b14d5d3d7dd635edfa69efa0",
"path": "houseplan-assets/zigbee-topology-BrocYSC0.js",
"sha256": "5888949557c1ed87b227e130409616316dbd3b5f959cb8b49e63c64496f9e0b2",
"rawBytes": 12382,
"gzipBytes": 4844,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-BgNWi9NC.js"
"houseplan-assets/houseplan-card-CbNfZuwV.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/zigbee-topology-runtime-CZFgtAgE.js",
"sha256": "f5d307d2c163f7e26c2540ffa8345cd1f0b5395d210c71a59649f4a493c5aacf",
"path": "houseplan-assets/zigbee-topology-runtime-DEZ5gnWy.js",
"sha256": "2207a55c09b1433238a8cd71bca6347bbf865281ea6db9e305832af9b2966da1",
"rawBytes": 3941,
"gzipBytes": 1761,
"gzipBytes": 1762,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-BgNWi9NC.js",
"houseplan-assets/zigbee-topology-Dm2vd-Wv.js"
"houseplan-assets/houseplan-card-CbNfZuwV.js",
"houseplan-assets/zigbee-topology-BrocYSC0.js"
],
"dynamicImports": []
},
{
"path": "houseplan-card.js",
"sha256": "b8beb25d0d36a897e2a7a773d7a062a79aa8f8ef92ff145334f4ac4655e237a1",
"sha256": "39e8d0d696bfacd088b486bc4094e11a4022b1e64c24eb4fef5a7424d30b9a20",
"rawBytes": 1183,
"gzipBytes": 794,
"gzipBytes": 795,
"isEntry": true,
"imports": [
"houseplan-assets/houseplan-card-BgNWi9NC.js"
"houseplan-assets/houseplan-card-CbNfZuwV.js"
],
"dynamicImports": []
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,14 +1,14 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="53ac71b2f6ce21ea7cf23cc2a1d9d4f35897a012eca18d4aac6ace3994022019";import{k as e,l as o,t,dq as s,A as a,dr as i,ds as l,dt as r,E as n,c}from"./houseplan-card-BgNWi9NC.js";class h extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const r="string"==typeof this._config?.default_floor?this._config.default_floor:"",n=[...e];return r&&!e.some(e=>e.value===r)&&n.unshift({value:r,label:r}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:n}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return r();if("warm"===e)return n;this._loadSpaces();const s=this._lang,h={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},f=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="9e1ebf9d6211c5c1c2104827caef16f417dbe2080f54fe178465ecd45392ebf4";import{k as e,l as o,t,dq as s,A as a,dr as i,ds as l,dt as r,E as n,c}from"./houseplan-card-CbNfZuwV.js";class f extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const r="string"==typeof this._config?.default_floor?this._config.default_floor:"",n=[...e];return r&&!e.some(e=>e.value===r)&&n.unshift({value:r,label:r}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:n}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return r();if("warm"===e)return n;this._loadSpaces();const s=this._lang,f={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},h=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
.hass=${this.hass}
.data=${this._formData}
.schema=${e}
.computeLabel=${e=>h[e.name]||e.name}
.computeLabel=${e=>f[e.name]||e.name}
@value-changed=${this._valueChanged}
></ha-form>`;return c`
${d(f.slice(0,3))}
${d(h.slice(0,3))}
${_?c`<div class="default-floor-error" role="alert"
style="color:var(--error-color,#db4437);margin:-4px 0 12px;overflow-wrap:anywhere">
${t(s,"editor.default_floor_missing",{id:_})}
</div>`:a}
${d(f.slice(3))}
`}_valueChanged(e){const o={...this._config,...e.detail.value};""===o.floor?delete o.floor:o.floor===this._floorToken&&(o.floor=this._config?.floor);const t=new Event("config-changed",{bubbles:!0,composed:!0});t.detail={config:o},this.dispatchEvent(t)}}h.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",h);
${d(h.slice(3))}
`}_valueChanged(e){const o={...this._config,...e.detail.value};""===o.floor?delete o.floor:o.floor===this._floorToken&&(o.floor=this._config?.floor);const t=new Event("config-changed",{bubbles:!0,composed:!0});t.detail={config:o},this.dispatchEvent(t)}}f.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",f);
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,36 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="9e1ebf9d6211c5c1c2104827caef16f417dbe2080f54fe178465ecd45392ebf4";import{k as e,A as t,du as i,aw as s,c as o,l as r,j as n}from"./houseplan-card-CbNfZuwV.js";import{a,r as h,t as d}from"./zigbee-topology-BrocYSC0.js";import{zigbeeTopologyRuntimeSnapshot as p,subscribeZigbeeTopology as c}from"./zigbee-topology-runtime-DEZ5gnWy.js";const l={revision:0,topologies:[],states:{}};class v extends e{constructor(){super(...arguments),this.devices=[],this.currentSpace="",this._runtime=l,this._hovered="",this._pointerOver=e=>{if(!this._mouseAllowed(e))return void this._clear();const t=this._deviceFromEvent(e),i=t?.dataset.id||"";i&&i!==this._hovered&&(this._hovered=i,this.requestUpdate())},this._pointerOut=e=>{const t=this._deviceFromEvent(e);if(!t||t.dataset.id!==this._hovered)return;const i=e.relatedTarget instanceof Element?e.relatedTarget.closest('[data-hp="device"]'):null;i?.dataset.id!==this._hovered&&this._clear()},this._pointerDown=e=>{"mouse"!==e.pointerType&&this._clear()}}connectedCallback(){super.connectedCallback();const e=this.getRootNode();e.host&&"undefined"!=typeof MutationObserver&&(this._hoverGateObserver=new MutationObserver(()=>{e.host.hasAttribute("data-pointer-hover")||this._clear()}),this._hoverGateObserver.observe(e.host,{attributes:!0,attributeFilter:["data-pointer-hover"]})),queueMicrotask(()=>this._connectParent())}disconnectedCallback(){this._release?.(),this._release=void 0,this._disconnectParent(),this._hoverGateObserver?.disconnect(),this._hoverGateObserver=void 0,this._hovered="",super.disconnectedCallback()}updated(e){e.has("hass")&&(this._release?.(),this._runtime=p(this.hass),this._mappedMemo=void 0,this._release=c(this.hass,()=>{this._runtime=p(this.hass),this._mappedMemo=void 0,this.requestUpdate()})),(e.has("currentSpace")||e.has("devices")||e.has("registry"))&&(this._mappedMemo=void 0,this.devices.some(e=>e.id===this._hovered&&e.space===this.currentSpace)||(this._hovered=""))}_connectParent(){const e=this.parentElement;e&&e!==this._parent&&(this._disconnectParent(),this._parent=e,e.addEventListener("pointerover",this._pointerOver),e.addEventListener("pointerout",this._pointerOut),e.addEventListener("pointerdown",this._pointerDown,!0))}_disconnectParent(){this._parent?.removeEventListener("pointerover",this._pointerOver),this._parent?.removeEventListener("pointerout",this._pointerOut),this._parent?.removeEventListener("pointerdown",this._pointerDown,!0),this._parent=void 0}_deviceFromEvent(e){for(const t of e.composedPath())if(t instanceof HTMLElement&&"device"===t.dataset.hp)return t;return null}_mouseAllowed(e){const t=this.getRootNode();return"mouse"===e.pointerType&&t.host?.hasAttribute?.("data-pointer-hover")}_clear(){this._hovered&&(this._hovered="",this.requestUpdate())}_position(e){const t=[...this._parent?.querySelectorAll(".dev[data-id]")||[]].find(t=>t.dataset.id===e);if(!t)return null;const i=Number.parseFloat(t.style.left),s=Number.parseFloat(t.style.top),o=t.getBoundingClientRect();return Number.isFinite(i)&&Number.isFinite(s)?{x:i,y:s,width:o.width,height:o.height}:null}render(){if(!this._hovered||!this.registry||!this._runtime.topologies.length)return t;const e=this._mappedMemo,n=e&&e.runtime===this._runtime&&e.devices===this.devices&&e.registry===this.registry?e.mapped:a(this._runtime.topologies,this.devices,this.registry);e&&n===e.mapped||(this._mappedMemo={runtime:this._runtime,devices:this.devices,registry:this.registry,mapped:n});const p=h(n,this.currentSpace,this._hovered),c=this._position(this._hovered);if(!c)return t;const l=p.lines.map(e=>({...e,point:this._position(e.neighborMarkerId)})).filter(e=>!!e.point);return o`
${l.length?s`<svg viewBox="0 0 100 100" preserveAspectRatio="none"
aria-hidden="true" data-hp="zigbee-topology-lines">
${l.map(e=>s`<line x1=${c.x} y1=${c.y}
x2=${e.point.x} y2=${e.point.y}
stroke=${void 0===e.lqi?"rgba(145,155,165,.85)":i(e.lqi)}
stroke-width=${void 0===e.lqi?2:2.2}
stroke-dasharray=${void 0===e.lqi?"5 5":t} opacity=".9"></line>`)}
</svg>`:t}
${l.map(e=>o`<div class="halo" data-hp="zigbee-topology-neighbor"
data-id=${e.neighborMarkerId} style="left:${e.point.x}%;top:${e.point.y}%;width:${1.22*e.point.width}px;height:${1.22*e.point.height}px"></div>`)}
${p.remoteCount?o`<div class="remote" data-hp="zigbee-topology-remote"
style="left:${c.x}%;top:${c.y}%">${d(r(this.hass),"remote_count",{n:p.remoteCount})}</div>`:t}
`}}v.properties={hass:{attribute:!1},devices:{attribute:!1},registry:{attribute:!1},currentSpace:{type:String,attribute:"current-space"},viewKey:{attribute:!1}},v.styles=n`
:host { position: absolute; inset: 0; z-index: 5; display: block; pointer-events: none; }
svg { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }
line { vector-effect: non-scaling-stroke; stroke-linecap: round; }
.halo {
position: absolute; width: calc(var(--device-base-size, 4cqw) * 1.22);
height: calc(var(--device-base-size, 4cqw) * 1.22); transform: translate(-50%, -50%);
box-sizing: border-box; border: 2px solid rgba(120, 190, 220, .82); border-radius: 50%;
box-shadow: 0 0 0 4px rgba(120, 190, 220, .16);
}
.remote {
position: absolute; transform: translate(12px, calc(-100% - 12px));
border: 1px solid rgba(255,255,255,.7); border-radius: 999px;
padding: 3px 7px; color: #fff; background: rgba(28,31,36,.88);
box-shadow: 0 2px 7px rgba(0,0,0,.28); white-space: nowrap;
font: 600 11px/1.2 system-ui, sans-serif;
}
@media (forced-colors: active) {
line { stroke: Highlight !important; }
.halo { border-color: Highlight; box-shadow: none; }
.remote { color: CanvasText; background: Canvas; border-color: CanvasText; }
}
`,customElements.get("hp-zigbee-topology-overlay")||customElements.define("hp-zigbee-topology-overlay",v);export{v as HpZigbeeTopologyOverlay};
@@ -1,36 +0,0 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="53ac71b2f6ce21ea7cf23cc2a1d9d4f35897a012eca18d4aac6ace3994022019";import{k as e,A as t,du as i,aw as s,c as o,l as r,j as a}from"./houseplan-card-BgNWi9NC.js";import{a as n,r as h,t as d}from"./zigbee-topology-Dm2vd-Wv.js";import{zigbeeTopologyRuntimeSnapshot as p,subscribeZigbeeTopology as c}from"./zigbee-topology-runtime-CZFgtAgE.js";const l={revision:0,topologies:[],states:{}};class v extends e{constructor(){super(...arguments),this.devices=[],this.currentSpace="",this._runtime=l,this._hovered="",this._pointerOver=e=>{if(!this._mouseAllowed(e))return void this._clear();const t=this._deviceFromEvent(e),i=t?.dataset.id||"";i&&i!==this._hovered&&(this._hovered=i,this.requestUpdate())},this._pointerOut=e=>{const t=this._deviceFromEvent(e);if(!t||t.dataset.id!==this._hovered)return;const i=e.relatedTarget instanceof Element?e.relatedTarget.closest('[data-hp="device"]'):null;i?.dataset.id!==this._hovered&&this._clear()},this._pointerDown=e=>{"mouse"!==e.pointerType&&this._clear()}}connectedCallback(){super.connectedCallback();const e=this.getRootNode();e.host&&"undefined"!=typeof MutationObserver&&(this._hoverGateObserver=new MutationObserver(()=>{e.host.hasAttribute("data-pointer-hover")||this._clear()}),this._hoverGateObserver.observe(e.host,{attributes:!0,attributeFilter:["data-pointer-hover"]})),queueMicrotask(()=>this._connectParent())}disconnectedCallback(){this._release?.(),this._release=void 0,this._disconnectParent(),this._hoverGateObserver?.disconnect(),this._hoverGateObserver=void 0,this._hovered="",super.disconnectedCallback()}updated(e){e.has("hass")&&(this._release?.(),this._runtime=p(this.hass),this._mappedMemo=void 0,this._release=c(this.hass,()=>{this._runtime=p(this.hass),this._mappedMemo=void 0,this.requestUpdate()})),(e.has("currentSpace")||e.has("devices")||e.has("registry"))&&(this._mappedMemo=void 0,this.devices.some(e=>e.id===this._hovered&&e.space===this.currentSpace)||(this._hovered=""))}_connectParent(){const e=this.parentElement;e&&e!==this._parent&&(this._disconnectParent(),this._parent=e,e.addEventListener("pointerover",this._pointerOver),e.addEventListener("pointerout",this._pointerOut),e.addEventListener("pointerdown",this._pointerDown,!0))}_disconnectParent(){this._parent?.removeEventListener("pointerover",this._pointerOver),this._parent?.removeEventListener("pointerout",this._pointerOut),this._parent?.removeEventListener("pointerdown",this._pointerDown,!0),this._parent=void 0}_deviceFromEvent(e){for(const t of e.composedPath())if(t instanceof HTMLElement&&"device"===t.dataset.hp)return t;return null}_mouseAllowed(e){const t=this.getRootNode();return"mouse"===e.pointerType&&t.host?.hasAttribute?.("data-pointer-hover")}_clear(){this._hovered&&(this._hovered="",this.requestUpdate())}_position(e){const t=[...this._parent?.querySelectorAll(".dev[data-id]")||[]].find(t=>t.dataset.id===e);if(!t)return null;const i=Number.parseFloat(t.style.left),s=Number.parseFloat(t.style.top),o=t.getBoundingClientRect();return Number.isFinite(i)&&Number.isFinite(s)?{x:i,y:s,width:o.width,height:o.height}:null}render(){if(!this._hovered||!this.registry||!this._runtime.topologies.length)return t;const e=this._mappedMemo,a=e&&e.runtime===this._runtime&&e.devices===this.devices&&e.registry===this.registry?e.mapped:n(this._runtime.topologies,this.devices,this.registry);e&&a===e.mapped||(this._mappedMemo={runtime:this._runtime,devices:this.devices,registry:this.registry,mapped:a});const p=h(a,this.currentSpace,this._hovered),c=this._position(this._hovered);if(!c)return t;const l=p.lines.map(e=>({...e,point:this._position(e.neighborMarkerId)})).filter(e=>!!e.point);return o`
${l.length?s`<svg viewBox="0 0 100 100" preserveAspectRatio="none"
aria-hidden="true" data-hp="zigbee-topology-lines">
${l.map(e=>s`<line x1=${c.x} y1=${c.y}
x2=${e.point.x} y2=${e.point.y}
stroke=${void 0===e.lqi?"rgba(145,155,165,.85)":i(e.lqi)}
stroke-width=${void 0===e.lqi?2:2.2}
stroke-dasharray=${void 0===e.lqi?"5 5":t} opacity=".9"></line>`)}
</svg>`:t}
${l.map(e=>o`<div class="halo" data-hp="zigbee-topology-neighbor"
data-id=${e.neighborMarkerId} style="left:${e.point.x}%;top:${e.point.y}%;width:${1.22*e.point.width}px;height:${1.22*e.point.height}px"></div>`)}
${p.remoteCount?o`<div class="remote" data-hp="zigbee-topology-remote"
style="left:${c.x}%;top:${c.y}%">${d(r(this.hass),"remote_count",{n:p.remoteCount})}</div>`:t}
`}}v.properties={hass:{attribute:!1},devices:{attribute:!1},registry:{attribute:!1},currentSpace:{type:String,attribute:"current-space"},viewKey:{attribute:!1}},v.styles=a`
:host { position: absolute; inset: 0; z-index: 5; display: block; pointer-events: none; }
svg { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }
line { vector-effect: non-scaling-stroke; stroke-linecap: round; }
.halo {
position: absolute; width: calc(var(--device-base-size, 4cqw) * 1.22);
height: calc(var(--device-base-size, 4cqw) * 1.22); transform: translate(-50%, -50%);
box-sizing: border-box; border: 2px solid rgba(120, 190, 220, .82); border-radius: 50%;
box-shadow: 0 0 0 4px rgba(120, 190, 220, .16);
}
.remote {
position: absolute; transform: translate(12px, calc(-100% - 12px));
border: 1px solid rgba(255,255,255,.7); border-radius: 999px;
padding: 3px 7px; color: #fff; background: rgba(28,31,36,.88);
box-shadow: 0 2px 7px rgba(0,0,0,.28); white-space: nowrap;
font: 600 11px/1.2 system-ui, sans-serif;
}
@media (forced-colors: active) {
line { stroke: Highlight !important; }
.halo { border-color: Highlight; box-shadow: none; }
.remote { color: CanvasText; background: Canvas; border-color: CanvasText; }
}
`,customElements.get("hp-zigbee-topology-overlay")||customElements.define("hp-zigbee-topology-overlay",v);export{v as HpZigbeeTopologyOverlay};
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="53ac71b2f6ce21ea7cf23cc2a1d9d4f35897a012eca18d4aac6ace3994022019";import{n as t,b as n}from"./zigbee-topology-Dm2vd-Wv.js";import{y as e}from"./houseplan-card-BgNWi9NC.js";const o=new WeakMap;function i(t){const n=function(t){const n=t?.connection||t;return!n||"object"!=typeof n&&"function"!=typeof n?null:n}(t);if(!n)return null;let e=o.get(n);return e||(e={revision:0,topologies:[],states:{},listeners:new Set,inflight:new Map},o.set(n,e)),e}function r(t,n,e){t.states={...t.states,[n]:e},function(t){t.revision++;for(const n of t.listeners)n()}(t)}function s(t){const n=t?.code;if("permission"===n||"unsupported"===n||"timeout"===n||"invalid_topic"===n||"invalid_payload"===n)return n;const e=String(t?.message||"").toLowerCase();return e.includes("unauthor")||e.includes("permission")?"permission":e.includes("unknown_command")||e.includes("not found")?"unsupported":"provider"}function a(t){return Object.assign(new Error(t),{code:t})}function c(t,n,e){const o=t.inflight.get(n);if(o)return o;r(t,n,{phase:"loading"});const i=e().then(n=>{if(!n.nodes.length&&n.warnings.some(t=>"invalid_payload"===t.code))throw a("invalid_payload");!function(t,n){t.topologies=[...t.topologies.filter(t=>!(t.provider===n.provider&&t.instanceId===n.instanceId)),n],r(t,"zha"===n.provider?"zha":`z2m:${n.instanceId}`,{phase:"ready",obtainedAt:n.obtainedAt,partial:n.warnings.length>0})}(t,n)}).catch(e=>{r(t,n,{phase:"error",error:s(e)})}).finally(()=>t.inflight.delete(n));return t.inflight.set(n,i),i}function l(t){const n=i(t);return n?{revision:n.revision,topologies:n.topologies,states:n.states}:{revision:0,topologies:[],states:{}}}function u(t,n){const e=i(t);return e?(e.listeners.add(n),()=>e.listeners.delete(n)):()=>{}}function p(t){if(!0!==t?.user?.is_admin)throw a("permission")}function f(n){const e=i(n);return e?c(e,"zha",async()=>{if(p(n),"function"!=typeof n?.callWS)throw a("unsupported");return t(await n.callWS({type:"zha/devices"}))}):Promise.resolve()}function d(t){return null!==t&&"object"==typeof t?t:null}function h(t){const n=d(t),e=n?.payload??t;if("string"!=typeof e)return e;try{return JSON.parse(e)}catch{return null}}async function m(t,n){let e;try{return await Promise.race([t,new Promise((t,o)=>{e=globalThis.setTimeout(()=>o(a("timeout")),Math.max(1,n))})])}finally{void 0!==e&&globalThis.clearTimeout(e)}}function g(t,o,r=15e4){const s=i(t);if(!s)return Promise.resolve();const l=e(o);return c(s,`z2m:${l||String(o)}`,async()=>{if(p(t),!l)throw a("invalid_topic");const e=t.connection,o=e?.subscribeMessage;if("function"!=typeof o||"function"!=typeof t?.callService)throw a("unsupported");const i=function(){const t=globalThis.crypto;return"function"==typeof t?.randomUUID?`houseplan-${t.randomUUID()}`:`houseplan-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`}(),s=Date.now()+Math.max(1,r);let c=!1,u=null,f=null,g=null;const y=new Promise(t=>{u=t}),w=new Promise((t,n)=>{f=t,g=n}),b=[];try{const r=await o.call(e,t=>{!0===d(t)?.retain&&h(t)&&u?.()},{type:"mqtt/subscribe",topic:`${l}/bridge/info`});"function"==typeof r&&b.push(r);const p=await o.call(e,t=>{if(!0===d(t)?.retain)return;const n=h(t);null!==n?n&&function(t){const n=d(t),e=n?.transaction??d(n?.data)?.transaction;return"string"==typeof e||"number"==typeof e?String(e):null}(n)===i&&f?.(n):c&&g?.(a("invalid_payload"))},{type:"mqtt/subscribe",topic:`${l}/bridge/response/networkmap`});"function"==typeof p&&b.push(p),await m(y,Math.min(4e3,Math.max(1,s-Date.now()))),c=!0,await t.callService("mqtt","publish",{topic:`${l}/bridge/request/networkmap`,payload:JSON.stringify({type:"raw",routes:!1,transaction:i}),qos:0,retain:!1});const v=await m(w,s-Date.now()),_=d(v)?.status;if(_&&"ok"!==_)throw a("provider");return n(v,l)}finally{for(const t of b)try{t()}catch{}}})}export{f as readZhaTopology,g as refreshZ2mTopology,u as subscribeZigbeeTopology,l as zigbeeTopologyRuntimeSnapshot};
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="9e1ebf9d6211c5c1c2104827caef16f417dbe2080f54fe178465ecd45392ebf4";import{n as t,b as n}from"./zigbee-topology-BrocYSC0.js";import{y as e}from"./houseplan-card-CbNfZuwV.js";const o=new WeakMap;function i(t){const n=function(t){const n=t?.connection||t;return!n||"object"!=typeof n&&"function"!=typeof n?null:n}(t);if(!n)return null;let e=o.get(n);return e||(e={revision:0,topologies:[],states:{},listeners:new Set,inflight:new Map},o.set(n,e)),e}function r(t,n,e){t.states={...t.states,[n]:e},function(t){t.revision++;for(const n of t.listeners)n()}(t)}function s(t){const n=t?.code;if("permission"===n||"unsupported"===n||"timeout"===n||"invalid_topic"===n||"invalid_payload"===n)return n;const e=String(t?.message||"").toLowerCase();return e.includes("unauthor")||e.includes("permission")?"permission":e.includes("unknown_command")||e.includes("not found")?"unsupported":"provider"}function a(t){return Object.assign(new Error(t),{code:t})}function c(t,n,e){const o=t.inflight.get(n);if(o)return o;r(t,n,{phase:"loading"});const i=e().then(n=>{if(!n.nodes.length&&n.warnings.some(t=>"invalid_payload"===t.code))throw a("invalid_payload");!function(t,n){t.topologies=[...t.topologies.filter(t=>!(t.provider===n.provider&&t.instanceId===n.instanceId)),n],r(t,"zha"===n.provider?"zha":`z2m:${n.instanceId}`,{phase:"ready",obtainedAt:n.obtainedAt,partial:n.warnings.length>0})}(t,n)}).catch(e=>{r(t,n,{phase:"error",error:s(e)})}).finally(()=>t.inflight.delete(n));return t.inflight.set(n,i),i}function l(t){const n=i(t);return n?{revision:n.revision,topologies:n.topologies,states:n.states}:{revision:0,topologies:[],states:{}}}function u(t,n){const e=i(t);return e?(e.listeners.add(n),()=>e.listeners.delete(n)):()=>{}}function f(t){if(!0!==t?.user?.is_admin)throw a("permission")}function p(n){const e=i(n);return e?c(e,"zha",async()=>{if(f(n),"function"!=typeof n?.callWS)throw a("unsupported");return t(await n.callWS({type:"zha/devices"}))}):Promise.resolve()}function d(t){return null!==t&&"object"==typeof t?t:null}function h(t){const n=d(t),e=n?.payload??t;if("string"!=typeof e)return e;try{return JSON.parse(e)}catch{return null}}async function m(t,n){let e;try{return await Promise.race([t,new Promise((t,o)=>{e=globalThis.setTimeout(()=>o(a("timeout")),Math.max(1,n))})])}finally{void 0!==e&&globalThis.clearTimeout(e)}}function g(t,o,r=15e4){const s=i(t);if(!s)return Promise.resolve();const l=e(o);return c(s,`z2m:${l||String(o)}`,async()=>{if(f(t),!l)throw a("invalid_topic");const e=t.connection,o=e?.subscribeMessage;if("function"!=typeof o||"function"!=typeof t?.callService)throw a("unsupported");const i=function(){const t=globalThis.crypto;return"function"==typeof t?.randomUUID?`houseplan-${t.randomUUID()}`:`houseplan-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`}(),s=Date.now()+Math.max(1,r);let c=!1,u=null,p=null,g=null;const y=new Promise(t=>{u=t}),w=new Promise((t,n)=>{p=t,g=n}),b=[];try{const r=await o.call(e,t=>{!0===d(t)?.retain&&h(t)&&u?.()},{type:"mqtt/subscribe",topic:`${l}/bridge/info`});"function"==typeof r&&b.push(r);const f=await o.call(e,t=>{if(!0===d(t)?.retain)return;const n=h(t);null!==n?n&&function(t){const n=d(t),e=n?.transaction??d(n?.data)?.transaction;return"string"==typeof e||"number"==typeof e?String(e):null}(n)===i&&p?.(n):c&&g?.(a("invalid_payload"))},{type:"mqtt/subscribe",topic:`${l}/bridge/response/networkmap`});"function"==typeof f&&b.push(f),await m(y,Math.min(4e3,Math.max(1,s-Date.now()))),c=!0,await t.callService("mqtt","publish",{topic:`${l}/bridge/request/networkmap`,payload:JSON.stringify({type:"raw",routes:!1,transaction:i}),qos:0,retain:!1});const v=await m(w,s-Date.now()),_=d(v)?.status;if(_&&"ok"!==_)throw a("provider");return n(v,l)}finally{for(const t of b)try{t()}catch{}}})}export{p as readZhaTopology,g as refreshZ2mTopology,u as subscribeZigbeeTopology,l as zigbeeTopologyRuntimeSnapshot};
+1 -1
View File
@@ -1 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="53ac71b2f6ce21ea7cf23cc2a1d9d4f35897a012eca18d4aac6ace3994022019";try{await import("./houseplan-assets/houseplan-card-BgNWi9NC.js")}catch(e){if(!customElements.get("houseplan-card")){const l=String(navigator.language||"en").toLowerCase();const m=l.startsWith("ru")?"House Plan обновился — перезагрузите страницу (Ctrl+F5).":l.startsWith("de")?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5).":l.startsWith("fr")?"House Plan a été mis à jour — veuillez recharger la page (Ctrl+F5).":"House Plan was updated — please reload the page (Ctrl+F5).";customElements.define("houseplan-card",class extends HTMLElement{setConfig(){}getCardSize(){return 1}connectedCallback(){this.style.cssText="display:block;box-sizing:border-box;padding:16px;border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";this.textContent=m}})}console.error("[houseplan] stale entry: the main chunk is unavailable",e)}
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="9e1ebf9d6211c5c1c2104827caef16f417dbe2080f54fe178465ecd45392ebf4";try{await import("./houseplan-assets/houseplan-card-CbNfZuwV.js")}catch(e){if(!customElements.get("houseplan-card")){const l=String(navigator.language||"en").toLowerCase();const m=l.startsWith("ru")?"House Plan обновился — перезагрузите страницу (Ctrl+F5).":l.startsWith("de")?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5).":l.startsWith("fr")?"House Plan a été mis à jour — veuillez recharger la page (Ctrl+F5).":"House Plan was updated — please reload the page (Ctrl+F5).";customElements.define("houseplan-card",class extends HTMLElement{setConfig(){}getCardSize(){return 1}connectedCallback(){this.style.cssText="display:block;box-sizing:border-box;padding:16px;border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";this.textContent=m}})}console.error("[houseplan] stale entry: the main chunk is unavailable",e)}
+10 -2
View File
@@ -252,7 +252,8 @@ 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 kiosk double-tap interpolate the existing exact camera target for
and the free-background 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`
the immediate path used before #82. Rapid wheel input retargets one RAF from
@@ -293,6 +294,13 @@ produce a zero-sized SVG `viewBox`.
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.
* **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
@@ -302,7 +310,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 the double-tap zoom reset is untouched.
nothing, so a clean free-background 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
+4
View File
@@ -2,6 +2,10 @@
## Unreleased
- Double-clicking or double-tapping free plan background in View or kiosk now
runs the same **Fit all** action as the toolbar button; rooms, devices,
openings and editor surfaces keep their own gestures
([#449](https://github.com/Matysh/houseplan-card/issues/449)).
- Zigbee2MQTT topology refresh now accepts the real camelCase raw network-map
fields (while retaining snake_case compatibility), so valid maps populate
devices and links instead of failing as unsupported data
+4
View File
@@ -8,6 +8,10 @@
## Не выпущено
- Двойной клик или двойной тап по свободному фону плана в Просмотре и киоске
теперь выполняет то же действие **«Вписать всё»**, что кнопка в панели;
комнаты, устройства, проёмы и редакторы сохраняют собственные жесты
([#449](https://github.com/Matysh/houseplan-card/issues/449)).
- Обновление топологии Zigbee2MQTT теперь понимает реальные camelCase-поля
raw-карты сети, сохраняя совместимость со snake_case: корректная карта
заполняет устройства и связи вместо ошибки неподдерживаемых данных
+2
View File
@@ -28,6 +28,8 @@ On phones, tablets, wall panels and HA Companion apps, the ordinary View must:
- render the plan and current states correctly;
- support convenient pan, pinch zoom and space switching;
- fit the whole plan after two clean taps on free scene background, while one
tap on a room keeps the immediate room-fit action;
- provide a touch path for essential information that desktop exposes through
hover;
- open and close View dialogs without clipping their essential content or
+5 -3
View File
@@ -242,7 +242,8 @@ objects; each space keeps its local View viewport.
| Scenario | Mouse | Touch View | Touch editors | Keyboard |
|---|---|---|---|---|
| Zoom and pan | Wheel; drag empty space; `−`/`+` | Pinch; drag; double-tap resets kiosk | Available but precision is not guaranteed | — |
| 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 | — |
| 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 |
@@ -252,8 +253,9 @@ objects; each space keeps its local View viewport.
At zoom above 1:1 a horizontal kiosk gesture pans instead of changing space.
Any manual kiosk operation pauses auto-cycle for 60 seconds.
Wheel, `−`/`+`, **Fit all**, the return arrow and kiosk double-tap use a short
smooth camera transition. Rapid wheel input changes the current destination
Wheel, `−`/`+`, **Fit all**, the return arrow and a free-background
double-click/tap in View or kiosk use a short smooth camera transition. Rapid
wheel input changes the current destination
instead of building a queue. Pinch and pan stay directly under the fingers.
With the operating system's reduced-motion preference enabled, every zoom
command is immediate.
+4 -4
View File
@@ -276,7 +276,7 @@ desktop: для точного рисования, Resize, модификато
| Сценарий | Мышь | Touch в просмотре | Touch в редакторах | Клавиатура |
|---|---|---|---|---|
| Масштаб и панорама | Колесо; drag пустого места; кнопки `−`/`+` | Щипок; drag; двойной тап сбрасывает киоск | Работает, но точность не гарантируется | — |
| Масштаб и панорама | Колесо; drag пустого места; кнопки `−`/`+`; двойной клик по свободному фону вписывает всё | Щипок; drag; двойной тап по свободному фону вписывает всё | Работает, но точность не гарантируется; двойного тапа «Вписать всё» нет | — |
| Комната | Клик по свободной части комнаты вписывает её с полями 10% | Одиночный tap вписывает комнату; повторный tap не сбрасывает киоск | — | `Enter`/`Space` на видимой подписи комнаты |
| Переключение пространства | Клик по вкладке | Клик; в киоске свайп при масштабе 1:1 | Клик по вкладке | — |
| Устройство | Клик/двойной клик по правилам режима | Tap; безопасные действия как на desktop | Drag и свойства — best effort | `Esc` закрывает верхнюю поверхность |
@@ -286,9 +286,9 @@ desktop: для точного рисования, Resize, модификато
Особенности:
- колесо, кнопки `−`/`+`, «Вписать всё», стрелка возврата и двойной тап в
киоске используют короткий плавный переход камеры; частые события колеса
меняют текущую цель, а не выстраиваются в очередь;
- колесо, кнопки `−`/`+`, «Вписать всё», стрелка возврата и двойной клик/тап по
свободному фону в Просмотре или киоске используют короткий плавный переход
камеры; частые события колеса меняют текущую цель, а не выстраиваются в очередь;
- щипок и панорамирование по-прежнему следуют за пальцами без задержки; при
включённом в системе уменьшении движения любой масштаб меняется мгновенно;
- панорамирование работает при любом масштабе;
+3 -2
View File
@@ -64,11 +64,12 @@ support. User documentation recommends desktop for creation and maintenance.
## View — display and device interaction only
Allowed: pan/zoom (wheel, pinch, buttons), switching spaces, device tap
Allowed: pan/zoom (wheel, pinch, buttons, double-click/tap on free background
to Fit all), switching spaces, device tap
(info / more-info / toggle per settings), long-press → info card, opening tap →
door/lock info card (with an explicit Unlock/Lock button when a lock is bound —
the only way to operate a lock from the card; plan-icon taps never toggle locks),
room-card link icon → HA area (room taps do nothing since v1.40.1), room hover
room-card link icon → HA area, clean room click/tap → room fit, room hover
highlight, hover tooltips (name, clean-floor area, temperature, signal).
Room, device, opening and shared-control hover is a mouse-only transient layer:
+48 -4
View File
@@ -1476,8 +1476,8 @@ const MUTANT_DEFINITIONS = [
+ 'the room camera command (#152 AC6)',
patches: [{
file: 'src/room-fit.ts',
find: ' if (node.matches(ROOM_FIT_INTERACTIVE_OWNER)) return null;',
replace: ' if (node.matches(ROOM_FIT_INTERACTIVE_OWNER)) continue;',
find: " if (pathMatches(node, ROOM_FIT_INTERACTIVE_OWNER)) return { kind: 'interactive' };",
replace: " if (pathMatches(node, ROOM_FIT_INTERACTIVE_OWNER)) continue;",
}],
},
{
@@ -1487,8 +1487,8 @@ const MUTANT_DEFINITIONS = [
+ 'is released over the original room (#152 AC7)',
patches: [{
file: 'src/houseplan-card.ts',
find: ' this._roomPointer = null;\n this._suppressClick = true;',
replace: ' this._suppressClick = true;',
find: ' this._roomPointer = null;\n this._doubleFit.clear();\n this._suppressClick = true;',
replace: ' this._doubleFit.clear();\n this._suppressClick = true;',
}, {
file: 'src/room-fit.ts',
find: ' if (!candidate || blocked || candidate.pointerId !== pointerId',
@@ -5673,6 +5673,50 @@ const MUTANT_DEFINITIONS = [
replace: ' try { void unsubscribe; } catch { /* cleanup is best effort */ }',
}],
},
{
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)',
patches: [{
file: 'src/room-fit.ts',
find: " if (!modality || input.mode !== 'view' || input.owner.kind !== 'background'",
replace: " if (!modality || input.mode !== 'view' || false",
}],
},
{
id: 'double-fit-navigation-block-ignored',
guard: 'node --test --test-name-pattern="#449 moved" test/room-fit.test.mjs',
because: 'a pan, pinch, swipe, long press or cancelled pointer must disarm the sequence '
+ 'instead of completing fit-all (#449 AC6)',
patches: [{
file: 'src/room-fit.ts',
find: " || input.blocked || candidate.pointerId !== input.pointerId",
replace: ' || candidate.pointerId !== input.pointerId',
}],
},
{
id: 'double-fit-editor-mode-enabled',
guard: 'node --test --test-name-pattern="#449 only" 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'",
}],
},
{
id: 'double-fit-bypasses-canonical-fit-all',
guard: 'node demo/smoke_room_fit.mjs',
because: 'the gesture must share the toolbar command, including far-object framing and '
+ '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');",
}],
},
];
const mutationCardSource = readFileSync(join(repoRoot, 'src/houseplan-card.ts'), 'utf8');
+25 -25
View File
@@ -316,8 +316,9 @@ import {
type CameraState, type CameraTransitionReason, type CameraTransitionState,
} from './viewport-transition';
import {
acceptedRoomFitGesture, roomFitCameraTarget, roomFitClampFrame,
roomFitGeometryBounds, roomFitOwnerFromPath,
acceptedRoomFitGesture, DoubleFitGestureRecognizer, roomFitCameraTarget,
roomFitClampFrame,
roomFitGeometryBounds, roomFitOwnerFromPath, STAGE_TAP_DISTANCE_PX,
type RoomFitGestureCandidate,
} from './room-fit';
import { EditorRuntimeLoader, lazyLoadFailureMessage, type EditorRuntimeLoaderState } from './editor-runtime-loader';
@@ -1092,6 +1093,8 @@ export class HouseplanCard extends LitElement {
return this._modeTransitionPreparing || this._modeTransition.active;
}
private get _doubleFitEnabled(): boolean { return this._mode === 'view' && !this._vacFit && !this._modeTransitionBusy && !this._suppressClick && !this._touchSequenceMultitouch && this._continuity.state === 'steady' && !this._continuity.overlayBlocksInteraction; }
private _cameraState(): CameraState {
const view = this._viewOr(this._baseVb());
return { zoom: this._zoom, viewBox: { ...view } };
@@ -1937,7 +1940,7 @@ 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 _roomPointer: RoomFitGestureCandidate | null = null; private readonly _doubleFit = new DoubleFitGestureRecognizer();
private _pointers = new Map<number, { x: number; y: number }>();
private _panStart: { sx: number; sy: number; vx: number; vy: number } | null = null;
/**
@@ -2376,7 +2379,6 @@ export class HouseplanCard extends LitElement {
/** The positional-`floor` warning is worth saying once, not on every drop. */
private _tabOrderWarned = false;
private _lastTap = 0;
private get _labsIso(): boolean {
return this._labs.active.includes('iso');
}
@@ -5663,6 +5665,7 @@ export class HouseplanCard extends LitElement {
this._pinchStart = null;
this._swipeStart = null;
this._roomPointer = null;
this._doubleFit.clear();
}
/** Close the device card and make stale long-press state non-observable. */
@@ -6214,16 +6217,11 @@ export class HouseplanCard extends LitElement {
}
private _clearRoomFocus(pointer = false): void {
this._doubleFit.clear();
this._roomFocus = null;
if (pointer) this._roomPointer = null;
}
/** The rendered SVG/label event path is the same authority as hover. */
private _roomOwner(ev: Event): string | null {
if (this._mode !== 'view') return null;
return roomFitOwnerFromPath(ev.composedPath());
}
/** Exact finite room geometry in the current camera coordinate system. */
private _roomFitBounds(room: RoomCfg, space: SpaceModel) {
const own = roomPoly(room);
@@ -6265,6 +6263,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();
const space = this._spaceModel();
const room = space?.rooms.find((item) => item.id === roomId);
if (!space || !room) {
@@ -6315,7 +6314,7 @@ export class HouseplanCard extends LitElement {
/** «Вписать всё» (docs/CANVAS.md §8) — the toolbar button and the "home is
* that way" arrow share it. It fits whatever the frame currently means:
* the main mass, or everything once the far-objects hint has been used. */
private _fitAll(reason: 'fit' | 'home' = 'fit'): void {
private _fitAll(reason: 'fit' | 'home' | 'double-tap' = 'fit'): void {
this._clearRoomFocus();
this._showFar = true;
this._frame = null;
@@ -6740,10 +6739,11 @@ export class HouseplanCard extends LitElement {
}
private _stagePointerDown(ev: PointerEvent): void {
const roomId = ev.isPrimary && ev.button === 0 ? this._roomOwner(ev) : null;
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);
// 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);
@@ -6761,6 +6761,7 @@ export class HouseplanCard extends LitElement {
clearTimeout(this._kioskHoldTimer);
this._kioskHoldTimer = window.setTimeout(() => {
this._roomPointer = null;
this._doubleFit.clear();
this._kioskDialog = true;
this._swipeStart = null;
}, 3000);
@@ -6889,6 +6890,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._suppressClick = true;
clearTimeout(this._holdTimer);
if (this._tool === 'opening') {
@@ -6905,7 +6907,7 @@ export class HouseplanCard extends LitElement {
// 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) {
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';
if (this._panLock === 'pan') this._clearRoomFocus();
}
@@ -6943,11 +6945,13 @@ export class HouseplanCard extends LitElement {
this._roomPointer,
ev.pointerId,
this._space,
this._roomPointer ? this._roomOwner(ev) : null,
this._roomPointer && this._mode === 'view' ? roomFitOwnerFromPath(ev.composedPath()) : null,
this._suppressClick || !!this._pinchStart || this._panLock !== null
|| this._holdFired || this._touchSequenceMultitouch,
);
if (this._roomPointer?.pointerId === ev.pointerId) this._roomPointer = null;
const doubleFit = this._doubleFit.pointerUp(ev, this._space, this._doubleFitEnabled, !!this._pinchStart || this._panLock !== null || this._holdFired);
if (doubleFit) this._fitAll('double-tap');
if (this._kiosk) {
clearTimeout(this._kioskHoldTimer);
const ss = this._swipeStart;
@@ -6955,12 +6959,6 @@ export class HouseplanCard extends LitElement {
if (!acceptedRoom && ss && ss.id === ev.pointerId) {
const dx = ev.clientX - ss.x;
const dy = ev.clientY - ss.y;
// double tap (no movement) resets the zoom to 1:1
if (Math.abs(dx) + Math.abs(dy) < 8) {
const now = Date.now();
if (now - this._lastTap < 350) this._resetZoom('double-tap');
this._lastTap = now;
}
// 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
@@ -6969,8 +6967,8 @@ export class HouseplanCard extends LitElement {
// '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 the
// double-tap zoom reset above is untouched.
// 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);
@@ -7015,14 +7013,14 @@ export class HouseplanCard extends LitElement {
return;
}
if (this._editorRuntime?._furnPointerUp(ev)) return;
const viewportGestureEnded = !!this._pinchStart || !!this._panStart;
const viewportGestureEnded = !!this._pinchStart || this._panLock === 'pan';
this._pointers.delete(ev.pointerId);
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);
if (this._suppressClick) setTimeout(() => (this._suppressClick = false), 0);
}
if (viewportGestureEnded && this._pointers.size === 0 && !acceptedRoom) this.requestUpdate();
if (acceptedRoom) this._fitRoom(acceptedRoom);
@@ -7232,6 +7230,7 @@ export class HouseplanCard extends LitElement {
}
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 (pointer.pointerType !== 'touch') return;
if (ev.type === 'pointerdown') {
this._touchContacts.set(pointer.pointerId, {
@@ -7862,7 +7861,8 @@ export class HouseplanCard extends LitElement {
/** Browser/OS cancellation is an aborted transaction, never a commit. */
private _stagePointerCancel(ev: PointerEvent): void {
if (this._roomPointer?.pointerId === ev.pointerId) this._roomPointer = null; if (this._editorRuntime) return this._editorRuntime._stagePointerCancel(ev);
if (this._roomPointer?.pointerId === ev.pointerId) this._roomPointer = null; this._doubleFit.clear();
if (this._editorRuntime) return this._editorRuntime._stagePointerCancel(ev);
const viewportGestureEnded = !!this._pinchStart || !!this._panStart; this._pointers.delete(ev.pointerId);
if (this._pointers.size < 2) this._pinchStart = null;
if (this._pointers.size === 0) {
+192 -12
View File
@@ -28,6 +28,67 @@ export interface RoomFitGestureCandidate {
roomId: string;
}
export type DoubleFitPointerModality = 'mouse' | 'touch' | 'pen';
export interface DoubleFitPointerCandidate {
pointerId: number;
spaceId: string;
modality: DoubleFitPointerModality;
x: number;
y: number;
}
export interface DoubleFitTapSequence {
at: number;
spaceId: string;
modality: DoubleFitPointerModality;
}
export type PlanGestureOwner =
| { kind: 'background' }
| { kind: 'room'; roomId: string }
| { kind: 'interactive' }
| { kind: 'outside' };
export interface DoubleFitPointerDownInput {
pointerId: number;
pointerType: string;
isPrimary: boolean;
button: number;
spaceId: string;
mode: string;
owner: PlanGestureOwner;
blocked: boolean;
x: number;
y: number;
}
export interface DoubleFitPointerUpInput {
pointerId: number;
spaceId: string;
mode: string;
owner: PlanGestureOwner;
blocked: boolean;
x: number;
y: number;
now: number;
}
export interface DoubleFitResult {
sequence: DoubleFitTapSequence | null;
trigger: boolean;
}
interface DoubleFitPointerEventLike {
pointerId: number;
pointerType: string;
isPrimary: boolean;
button: number;
clientX: number;
clientY: number;
composedPath: () => readonly unknown[];
}
interface RoomFitPathNode {
matches?: (selector: string) => boolean;
getAttribute?: (name: string) => string | null;
@@ -36,27 +97,146 @@ interface RoomFitPathNode {
const ROOM_FIT_INTERACTIVE_OWNER = [
'.dev', '.vacpuck', '.oplock', '.op-hit', '.opening', '.rlgo',
'a', 'button', 'input', 'select', 'textarea',
'[contenteditable="true"]', '[data-room-fit-block]', '[role="link"]', '[role="button"]',
'[contenteditable]:not([contenteditable="false"])',
'[data-room-fit-block]', '[role="link"]', '[role="button"]',
].join(',');
export const DOUBLE_FIT_WINDOW_MS = 350;
/** Existing kiosk clean-tap / stage pan-lock boundary. */
export const STAGE_TAP_DISTANCE_PX = 8;
const pathMatches = (node: RoomFitPathNode, selector: string): boolean => {
try {
return typeof node?.matches === 'function' && node.matches(selector);
} catch {
return false;
}
};
/**
* 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.
*/
export function planGestureOwnerFromPath(path: readonly unknown[]): PlanGestureOwner {
for (const raw of path) {
const node = raw as RoomFitPathNode;
if (pathMatches(node, '.roomlabel[data-id]')) {
const roomId = node.getAttribute?.('data-id');
return roomId ? { kind: 'room', roomId } : { kind: 'interactive' };
}
if (pathMatches(node, ROOM_FIT_INTERACTIVE_OWNER)) return { kind: 'interactive' };
if (pathMatches(node, '[data-hp="room"][data-id]')) {
const roomId = node.getAttribute?.('data-id');
return roomId ? { kind: 'room', roomId } : { kind: 'interactive' };
}
if (pathMatches(node, '.stage')) return { kind: 'background' };
}
return { kind: 'outside' };
}
/**
* Resolve the browser-painted room owner. The first independently interactive
* node in the composed path wins; the room label itself is the one deliberate
* role=button exception because it maps back to the same room command.
*/
export function roomFitOwnerFromPath(path: readonly unknown[]): string | null {
for (const raw of path) {
const node = raw as RoomFitPathNode;
if (typeof node?.matches !== 'function') continue;
if (node.matches('.roomlabel[data-id]')) {
return node.getAttribute?.('data-id') || null;
}
if (node.matches(ROOM_FIT_INTERACTIVE_OWNER)) return null;
if (node.matches('[data-hp="room"][data-id]')) {
return node.getAttribute?.('data-id') || null;
}
const owner = planGestureOwnerFromPath(path);
return owner.kind === 'room' ? owner.roomId : null;
}
const pointerModality = (value: string): DoubleFitPointerModality | null =>
value === 'mouse' || value === 'touch' || value === 'pen' ? value : null;
export function beginDoubleFitPointer(
input: DoubleFitPointerDownInput,
): DoubleFitPointerCandidate | null {
const modality = pointerModality(input.pointerType);
if (!modality || input.mode !== 'view' || input.owner.kind !== 'background'
|| input.blocked || !input.isPrimary || input.button !== 0
|| !Number.isFinite(input.x) || !Number.isFinite(input.y)) return null;
return {
pointerId: input.pointerId,
spaceId: input.spaceId,
modality,
x: input.x,
y: input.y,
};
}
/** Pure release arbitration. Invalid input always disarms the previous tap. */
export function completeDoubleFitPointer(
sequence: DoubleFitTapSequence | null,
candidate: DoubleFitPointerCandidate | null,
input: DoubleFitPointerUpInput,
): DoubleFitResult {
if (!candidate || input.mode !== 'view' || input.owner.kind !== 'background'
|| input.blocked || candidate.pointerId !== input.pointerId
|| candidate.spaceId !== input.spaceId
|| !Number.isFinite(input.x) || !Number.isFinite(input.y)
|| !Number.isFinite(input.now)
|| Math.abs(input.x - candidate.x) + Math.abs(input.y - candidate.y)
>= STAGE_TAP_DISTANCE_PX) {
return { sequence: null, trigger: false };
}
const elapsed = sequence ? input.now - sequence.at : Number.POSITIVE_INFINITY;
if (sequence && sequence.spaceId === input.spaceId
&& sequence.modality === candidate.modality
&& elapsed >= 0 && elapsed <= DOUBLE_FIT_WINDOW_MS) {
return { sequence: null, trigger: true };
}
return {
sequence: { at: input.now, spaceId: input.spaceId, modality: candidate.modality },
trigger: false,
};
}
/** Per-card owner of the two transient halves; no timers, renders or writes. */
export class DoubleFitGestureRecognizer {
private pointer: DoubleFitPointerCandidate | null = null;
private sequence: DoubleFitTapSequence | null = null;
clear(): void {
this.pointer = null;
this.sequence = null;
}
clearOutside(event: DoubleFitPointerEventLike): void {
let owner: PlanGestureOwner = { kind: 'outside' };
try { owner = planGestureOwnerFromPath(event.composedPath()); } catch { /* fail closed */ }
if (owner.kind === 'outside') this.clear();
}
pointerDown(event: DoubleFitPointerEventLike, spaceId: string, enabled: boolean): void {
let owner: PlanGestureOwner = { kind: 'outside' };
try { owner = planGestureOwnerFromPath(event.composedPath()); } catch { /* fail closed */ }
this.pointer = beginDoubleFitPointer({
pointerId: event.pointerId, pointerType: event.pointerType,
isPrimary: event.isPrimary, button: event.button, spaceId,
mode: enabled ? 'view' : 'blocked', owner, blocked: !enabled,
x: event.clientX, y: event.clientY,
});
if (!this.pointer) this.sequence = null;
}
pointerUp(
event: DoubleFitPointerEventLike,
spaceId: string,
enabled: boolean,
blocked: boolean,
now = Date.now(),
): boolean {
let owner: PlanGestureOwner = { kind: 'outside' };
try { owner = planGestureOwnerFromPath(event.composedPath()); } catch { /* fail closed */ }
const result = completeDoubleFitPointer(this.sequence, this.pointer, {
pointerId: event.pointerId, spaceId, mode: enabled ? 'view' : 'blocked', owner,
blocked: blocked || !enabled, x: event.clientX, y: event.clientY, now,
});
this.pointer = null;
this.sequence = result.sequence;
return result.trigger;
}
return null;
}
export function acceptedRoomFitGesture(
+131
View File
@@ -2,6 +2,11 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import {
acceptedRoomFitGesture,
beginDoubleFitPointer,
completeDoubleFitPointer,
DoubleFitGestureRecognizer,
DOUBLE_FIT_WINDOW_MS,
planGestureOwnerFromPath,
roomFitCameraTarget,
roomFitClampFrame,
roomFitGeometryBounds,
@@ -123,11 +128,23 @@ const pathNode = (selectors, id = null) => ({
test('#152 browser event path is the room authority and interactive children suppress it', () => {
const room = pathNode(['[data-hp="room"][data-id]'], 'room-a');
const label = pathNode(['.roomlabel[data-id]', '[role="button"]'], 'room-a');
const stage = pathNode(['.stage']);
assert.equal(roomFitOwnerFromPath([room]), 'room-a');
assert.equal(roomFitOwnerFromPath([pathNode(['.rlname']), label]), 'room-a');
assert.equal(roomFitOwnerFromPath([pathNode(['.rlgo', '[role="link"]']), label]), null);
assert.equal(roomFitOwnerFromPath([pathNode(['.dev']), room]), null);
assert.equal(roomFitOwnerFromPath([pathNode(['button']), room]), null);
assert.deepEqual(planGestureOwnerFromPath([stage]), { kind: 'background' });
assert.deepEqual(planGestureOwnerFromPath([room, stage]), { kind: 'room', roomId: 'room-a' });
assert.deepEqual(planGestureOwnerFromPath([pathNode(['.dev']), room, stage]),
{ kind: 'interactive' });
assert.deepEqual(planGestureOwnerFromPath([pathNode(['button']), stage]),
{ kind: 'interactive' });
assert.deepEqual(planGestureOwnerFromPath([pathNode(['.opening']), stage]),
{ kind: 'interactive' });
assert.deepEqual(planGestureOwnerFromPath([pathNode(['[data-room-fit-block]']), stage]),
{ kind: 'interactive' });
assert.deepEqual(planGestureOwnerFromPath([]), { kind: 'outside' });
});
test('#152 release accepts only the same pointer, space and painted room', () => {
@@ -138,3 +155,117 @@ test('#152 release accepts only the same pointer, space and painted room', () =>
assert.equal(acceptedRoomFitGesture(candidate, 7, 'floor-a', 'room-b', false), null);
assert.equal(acceptedRoomFitGesture(candidate, 7, 'floor-a', 'room-a', true), null);
});
const background = { kind: 'background' };
const down = (overrides = {}) => beginDoubleFitPointer({
pointerId: 1,
pointerType: 'mouse',
isPrimary: true,
button: 0,
spaceId: 'floor-a',
mode: 'view',
owner: background,
blocked: false,
x: 100,
y: 100,
...overrides,
});
const up = (sequence, candidate, overrides = {}) => completeDoubleFitPointer(
sequence,
candidate,
{
pointerId: 1,
spaceId: 'floor-a',
mode: 'view',
owner: background,
blocked: false,
x: 101,
y: 100,
now: 1_000,
...overrides,
},
);
test('#449 only a primary clean View press on free stage background becomes a candidate', () => {
assert.deepEqual(down(), {
pointerId: 1, spaceId: 'floor-a', modality: 'mouse', x: 100, y: 100,
});
assert.equal(down({ pointerType: 'touch' }).modality, 'touch');
assert.equal(down({ pointerType: 'pen' }).modality, 'pen');
for (const rejected of [
{ pointerType: '' },
{ isPrimary: false },
{ button: 2 },
{ mode: 'plan' },
{ owner: { kind: 'room', roomId: 'room-a' } },
{ owner: { kind: 'interactive' } },
{ owner: { kind: 'outside' } },
{ blocked: true },
]) assert.equal(down(rejected), null);
});
test('#449 two clean taps within 350 ms trigger once and clear before the command', () => {
const first = up(null, down());
assert.equal(first.trigger, false);
assert.deepEqual(first.sequence, { at: 1_000, spaceId: 'floor-a', modality: 'mouse' });
const secondCandidate = down({ pointerId: 2 });
const second = up(first.sequence, secondCandidate, { pointerId: 2, now: 1_350 });
assert.deepEqual(second, { sequence: null, trigger: true });
assert.deepEqual(up(second.sequence, down({ pointerId: 3 }), { pointerId: 3, now: 1_351 }), {
sequence: { at: 1_351, spaceId: 'floor-a', modality: 'mouse' }, 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 });
assert.equal(expired.trigger, false);
assert.equal(expired.sequence.at, 1_000 + DOUBLE_FIT_WINDOW_MS + 1);
const touch = up(old, down({ pointerType: 'touch' }), { now: 1_100 });
assert.deepEqual(touch, {
sequence: { at: 1_100, spaceId: 'floor-a', modality: 'touch' }, trigger: false,
});
const otherSpace = up(old, down({ spaceId: 'floor-b' }), {
spaceId: 'floor-b', now: 1_100,
});
assert.deepEqual(otherSpace, {
sequence: { at: 1_100, spaceId: 'floor-b', modality: 'mouse' }, trigger: false,
});
});
test('#449 moved, cancelled, multitouch, foreign-owner and editor releases disarm the pair', () => {
const armed = { at: 900, spaceId: 'floor-a', modality: 'mouse' };
const candidate = down();
const invalid = [
{ pointerId: 2 },
{ spaceId: 'floor-b' },
{ mode: 'decor' },
{ owner: { kind: 'room', roomId: 'room-a' } },
{ owner: { kind: 'interactive' } },
{ blocked: true },
{ x: 108, y: 100 },
];
for (const input of invalid) {
assert.deepEqual(up(armed, candidate, input), { sequence: null, trigger: false });
}
assert.deepEqual(up(armed, null), { sequence: null, trigger: false });
});
test('#449 recognizer instances keep independent transient sequences', () => {
const stage = pathNode(['.stage']);
const event = (pointerId) => ({
pointerId, pointerType: 'mouse', isPrimary: true, button: 0,
clientX: 10, clientY: 10, composedPath: () => [stage],
});
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);
second.pointerDown(event(2), 'floor-a', true);
assert.equal(second.pointerUp(event(2), 'floor-a', true, false, 1_100), false);
first.pointerDown(event(3), 'floor-a', true);
assert.equal(first.pointerUp(event(3), 'floor-a', true, false, 1_200), true);
second.clear();
second.pointerDown(event(4), 'floor-a', true);
assert.equal(second.pointerUp(event(4), 'floor-a', true, false, 1_250), false);
});