Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3cd238b2d4 | ||
|
|
cc73252aa7 | ||
|
|
b5324ce4e3 | ||
|
|
9171644f68 | ||
|
|
64f54fd4b0 | ||
|
|
4e24e2668a | ||
|
|
4c4399a729 | ||
|
|
a945ca473b | ||
|
|
b3abfc6744 | ||
|
|
5440370414 | ||
|
|
c465eae821 | ||
|
|
486a5d7bc2 | ||
|
|
7610b32306 | ||
|
|
8a18e81278 |
@@ -1,125 +1,125 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"fingerprint": "a75fd51476e6981375898466243f1f67acd5af627334883a490aeed45b89813f",
|
||||
"fingerprint": "099688ecae2a6f015325a6ffa40c7112bd2726309f7760aec7d6b2534ad2f347",
|
||||
"entry": "houseplan-card.js",
|
||||
"initialViewFiles": [
|
||||
"houseplan-assets/houseplan-card-DdUxnMX-.js",
|
||||
"houseplan-assets/houseplan-card-DzlQl4_k.js",
|
||||
"houseplan-card.js"
|
||||
],
|
||||
"initialViewGzipBytes": 281785,
|
||||
"initialViewGzipBytes": 283094,
|
||||
"lazyFiles": [
|
||||
"houseplan-assets/backdrop-pick-DAhkK64J.js",
|
||||
"houseplan-assets/de-CeeGlSdH.js",
|
||||
"houseplan-assets/editor-CWRRIfyY.js",
|
||||
"houseplan-assets/fr-BIGiyq-a.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-DmHZCYVI.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-zkpLLWyG.js"
|
||||
"houseplan-assets/backdrop-pick-CJ3kZCi2.js",
|
||||
"houseplan-assets/de-Dq2fGpol.js",
|
||||
"houseplan-assets/editor-DrQ9S89m.js",
|
||||
"houseplan-assets/fr-Bq4OMxB4.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-C6fRvS15.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-HmSdEh0z.js"
|
||||
],
|
||||
"lazyGzipBytes": 197610,
|
||||
"lazyGzipBytes": 197612,
|
||||
"lazyEditorFiles": [
|
||||
"houseplan-assets/backdrop-pick-DAhkK64J.js",
|
||||
"houseplan-assets/editor-CWRRIfyY.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-DmHZCYVI.js"
|
||||
"houseplan-assets/backdrop-pick-CJ3kZCi2.js",
|
||||
"houseplan-assets/editor-DrQ9S89m.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-C6fRvS15.js"
|
||||
],
|
||||
"lazyEditorGzipBytes": 143896,
|
||||
"lazyEditorGzipBytes": 143897,
|
||||
"lazyOnboardingFiles": [
|
||||
"houseplan-assets/backdrop-pick-DAhkK64J.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-zkpLLWyG.js"
|
||||
"houseplan-assets/backdrop-pick-CJ3kZCi2.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-HmSdEh0z.js"
|
||||
],
|
||||
"lazyOnboardingGzipBytes": 13791,
|
||||
"lazyOnboardingGzipBytes": 13789,
|
||||
"lazyLocaleFiles": [
|
||||
"houseplan-assets/de-CeeGlSdH.js",
|
||||
"houseplan-assets/fr-BIGiyq-a.js"
|
||||
"houseplan-assets/de-Dq2fGpol.js",
|
||||
"houseplan-assets/fr-Bq4OMxB4.js"
|
||||
],
|
||||
"lazyLocaleGzipBytes": 46993,
|
||||
"lazyLocaleGzipBytes": 46997,
|
||||
"files": [
|
||||
{
|
||||
"path": "houseplan-assets/backdrop-pick-DAhkK64J.js",
|
||||
"sha256": "d0c0fc8d1a54632e83bf0832717f9cad9aef21e03740f39a68e254b9fa2de006",
|
||||
"path": "houseplan-assets/backdrop-pick-CJ3kZCi2.js",
|
||||
"sha256": "383eea583e6ee787be10c7766420bbff1738f466827bcada4dbb2fbf1de24505",
|
||||
"rawBytes": 20636,
|
||||
"gzipBytes": 7070,
|
||||
"gzipBytes": 7071,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-DdUxnMX-.js"
|
||||
"houseplan-assets/houseplan-card-DzlQl4_k.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/de-CeeGlSdH.js",
|
||||
"sha256": "833468501744eb020baadd4664c352cde634c2324280ad32ed4c3c4353d44857",
|
||||
"path": "houseplan-assets/de-Dq2fGpol.js",
|
||||
"sha256": "17cca86ed13e257b06567609834707e975e9dacb38e495208a5bceedf4e7da8f",
|
||||
"rawBytes": 78310,
|
||||
"gzipBytes": 23718,
|
||||
"gzipBytes": 23721,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/editor-CWRRIfyY.js",
|
||||
"sha256": "0abaa66a50efc8648f0eddc9e91b9cbd52213f70741e5193fbee331b28e2b3a7",
|
||||
"path": "houseplan-assets/editor-DrQ9S89m.js",
|
||||
"sha256": "0b137accab6d6ed51f44aed3d4d2b1ff444b5307ac9c11ff0cb9535cfd25bb2c",
|
||||
"rawBytes": 3826,
|
||||
"gzipBytes": 1581,
|
||||
"gzipBytes": 1582,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-DdUxnMX-.js"
|
||||
"houseplan-assets/houseplan-card-DzlQl4_k.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/fr-BIGiyq-a.js",
|
||||
"sha256": "a553eff93f33804b66b2e1be445960d38c4222f03627e78b0514299989ba9a51",
|
||||
"path": "houseplan-assets/fr-Bq4OMxB4.js",
|
||||
"sha256": "da0d137d42243e36315ed6ad73197210ea12cf672cf14eb7cb6320594c9aec56",
|
||||
"rawBytes": 80482,
|
||||
"gzipBytes": 23275,
|
||||
"gzipBytes": 23276,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-card-DdUxnMX-.js",
|
||||
"sha256": "eea3b14c28ac1f495e685d7b708ac3ee9c585efdc2cefc1454d962454cbfa91b",
|
||||
"rawBytes": 996305,
|
||||
"gzipBytes": 280990,
|
||||
"path": "houseplan-assets/houseplan-card-DzlQl4_k.js",
|
||||
"sha256": "ac14483abe54719e12506df7eef1f26f4fd570c92600ea781159e445249ecca9",
|
||||
"rawBytes": 1001346,
|
||||
"gzipBytes": 282298,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": [
|
||||
"houseplan-assets/de-CeeGlSdH.js",
|
||||
"houseplan-assets/editor-CWRRIfyY.js",
|
||||
"houseplan-assets/fr-BIGiyq-a.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-DmHZCYVI.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-zkpLLWyG.js"
|
||||
"houseplan-assets/de-Dq2fGpol.js",
|
||||
"houseplan-assets/editor-DrQ9S89m.js",
|
||||
"houseplan-assets/fr-Bq4OMxB4.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-C6fRvS15.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-HmSdEh0z.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-editor-runtime-DmHZCYVI.js",
|
||||
"sha256": "cc614c3f22050e61668b34887dd6bfe2be7f84619b4b57202674f45c5790b42f",
|
||||
"path": "houseplan-assets/houseplan-editor-runtime-C6fRvS15.js",
|
||||
"sha256": "aeef2fb0c3cec33286d9aee59488c69713483da5e93d5a47b28b67e5637354e6",
|
||||
"rawBytes": 524015,
|
||||
"gzipBytes": 135245,
|
||||
"gzipBytes": 135244,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/backdrop-pick-DAhkK64J.js",
|
||||
"houseplan-assets/houseplan-card-DdUxnMX-.js"
|
||||
"houseplan-assets/backdrop-pick-CJ3kZCi2.js",
|
||||
"houseplan-assets/houseplan-card-DzlQl4_k.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-onboarding-runtime-zkpLLWyG.js",
|
||||
"sha256": "4cf023f49c8315f3ca2866086169677326c450ad96aac0a36f299f811df7f5bc",
|
||||
"path": "houseplan-assets/houseplan-onboarding-runtime-HmSdEh0z.js",
|
||||
"sha256": "d7aba6728494fdc25e17a614eba3e91bb8132ee8248d8970fe6030565ec8c36b",
|
||||
"rawBytes": 27120,
|
||||
"gzipBytes": 6721,
|
||||
"gzipBytes": 6718,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/backdrop-pick-DAhkK64J.js",
|
||||
"houseplan-assets/houseplan-card-DdUxnMX-.js"
|
||||
"houseplan-assets/backdrop-pick-CJ3kZCi2.js",
|
||||
"houseplan-assets/houseplan-card-DzlQl4_k.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-card.js",
|
||||
"sha256": "b526f327c3c51c30c72fc2fdef92c48f82a9f6334839ee24d07120895d46386f",
|
||||
"sha256": "2bdf546fd8a2b8fe97577f166d3dd1720dc7777201accb899856e1ea3e96c14d",
|
||||
"rawBytes": 1183,
|
||||
"gzipBytes": 795,
|
||||
"gzipBytes": 796,
|
||||
"isEntry": true,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-DdUxnMX-.js"
|
||||
"houseplan-assets/houseplan-card-DzlQl4_k.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="a75fd51476e6981375898466243f1f67acd5af627334883a490aeed45b89813f";import{b as e,l as o,t,d7 as s,A as a,d8 as i,d9 as l,da as n,E as r,c}from"./houseplan-card-DdUxnMX-.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 n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{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 n();if("warm"===e)return r;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__="099688ecae2a6f015325a6ffa40c7112bd2726309f7760aec7d6b2534ad2f347";import{b as e,l as o,t,d7 as s,A as a,d8 as i,d9 as l,da as n,E as r,c}from"./houseplan-card-DzlQl4_k.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 n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{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 n();if("warm"===e)return r;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
|
||||
.hass=${this.hass}
|
||||
.data=${this._formData}
|
||||
.schema=${e}
|
||||
@@ -1,4 +1,4 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="a75fd51476e6981375898466243f1f67acd5af627334883a490aeed45b89813f";import{l as t,ar as s,A as e,t as o,c as a,bV as l,bW as i,bX as h,bY as c,ao as n,bZ as p,c2 as r,b_ as _,b$ as d,c0 as u,c1 as g,cX as m,an as b,ap as $,cY as v}from"./houseplan-card-DdUxnMX-.js";import{i as f,c as y,e as w,r as D,a as S,b as k,s as C,t as M}from"./backdrop-pick-DAhkK64J.js";const F=1e3,x=t=>{if(!t.trim())return null;const s=Number(t.replace(",","."));return Number.isFinite(s)?s:null},T="a75fd51476e6981375898466243f1f67acd5af627334883a490aeed45b89813f";class I{constructor(t){this.host=t,this._toggleServerPlans=async()=>{const t=this.host._spaceDialog;if(t)if(t.pickSaved)this.host._spaceDialog={...t,pickSaved:!1};else{this.host._spaceDialog={...t,pickSaved:!0,savedBusy:!0};try{const t=await this.host.hass.callWS({type:"houseplan/plans/list"}),s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,saved:t?.plans||[],savedBusy:!1})}catch(t){const s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,saved:[],savedBusy:!1}),this.host._showToast(this.host._t("toast.plans_list_failed",{err:this.host._errText(t)}))}}}}_help(l){const i=`${l}.aria`,h=t(this.host.hass,this.host._config?.language);return s(h,l)&&s(h,i)?a`<hp-help data-help-key=${l}
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="099688ecae2a6f015325a6ffa40c7112bd2726309f7760aec7d6b2534ad2f347";import{l as t,ar as s,A as e,t as o,c as a,bV as l,bW as i,bX as h,bY as c,ao as n,bZ as p,c2 as r,b_ as _,b$ as d,c0 as u,c1 as g,cX as m,an as b,ap as $,cY as v}from"./houseplan-card-DzlQl4_k.js";import{i as f,c as y,e as w,r as D,a as S,b as k,s as C,t as M}from"./backdrop-pick-CJ3kZCi2.js";const F=1e3,x=t=>{if(!t.trim())return null;const s=Number(t.replace(",","."));return Number.isFinite(s)?s:null},T="099688ecae2a6f015325a6ffa40c7112bd2726309f7760aec7d6b2534ad2f347";class I{constructor(t){this.host=t,this._toggleServerPlans=async()=>{const t=this.host._spaceDialog;if(t)if(t.pickSaved)this.host._spaceDialog={...t,pickSaved:!1};else{this.host._spaceDialog={...t,pickSaved:!0,savedBusy:!0};try{const t=await this.host.hass.callWS({type:"houseplan/plans/list"}),s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,saved:t?.plans||[],savedBusy:!1})}catch(t){const s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,saved:[],savedBusy:!1}),this.host._showToast(this.host._t("toast.plans_list_failed",{err:this.host._errText(t)}))}}}}_help(l){const i=`${l}.aria`,h=t(this.host.hass,this.host._config?.language);return s(h,l)&&s(h,i)?a`<hp-help data-help-key=${l}
|
||||
.text=${o(h,l)} .ariaLabel=${o(h,i)}></hp-help>`:e}_openSpaceDialog(t,s){if(!this.host._serverStorage||!this.host._serverCfg)return void this.host._showToast(this.host._t("toast.integration_missing"));if("edit"===t){const e=this.host._serverCfg.spaces.find(t=>t.id===s);if(!e)return;const o=l(e),a=e.settings?.custom_fill&&"object"==typeof e.settings.custom_fill?i(e.settings.custom_fill):null,r="none"===o.fill?{...a||h,a:0}:a;return void(this.host._spaceDialog={mode:t,spaceId:s,title:e.title,planUrl:e.plan_url||null,planFile:null,source:e.plan_url?"file":"draw",showBorders:o.showBorders,showNames:o.showNames,zeroWallStyle:p(e),displayTouched:!0,hideDecor:o.hideDecor,hideOpenings:o.hideOpenings,roomColor:o.color,roomOpacity:o.opacity,fillMode:"none"===o.fill?"custom":o.fill,customFill:r,glowEnabled:o.glow,bgColor:o.bgColor,bgMode:"static"===e.settings?.bg_mode||"daynight"===e.settings?.bg_mode?e.settings.bg_mode:null,northDeg:n({},e.settings),sunRays:"boolean"==typeof e.settings?.sun_rays?e.settings.sun_rays:null,tempMin:o.tempMin,tempMax:o.tempMax,showLqi:o.showLqi??this.host._config?.show_signal??!0,cardFontScale:o.cardFontScale,labelTemp:o.labelTemp,labelHum:o.labelHum,labelLqi:o.labelLqi,labelLight:o.labelLight,cellCm:Number(e.cell_cm)>0?Number(e.cell_cm):5,cellCmInput:c(Number(e.cell_cm)>0?Number(e.cell_cm):5,this.host._imperial),cellCmTouched:!1,busy:!1})}const e=r(this.host._imperial);this.host._spaceDialog={mode:t,title:"",planUrl:null,planFile:null,...f(),hideDecor:!1,hideOpenings:!1,zeroWallStyle:"dashed",roomColor:g,roomOpacity:u,fillMode:"custom",customFill:{...h,a:0},glowEnabled:!0,bgColor:null,bgMode:"daynight",northDeg:null,sunRays:null,tempMin:d,tempMax:_,showLqi:this.host._config?.show_signal??!0,cardFontScale:1,labelTemp:!1,labelHum:!1,labelLqi:!1,labelLight:!1,cellCm:e,cellCmInput:c(e,this.host._imperial),cellCmTouched:!1,busy:!1}}async _pickPlanFile(t){const s=t.target,e=s.files?.[0];if(!e||!this.host._spaceDialog)return;s.value="";const o=await y(e);if("reject"===o.kind)return void this.host._showToast(this.host._t("toast.plan_formats"));if("guard"===o.kind)return void(this.host._backdropGuard=o.state);const a=await w(e,o.ext,e.name);this.host._spaceDialog&&(this.host._spaceDialog={...this.host._spaceDialog,planFile:a})}_renderBackdropGuard(){return D(this.host,t=>{this.host._spaceDialog&&(this.host._spaceDialog={...this.host._spaceDialog,planFile:t})},()=>{this.host._backdropGuard=null},this.host.hass)??e}_useServerPlan(t){const s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,planUrl:t,planFile:null,pickSaved:!1,savedAspect:void 0},this.host._aspectJob=this._readPlanAspect(t))}async _readPlanAspect(t){for(let s=0;s<40;s++){const s=this.host._display(t);if(s){const e=await new Promise(t=>{const e=new Image;e.onload=()=>t(e.naturalWidth&&e.naturalHeight?e.naturalWidth/e.naturalHeight:0),e.onerror=()=>t(0),e.src=s}),o=this.host._spaceDialog;return o&&o.planUrl===t&&Number.isFinite(e)&&e>0?(this.host._spaceDialog={...o,savedAspect:e},e):0}if(await new Promise(t=>setTimeout(t,150)),this.host._spaceDialog?.planUrl!==t)return 0}return 0}async _deleteServerPlan(t){if(confirm(this.host._t("confirm.delete_plan",{name:t})))try{await this.host.hass.callWS({type:"houseplan/plans/delete",name:t});const s=this.host._spaceDialog;s?.saved&&(this.host._spaceDialog={...s,saved:s.saved.filter(s=>s.name!==t)})}catch(t){this.host._showToast(this.host._t("toast.plan_delete_failed",{err:this.host._errText(t)}))}}_renderServerPlans(t){if(t.savedBusy)return a`<div class="savedplans muted">${this.host._t("space.loading")}</div>`;const s=t.saved||[];if(!s.length)return a`<div class="savedplans muted">${this.host._t("space.no_saved")}</div>`;return a`<div class="savedplans">
|
||||
${s.map(s=>{return a`
|
||||
<div class="savedplan ${s.url===t.planUrl?"cur":""}">
|
||||
@@ -1 +1 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="a75fd51476e6981375898466243f1f67acd5af627334883a490aeed45b89813f";try{await import("./houseplan-assets/houseplan-card-DdUxnMX-.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__="099688ecae2a6f015325a6ffa40c7112bd2726309f7760aec7d6b2534ad2f347";try{await import("./houseplan-assets/houseplan-card-DzlQl4_k.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)}
|
||||
|
||||
@@ -86,6 +86,15 @@ const applyDocumentationState = (page, scenario) => page.evaluate(async (current
|
||||
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
||||
const card = window.__goldenCard;
|
||||
if (!card) throw new Error(`documentation card is missing: ${current.id}`);
|
||||
const settleCamera = async () => {
|
||||
const started = performance.now();
|
||||
do { await frame(); }
|
||||
while (card._cameraTransition?.active && performance.now() - started < 1200);
|
||||
if (card._cameraTransition?.active)
|
||||
throw new Error(`documentation camera did not settle: ${current.id}`);
|
||||
// One quiet pair proves that the settled reactive frame reached layout.
|
||||
await frame();
|
||||
};
|
||||
|
||||
if (current.title) {
|
||||
card.setConfig({ ...card._config, title: current.title });
|
||||
@@ -142,6 +151,7 @@ const applyDocumentationState = (page, scenario) => page.evaluate(async (current
|
||||
throw new Error('documentation viewport does not show the device presentation preview');
|
||||
}
|
||||
|
||||
await settleCamera();
|
||||
return { dialog: !!card.renderRoot.querySelector('hp-dialog') };
|
||||
}, scenario);
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"matrixVersion": 53,
|
||||
"acceptedAt": "2026-08-30T11:29:48.257Z",
|
||||
"sourceFingerprint": "c63e55839e4e87dfffefae928d34c030e38064a799953b0ffd7b14052ef53d85",
|
||||
"acceptedAt": "2026-08-30T15:29:10.420Z",
|
||||
"sourceFingerprint": "099688ecae2a6f015325a6ffa40c7112bd2726309f7760aec7d6b2534ad2f347",
|
||||
"chromium": "151.0.7922.34",
|
||||
"witnesses": {
|
||||
"count": 133,
|
||||
"count": 96,
|
||||
"floor": 10
|
||||
},
|
||||
"scenarios": {
|
||||
@@ -66,13 +66,13 @@
|
||||
"opening-symbol-flip-pairs-light": "6d780d1d06909e6e5a7ebb35987b9681d6902d78408e974dfe2fe587521990ce",
|
||||
"isometric-opening-symbol-parity-dark": "1e254ed43da00a16d3048e9a2d29db0a43c7790991b1006ecb7205d002bd1f63",
|
||||
"geometry-devices-editor-dark": "a9a9d22b37e03fde37120889a4d9aa50b9d318a31e416504f74e2905b5d947ff",
|
||||
"device-inbox-desktop-en-light": "a4313ccac566e20dcd4c8b66fe8341b58e62a6f39657cb9fca1cfceabda4ad94",
|
||||
"device-inbox-desktop-ru-dark": "dc251365449a124a4887277945ea43afb822a7e3f1e2355eb191dba14fdd69eb",
|
||||
"device-inbox-narrow-ru-dark": "68771e55e12a0860b4c509d2bf780211e1f5dca42ff62a1627b6a522d063c808",
|
||||
"device-inbox-desktop-en-light": "23fef91974a18f12a71858e67ce981e7b0d34892883cb1c2884cd2695a38bcfc",
|
||||
"device-inbox-desktop-ru-dark": "95a4c0b93a6150b145b967b55344727287843be09bc77b25b23fb0fdaf911142",
|
||||
"device-inbox-narrow-ru-dark": "061031091fd29a3be0af435e84f1e3fe7a4e2a485e7fefc617aa24b5ec1cdb39",
|
||||
"geometry-decor-editor-dark": "4a4d40844f4f92b0e1baf169a25f8a15a507682cea677c631dc57f68e31e373f",
|
||||
"tray-wide-selection-en": "056f3b54d60bba83ef313f9c12fe697ea6a0b3d48db0470735394ee0d3f72c39",
|
||||
"tray-wide-tool-ru": "f8e050c96cb3e78f1ab8d0b8b276b3d154950e795c5b620148b9e395330cffe9",
|
||||
"tray-medium-group-en": "4513abc13f1bc24e4d04f14445069c8ce9785dc3fddcdc99bcee41b5eadb8d53",
|
||||
"tray-medium-group-en": "9b74b675760be423e410176c72b2aa413c19ba7411ddd2d2d0324cf5ba2e1dd6",
|
||||
"tray-medium-selection-ru": "44de98a53d7de665daf2a3f466c281caa66ef6d6cd2941125e2cbfd975f8c8f4",
|
||||
"tray-narrow-palette-en": "392eeac327c899add9792e356d75f48ae541c38bd1c2ace6bdde8e9dcdf17fd6",
|
||||
"furniture-categories-light": "13ab156ce69ff67f82b0aaa1e755d7b2ed63bda2a06f248f56d713c0d88e6fb7",
|
||||
@@ -134,18 +134,18 @@
|
||||
"large-house-zoom-250-dark": "0a74099e265b38f376bb7bfd86d4a57c5d493ac3870478a7d55c48cf99e8f892",
|
||||
"large-house-warm-remount-dark": "1c14dba8cdbc4628984091d395c1ac14faead1d43338e44aa3cb57976e519df6",
|
||||
"device-dialog-desktop-en": "e142521eae1bd40861c1d92eadbcfd6d88aefa689cd5ee9b4c1b6378c8ce712c",
|
||||
"device-dialog-mobile-ru": "7921b7d5e2c082fc4dd5053607a8228c2e63e0e72a401d86153effd54c2c6ca0",
|
||||
"device-dialog-desktop-de": "ac3f657e072006f5c1686d89da61df922991a04f69d2d7994a03d1ba93532681",
|
||||
"device-dialog-mobile-ru": "bde342feb5667a76884c081a4d9dd9bbacbbf7158f48f1c68322408bdde3d053",
|
||||
"device-dialog-desktop-de": "ef620a0b5d14f24d0d5021bcd720fe1531c84832442cf0f7964397450ed8ebc3",
|
||||
"german-view-mobile-light": "9496071b11f510f1c851a9c60583a96bfe768c1fd1e12a2b1b9183dee7e453a8",
|
||||
"toggle-entity-dialog-desktop-en": "e192bab83ae99bfb8d33046921f0a3009ebaa8074bd30971ab41a1eeac69ea0d",
|
||||
"toggle-entity-dialog-mobile-ru": "15ac99162f51b5112896fd879bc5645d94593dd839d400e8c7792ebc4340829d",
|
||||
"toggle-entity-dialog-mobile-ru": "5143ad6846bc605dc52a97abe1b6593596fb25bb80a121f3c6279f1b7f421fee",
|
||||
"device-help-popover-light-ru": "259a50065b7f09d07db656b6cfe79d1de34397665ff8dd8a8e361f6f29a7ea6a",
|
||||
"decor-color-popover-mobile-ru": "606c41d53f9a996b24990032505180159ce032e2b4f14dfb214c79a8c4b85702",
|
||||
"decor-color-popover-desktop-en": "f626f246c8e29d1b1e5e067e0923438eb1222606388a33c3071e86307b4ca130",
|
||||
"general-color-popover-desktop-en": "008865918113e05847a7ad8ae0fdc068b5326acf1c591d855806b333084978a3",
|
||||
"settings-help-zoom-200-en-light": "bb1192a52bab5ebc6984a5d799a32d05f9ce374ce6b493f5f618b639eedce8d5",
|
||||
"settings-help-zoom-200-ru-dark": "13e7fdd19017c38a5659394e622ff54ac8a426f9cb6e85b3248864767aa9f42b",
|
||||
"device-ripple-color-popover-mobile-ru": "4f2365818f46fe1e2b401e5835f5d29ea9c41b288d894025d7512db92f579762",
|
||||
"device-ripple-color-popover-mobile-ru": "01c538f8b504ab9f5e8d64cb4cbbf3131c5de9b0909c996e2d61f4523801127c",
|
||||
"space-room-color-popover-desktop-ru": "ff0e633c7fbedfa04883792682d8e407aa0633bbd46ed12e0ab8bc2207be9ccb",
|
||||
"backup-full-preview-desktop-en": "6b68188269fdbaddaf4f2ce8fc0d10ea41e050f0c08cdbd9507548871a3786ef",
|
||||
"backup-plan-only-export-desktop-en": "805fb7cd15959a7eee0b17150c4417e335f1b1030a438f7051c2b006a5f7d4f4",
|
||||
|
||||
|
Before Width: | Height: | Size: 372 KiB After Width: | Height: | Size: 373 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 275 KiB After Width: | Height: | Size: 282 KiB |
|
Before Width: | Height: | Size: 264 KiB After Width: | Height: | Size: 270 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 199 KiB After Width: | Height: | Size: 200 KiB |
@@ -742,6 +742,11 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
};
|
||||
const settleCamera = async (card) => {
|
||||
await until(() => !card._cameraTransition?.active, 1500);
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
};
|
||||
window.__goldenCard?.remove?.();
|
||||
window.__goldenEditor?.remove?.();
|
||||
window.__card?.remove?.();
|
||||
@@ -1711,7 +1716,9 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
}
|
||||
}
|
||||
await document.fonts?.ready;
|
||||
await frame();
|
||||
// Camera motion is intentionally visible in production, but reviewed
|
||||
// goldens own the settled UI. Never capture a timing-dependent RAF frame.
|
||||
await settleCamera(card);
|
||||
return {
|
||||
space: card._space,
|
||||
mode: card._mode,
|
||||
|
||||
@@ -44,6 +44,12 @@ const load = (cfg, layout, space) => page.evaluate(async ([c2, l2, sp]) => {
|
||||
await c.updateComplete;
|
||||
c._resetZoom();
|
||||
await c.updateComplete;
|
||||
const started = performance.now();
|
||||
while (c._cameraTransition?.active) {
|
||||
if (performance.now() - started > 1000) throw new Error('camera transition did not settle');
|
||||
await new Promise((r) => requestAnimationFrame(r));
|
||||
}
|
||||
await c.updateComplete;
|
||||
}, [cfg, layout, space]);
|
||||
|
||||
// ---------------------------------------------------------------- 01: hidden
|
||||
|
||||
@@ -12,6 +12,18 @@ const SHOTS = process.env.HP_SHOTS || '';
|
||||
const { page, browser } = await launch({ width: 900, height: 820 }, 1);
|
||||
const out = {};
|
||||
const raf = () => page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));
|
||||
await page.evaluate(() => {
|
||||
window.__waitForCamera = async (card = window.__card) => {
|
||||
const started = performance.now();
|
||||
while (card?._cameraTransition?.active) {
|
||||
if (performance.now() - started > 1000) throw new Error('camera transition did not settle');
|
||||
await new Promise((r) => requestAnimationFrame(r));
|
||||
}
|
||||
await card?.updateComplete;
|
||||
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
|
||||
await card?.updateComplete;
|
||||
};
|
||||
});
|
||||
|
||||
// A hand-drawn plan whose rooms live at 1.5..3.0 — entirely outside the old
|
||||
// canvas. Before the infinite canvas the -25%..125% envelope threw every one
|
||||
@@ -61,7 +73,7 @@ const loadFar = (cfg, layout) => page.evaluate(async ([c2, l2]) => {
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
c._resetZoom();
|
||||
await c.updateComplete;
|
||||
await window.__waitForCamera(c);
|
||||
}, [cfg, layout]);
|
||||
|
||||
await loadFar(FAR_CFG, FAR_LAYOUT);
|
||||
@@ -181,7 +193,7 @@ Object.assign(out, await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const sr = c.shadowRoot || c.renderRoot;
|
||||
sr.querySelector('.farhint button').click();
|
||||
await c.updateComplete;
|
||||
await window.__waitForCamera(c);
|
||||
const o = {};
|
||||
const v = c._viewOr(c._baseVb());
|
||||
o.fitAllReachesTheStray = v.x + v.w > 90000 && v.y + v.h > 90000;
|
||||
@@ -194,10 +206,11 @@ Object.assign(out, await page.evaluate(async () => {
|
||||
// ---- zoom-out stops at 3x, zoom-in unchanged -----------------------------
|
||||
await loadFar(FAR_CFG, FAR_LAYOUT);
|
||||
await raf();
|
||||
Object.assign(out, await page.evaluate(() => {
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const o = {};
|
||||
c._resetZoom();
|
||||
await window.__waitForCamera(c);
|
||||
const fit = { ...c._viewOr(c._baseVb()) };
|
||||
c._applyView(0.001);
|
||||
o.zoomOutStopsAtThree = Math.abs(c._zoom - 1 / 3) < 1e-9
|
||||
@@ -205,6 +218,7 @@ Object.assign(out, await page.evaluate(() => {
|
||||
c._applyView(50);
|
||||
o.zoomInUnchanged = c._zoom === 8;
|
||||
c._resetZoom();
|
||||
await window.__waitForCamera(c);
|
||||
return o;
|
||||
}));
|
||||
|
||||
@@ -213,7 +227,7 @@ Object.assign(out, await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const sr = c.shadowRoot || c.renderRoot;
|
||||
const o = {};
|
||||
c._resetZoom(); await c.updateComplete;
|
||||
c._resetZoom(); await window.__waitForCamera(c);
|
||||
const fit = { ...c._view };
|
||||
// pan most of a screen to the right: nothing pins the content over the scene
|
||||
c._view = c._clampView({ ...fit, x: fit.x + fit.w * 0.9 }, fit);
|
||||
@@ -225,13 +239,13 @@ Object.assign(out, await page.evaluate(async () => {
|
||||
o.homeArrowAppears = !!arrow;
|
||||
if (arrow) {
|
||||
arrow.click();
|
||||
await c.updateComplete;
|
||||
await window.__waitForCamera(c);
|
||||
o.homeArrowFitsBack = Math.abs(c._view.x - fit.x) < 2 && Math.abs(c._view.w - fit.w) < 2;
|
||||
await c.updateComplete;
|
||||
o.homeArrowGone = !sr.querySelector('.homearrow');
|
||||
}
|
||||
// and it is NOT there while the plan is on screen
|
||||
c._resetZoom(); c.requestUpdate(); await c.updateComplete;
|
||||
c._resetZoom(); await window.__waitForCamera(c);
|
||||
o.noHomeArrowWhenVisible = !sr.querySelector('.homearrow');
|
||||
return o;
|
||||
}));
|
||||
@@ -255,13 +269,13 @@ const farIcon = await page.evaluate(async () => {
|
||||
return Math.round(el.getBoundingClientRect().width * 100) / 100;
|
||||
};
|
||||
const o = {};
|
||||
c._resetZoom(); await c.updateComplete;
|
||||
c._resetZoom(); await window.__waitForCamera(c);
|
||||
const at1 = size();
|
||||
c._applyView(4); await c.updateComplete;
|
||||
const at4 = size();
|
||||
c._applyView(1 / 3); await c.updateComplete;
|
||||
const atOut = size();
|
||||
c._resetZoom(); await c.updateComplete;
|
||||
c._resetZoom(); await window.__waitForCamera(c);
|
||||
o.iconGrowsWithZoomIn = at1 > 4 && Math.abs(at4 / at1 - 4) < 0.05;
|
||||
o.iconShrinksWithZoomOut = Math.abs(atOut / at1 - 1 / 3) < 0.02;
|
||||
if (!o.iconGrowsWithZoomIn || !o.iconShrinksWithZoomOut) console.log('icon px', at1, at4, atOut);
|
||||
@@ -286,7 +300,7 @@ Object.assign(out, await page.evaluate(async () => {
|
||||
c._maybeRebuildDevices?.();
|
||||
c._defPos = c._defaultPositions(); // the auto grid follows the new rooms
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
c._resetZoom(); await c.updateComplete;
|
||||
c._resetZoom(); await window.__waitForCamera(c);
|
||||
const b = c._baseVb();
|
||||
// identical to what contentBounds produced before: the content bbox
|
||||
// (40..960 x 140..580) plus 5% of the longer side (920 -> 46)
|
||||
@@ -327,7 +341,7 @@ out.farIconNotDegenerate = await page.evaluate(async (farPx) => {
|
||||
c._maybeRebuildDevices?.();
|
||||
c._defPos = c._defaultPositions();
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
c._resetZoom(); await c.updateComplete;
|
||||
c._resetZoom(); await window.__waitForCamera(c);
|
||||
const el = sr.querySelector('.devlayer .dev');
|
||||
const px = el.getBoundingClientRect().width - 2;
|
||||
if (!(px > 4 && Math.abs(px / farPx - 1) < 0.3)) console.log('icon px near/far', px, farPx);
|
||||
@@ -347,7 +361,7 @@ if (SHOTS) {
|
||||
await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
(c.shadowRoot || c.renderRoot).querySelector('.farhint button').click();
|
||||
await c.updateComplete;
|
||||
await window.__waitForCamera(c);
|
||||
});
|
||||
await raf();
|
||||
await page.screenshot({ path: `${SHOTS}/canvas_fit_all.png` });
|
||||
@@ -356,13 +370,15 @@ if (SHOTS) {
|
||||
await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
c._setMode('plan'); await c.updateComplete;
|
||||
c._resetZoom(); c._applyView(1 / 3); await c.updateComplete;
|
||||
c._resetZoom(); await window.__waitForCamera(c);
|
||||
c._applyView(1 / 3); await c.updateComplete;
|
||||
});
|
||||
await raf();
|
||||
await page.screenshot({ path: `${SHOTS}/canvas_grid_far.png` });
|
||||
await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
c._resetZoom(); c._applyView(4); await c.updateComplete;
|
||||
c._resetZoom(); await window.__waitForCamera(c);
|
||||
c._applyView(4); await c.updateComplete;
|
||||
});
|
||||
await raf();
|
||||
await page.screenshot({ path: `${SHOTS}/canvas_grid_near.png` });
|
||||
|
||||
@@ -37,6 +37,11 @@ const res = await page.evaluate(async () => {
|
||||
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 }));
|
||||
const resetStarted = performance.now();
|
||||
while (c._cameraTransition?.active) {
|
||||
if (performance.now() - resetStarted > 1000) throw new Error('double-tap camera transition did not settle');
|
||||
await new Promise((r) => requestAnimationFrame(r));
|
||||
}
|
||||
await c.updateComplete;
|
||||
out.dblTapResets = c._zoom === 1;
|
||||
// 6) локальный множитель иконок применяется
|
||||
|
||||
@@ -111,6 +111,9 @@ const out = await page.evaluate(async () => {
|
||||
k._lastTap = Date.now() - 100;
|
||||
fire('pointerdown', 58, 500, 300);
|
||||
fire('pointerup', 58, 501, 300);
|
||||
const resetStarted = performance.now();
|
||||
do { await new Promise((resolve) => requestAnimationFrame(resolve)); }
|
||||
while (k._cameraTransition.active && performance.now() - resetStarted < 1000);
|
||||
await k.updateComplete;
|
||||
o.doubleTapStillResetsZoom = k._zoom === 1;
|
||||
o.doubleTapLeavesNoLock = k._panLock === null;
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
// #82: every discrete camera command uses one short, retargetable transition;
|
||||
// direct pointer gestures and structural/lifecycle changes remain atomic.
|
||||
import { launchColdView, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launchColdView({ width: 900, height: 720 });
|
||||
|
||||
const out = await page.evaluate(async () => {
|
||||
const card = window.__card;
|
||||
const root = () => card.shadowRoot || card.renderRoot;
|
||||
const stage = () => root().querySelector('.stage');
|
||||
const frame = () => new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const camera = () => ({ zoom: card._zoom, view: { ...card._view } });
|
||||
const close = (a, b, epsilon = 1e-4) => Math.abs(a - b) <= epsilon;
|
||||
const sameView = (a, b, epsilon = 1e-4) => close(a.x, b.x, epsilon)
|
||||
&& close(a.y, b.y, epsilon) && close(a.w, b.w, epsilon)
|
||||
&& close(a.h, b.h, epsilon);
|
||||
const settle = async () => {
|
||||
const samples = [];
|
||||
const started = performance.now();
|
||||
do {
|
||||
await frame();
|
||||
samples.push(camera());
|
||||
} while (card._cameraTransition.active && performance.now() - started < 1200);
|
||||
await card.updateComplete;
|
||||
return samples;
|
||||
};
|
||||
const directBaseline = (zoom = 1) => {
|
||||
const vb = card._baseVb();
|
||||
card._applyView(zoom, vb[0] + vb[2] / 2, vb[1] + vb[3] / 2);
|
||||
};
|
||||
|
||||
card._setMode('view');
|
||||
await card.updateComplete;
|
||||
directBaseline(1);
|
||||
const originalSave = card._saveZoom.bind(card);
|
||||
let saveCount = 0;
|
||||
card._saveZoom = () => { saveCount++; originalSave(); };
|
||||
|
||||
// Toolbar button: intermediate pixels exist and exact legacy target wins.
|
||||
const buttonExpected = card._cameraTargetAt(
|
||||
stage().clientWidth / 2, stage().clientHeight / 2, 1.4,
|
||||
).target;
|
||||
card._stepZoom(1);
|
||||
const buttonSamples = await settle();
|
||||
const buttonFinal = camera();
|
||||
const buttonSaves = saveCount;
|
||||
|
||||
// Wheel anchor is calculated from the actually painted frame. A reversal
|
||||
// replaces the target and RAF rather than queueing a second transition.
|
||||
directBaseline(1);
|
||||
saveCount = 0;
|
||||
const rect = stage().getBoundingClientRect();
|
||||
const anchor = { x: rect.width * 0.31, y: rect.height * 0.67 };
|
||||
stage().dispatchEvent(new WheelEvent('wheel', {
|
||||
bubbles: true, cancelable: true, deltaY: -100,
|
||||
clientX: rect.left + anchor.x, clientY: rect.top + anchor.y,
|
||||
}));
|
||||
const firstToken = card._cameraTransition.state?.token;
|
||||
await frame();
|
||||
const presentedCameraBeforeReverse = camera();
|
||||
const presentedBeforeReverse = presentedCameraBeforeReverse.zoom;
|
||||
const worldAtReverse = {
|
||||
x: presentedCameraBeforeReverse.view.x
|
||||
+ anchor.x / rect.width * presentedCameraBeforeReverse.view.w,
|
||||
y: presentedCameraBeforeReverse.view.y
|
||||
+ anchor.y / rect.height * presentedCameraBeforeReverse.view.h,
|
||||
};
|
||||
stage().dispatchEvent(new WheelEvent('wheel', {
|
||||
bubbles: true, cancelable: true, deltaY: 100,
|
||||
clientX: rect.left + anchor.x, clientY: rect.top + anchor.y,
|
||||
}));
|
||||
const secondToken = card._cameraTransition.state?.token;
|
||||
const reverseFrom = card._cameraTransition.state?.from.zoom;
|
||||
const wheelSamples = await settle();
|
||||
const worldAfter = {
|
||||
x: card._view.x + anchor.x / rect.width * card._view.w,
|
||||
y: card._view.y + anchor.y / rect.height * card._view.h,
|
||||
};
|
||||
const wheelSaves = saveCount;
|
||||
|
||||
// A real pointerdown freezes the presented camera: the obsolete target may
|
||||
// not settle later under a drag/select gesture.
|
||||
directBaseline(1);
|
||||
card._stepZoom(1);
|
||||
await frame();
|
||||
const pointerPresented = camera();
|
||||
stage().dispatchEvent(new PointerEvent('pointerdown', {
|
||||
bubbles: true, pointerId: 82, pointerType: 'mouse', buttons: 1,
|
||||
clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2,
|
||||
}));
|
||||
await wait(260);
|
||||
const pointerFrozen = camera();
|
||||
stage().dispatchEvent(new PointerEvent('pointerup', {
|
||||
bubbles: true, pointerId: 82, pointerType: 'mouse', buttons: 0,
|
||||
clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2,
|
||||
}));
|
||||
|
||||
// Pinch's production primitive is direct and cancels a running tween.
|
||||
directBaseline(1);
|
||||
card._stepZoom(1);
|
||||
await frame();
|
||||
card._zoomAt(rect.width / 2, rect.height / 2, 2);
|
||||
const pinchDirect = camera();
|
||||
await wait(240);
|
||||
const pinchAfterDeadline = camera();
|
||||
|
||||
// Fit/home/double-tap share the same exact fit but retain their observable
|
||||
// reason, so their entry points cannot silently regress to a snap.
|
||||
const reasons = {};
|
||||
for (const reason of ['fit', 'home', 'double-tap']) {
|
||||
directBaseline(2);
|
||||
if (reason === 'home') card._fitAll('home');
|
||||
else if (reason === 'fit') card._fitAll('fit');
|
||||
else card._resetZoom('double-tap');
|
||||
reasons[reason] = {
|
||||
liveReason: card._cameraTransition.state?.reason,
|
||||
samples: (await settle()).map((sample) => sample.zoom),
|
||||
final: card._zoom,
|
||||
};
|
||||
}
|
||||
card._showFar = false;
|
||||
card._frame = null;
|
||||
directBaseline(2);
|
||||
card._fitFar();
|
||||
const farFitReason = card._cameraTransition.state?.reason;
|
||||
const farFitSamples = await settle();
|
||||
const farFitFinal = card._zoom;
|
||||
|
||||
// Reduced motion before and during a command is authoritative immediately.
|
||||
directBaseline(1);
|
||||
card._reducedMotion = true;
|
||||
card._stepZoom(1);
|
||||
const reducedBefore = { active: card._cameraTransition.active, zoom: card._zoom };
|
||||
card._reducedMotion = false;
|
||||
directBaseline(1);
|
||||
card._stepZoom(1);
|
||||
await frame();
|
||||
card._onMotionChange({ matches: true });
|
||||
const reducedDuring = { active: card._cameraTransition.active, zoom: card._zoom };
|
||||
card._reducedMotion = false;
|
||||
|
||||
// Projection owns an atomic structural conversion and cancels the camera.
|
||||
card._labs = { ...card._labs, active: [...new Set([...card._labs.active, 'iso'])] };
|
||||
card._viewPreference = { ...card._viewPreference, [card._space]: 'flat' };
|
||||
directBaseline(1);
|
||||
card._stepZoom(1);
|
||||
await frame();
|
||||
card._setProjection('iso');
|
||||
const projectionCancelled = !card._cameraTransition.active;
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
await frame();
|
||||
const isoExpected = card._cameraTargetAt(
|
||||
stage().clientWidth / 2, stage().clientHeight / 2, card._zoom * 1.4,
|
||||
).target;
|
||||
card._stepZoom(1);
|
||||
await settle();
|
||||
const isoFinal = camera();
|
||||
card._setProjection('flat');
|
||||
|
||||
// Hidden commits the user's target and no old RAF resumes later.
|
||||
directBaseline(1);
|
||||
card._stepZoom(1);
|
||||
card._pageVisibility({ kind: 'hidden' });
|
||||
const hiddenCommitted = { active: card._cameraTransition.active, zoom: card._zoom };
|
||||
await wait(240);
|
||||
const hiddenAfterDeadline = card._zoom;
|
||||
|
||||
// Exact fit is a no-op: neither a RAF nor persistence write is created.
|
||||
card._reducedMotion = true;
|
||||
card._resetZoom('fit');
|
||||
card._reducedMotion = false;
|
||||
saveCount = 0;
|
||||
card._resetZoom('fit');
|
||||
const fitNoop = { active: card._cameraTransition.active, saves: saveCount };
|
||||
card._applyView(8);
|
||||
saveCount = 0;
|
||||
card._stepZoom(1);
|
||||
const maxNoop = { active: card._cameraTransition.active, saves: saveCount };
|
||||
card._applyView(1 / 3);
|
||||
saveCount = 0;
|
||||
card._stepZoom(-1);
|
||||
const minNoop = { active: card._cameraTransition.active, saves: saveCount };
|
||||
|
||||
const glowBefore = {
|
||||
spots: root().querySelectorAll('[data-glow-spot]').length,
|
||||
blend: root().querySelector('.glow-pools')?.getAttribute('data-blend') || '',
|
||||
rooms: root().querySelectorAll('.room').length,
|
||||
base: [...card._baseVb()],
|
||||
};
|
||||
card._stepZoom(1);
|
||||
await settle();
|
||||
const glowAfter = {
|
||||
spots: root().querySelectorAll('[data-glow-spot]').length,
|
||||
blend: root().querySelector('.glow-pools')?.getAttribute('data-blend') || '',
|
||||
rooms: root().querySelectorAll('.room').length,
|
||||
base: [...card._baseVb()],
|
||||
};
|
||||
|
||||
const resources = performance.getEntriesByType('resource')
|
||||
.map((entry) => new URL(entry.name).pathname);
|
||||
return {
|
||||
buttonHasIntermediateFrame: buttonSamples.some((sample) =>
|
||||
sample.zoom > 1.001 && sample.zoom < buttonExpected.zoom - 0.001),
|
||||
buttonSettlesAtExactTarget: close(buttonFinal.zoom, buttonExpected.zoom)
|
||||
&& sameView(buttonFinal.view, buttonExpected.viewBox),
|
||||
buttonPersistsOnce: buttonSaves === 1,
|
||||
wheelRetargetsRunningTween: Number.isFinite(firstToken) && Number.isFinite(secondToken)
|
||||
&& firstToken !== secondToken && close(reverseFrom, presentedBeforeReverse, 0.02),
|
||||
wheelHasIntermediateFrames: wheelSamples.some((sample) => sample.zoom > 1.001),
|
||||
wheelReversalKeepsAnchor: Math.hypot(
|
||||
(worldAfter.x - worldAtReverse.x) / card._view.w * rect.width,
|
||||
(worldAfter.y - worldAtReverse.y) / card._view.h * rect.height,
|
||||
) <= 0.5,
|
||||
wheelStreamPersistsOnce: wheelSaves === 1,
|
||||
pointerdownFreezesPresentedFrame: !card._cameraTransition.active
|
||||
&& close(pointerFrozen.zoom, pointerPresented.zoom, 0.001)
|
||||
&& sameView(pointerFrozen.view, pointerPresented.view, 0.001),
|
||||
pinchIsDirectWithoutPostAnimation: close(pinchDirect.zoom, 2)
|
||||
&& !card._cameraTransition.active
|
||||
&& close(pinchAfterDeadline.zoom, 2)
|
||||
&& sameView(pinchAfterDeadline.view, pinchDirect.view),
|
||||
fitHomeAndDoubleTapAnimate: Object.entries(reasons).every(([reason, value]) =>
|
||||
value.liveReason === reason
|
||||
&& value.samples.some((zoom) => zoom > 1.001 && zoom < 1.999)
|
||||
&& close(value.final, 1)),
|
||||
farFitUsesTheSameTransition: farFitReason === 'fit'
|
||||
&& farFitSamples.some((sample) => sample.zoom > 1.001 && sample.zoom < 1.999)
|
||||
&& close(farFitFinal, 1),
|
||||
reducedMotionIsImmediate: !reducedBefore.active && close(reducedBefore.zoom, 1.4)
|
||||
&& !reducedDuring.active && close(reducedDuring.zoom, 1.4),
|
||||
projectionCancelsAndIsoSettlesExactly: projectionCancelled
|
||||
&& close(isoFinal.zoom, isoExpected.zoom)
|
||||
&& sameView(isoFinal.view, isoExpected.viewBox),
|
||||
hiddenCommitsTargetOnce: !hiddenCommitted.active && close(hiddenCommitted.zoom, 1.4)
|
||||
&& close(hiddenAfterDeadline, hiddenCommitted.zoom),
|
||||
exactLimitsAndFitAreNoops: !fitNoop.active && fitNoop.saves === 0
|
||||
&& !maxNoop.active && maxNoop.saves === 0
|
||||
&& !minNoop.active && minNoop.saves === 0,
|
||||
glowAndStructuralFrameStayStable: JSON.stringify(glowAfter) === JSON.stringify(glowBefore),
|
||||
coldViewDoesNotLoadEditorRuntime: resources.every((path) =>
|
||||
!/houseplan-editor-runtime-/.test(path)),
|
||||
diagnostics: { projectionCancelled, isoExpected, isoFinal, glowBefore, glowAfter },
|
||||
};
|
||||
});
|
||||
|
||||
const { diagnostics, ...checks } = out;
|
||||
checkAll(checks);
|
||||
await finish(browser, out);
|
||||
@@ -1,125 +1,125 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"fingerprint": "a75fd51476e6981375898466243f1f67acd5af627334883a490aeed45b89813f",
|
||||
"fingerprint": "099688ecae2a6f015325a6ffa40c7112bd2726309f7760aec7d6b2534ad2f347",
|
||||
"entry": "houseplan-card.js",
|
||||
"initialViewFiles": [
|
||||
"houseplan-assets/houseplan-card-DdUxnMX-.js",
|
||||
"houseplan-assets/houseplan-card-DzlQl4_k.js",
|
||||
"houseplan-card.js"
|
||||
],
|
||||
"initialViewGzipBytes": 281785,
|
||||
"initialViewGzipBytes": 283094,
|
||||
"lazyFiles": [
|
||||
"houseplan-assets/backdrop-pick-DAhkK64J.js",
|
||||
"houseplan-assets/de-CeeGlSdH.js",
|
||||
"houseplan-assets/editor-CWRRIfyY.js",
|
||||
"houseplan-assets/fr-BIGiyq-a.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-DmHZCYVI.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-zkpLLWyG.js"
|
||||
"houseplan-assets/backdrop-pick-CJ3kZCi2.js",
|
||||
"houseplan-assets/de-Dq2fGpol.js",
|
||||
"houseplan-assets/editor-DrQ9S89m.js",
|
||||
"houseplan-assets/fr-Bq4OMxB4.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-C6fRvS15.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-HmSdEh0z.js"
|
||||
],
|
||||
"lazyGzipBytes": 197610,
|
||||
"lazyGzipBytes": 197612,
|
||||
"lazyEditorFiles": [
|
||||
"houseplan-assets/backdrop-pick-DAhkK64J.js",
|
||||
"houseplan-assets/editor-CWRRIfyY.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-DmHZCYVI.js"
|
||||
"houseplan-assets/backdrop-pick-CJ3kZCi2.js",
|
||||
"houseplan-assets/editor-DrQ9S89m.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-C6fRvS15.js"
|
||||
],
|
||||
"lazyEditorGzipBytes": 143896,
|
||||
"lazyEditorGzipBytes": 143897,
|
||||
"lazyOnboardingFiles": [
|
||||
"houseplan-assets/backdrop-pick-DAhkK64J.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-zkpLLWyG.js"
|
||||
"houseplan-assets/backdrop-pick-CJ3kZCi2.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-HmSdEh0z.js"
|
||||
],
|
||||
"lazyOnboardingGzipBytes": 13791,
|
||||
"lazyOnboardingGzipBytes": 13789,
|
||||
"lazyLocaleFiles": [
|
||||
"houseplan-assets/de-CeeGlSdH.js",
|
||||
"houseplan-assets/fr-BIGiyq-a.js"
|
||||
"houseplan-assets/de-Dq2fGpol.js",
|
||||
"houseplan-assets/fr-Bq4OMxB4.js"
|
||||
],
|
||||
"lazyLocaleGzipBytes": 46993,
|
||||
"lazyLocaleGzipBytes": 46997,
|
||||
"files": [
|
||||
{
|
||||
"path": "houseplan-assets/backdrop-pick-DAhkK64J.js",
|
||||
"sha256": "d0c0fc8d1a54632e83bf0832717f9cad9aef21e03740f39a68e254b9fa2de006",
|
||||
"path": "houseplan-assets/backdrop-pick-CJ3kZCi2.js",
|
||||
"sha256": "383eea583e6ee787be10c7766420bbff1738f466827bcada4dbb2fbf1de24505",
|
||||
"rawBytes": 20636,
|
||||
"gzipBytes": 7070,
|
||||
"gzipBytes": 7071,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-DdUxnMX-.js"
|
||||
"houseplan-assets/houseplan-card-DzlQl4_k.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/de-CeeGlSdH.js",
|
||||
"sha256": "833468501744eb020baadd4664c352cde634c2324280ad32ed4c3c4353d44857",
|
||||
"path": "houseplan-assets/de-Dq2fGpol.js",
|
||||
"sha256": "17cca86ed13e257b06567609834707e975e9dacb38e495208a5bceedf4e7da8f",
|
||||
"rawBytes": 78310,
|
||||
"gzipBytes": 23718,
|
||||
"gzipBytes": 23721,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/editor-CWRRIfyY.js",
|
||||
"sha256": "0abaa66a50efc8648f0eddc9e91b9cbd52213f70741e5193fbee331b28e2b3a7",
|
||||
"path": "houseplan-assets/editor-DrQ9S89m.js",
|
||||
"sha256": "0b137accab6d6ed51f44aed3d4d2b1ff444b5307ac9c11ff0cb9535cfd25bb2c",
|
||||
"rawBytes": 3826,
|
||||
"gzipBytes": 1581,
|
||||
"gzipBytes": 1582,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-DdUxnMX-.js"
|
||||
"houseplan-assets/houseplan-card-DzlQl4_k.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/fr-BIGiyq-a.js",
|
||||
"sha256": "a553eff93f33804b66b2e1be445960d38c4222f03627e78b0514299989ba9a51",
|
||||
"path": "houseplan-assets/fr-Bq4OMxB4.js",
|
||||
"sha256": "da0d137d42243e36315ed6ad73197210ea12cf672cf14eb7cb6320594c9aec56",
|
||||
"rawBytes": 80482,
|
||||
"gzipBytes": 23275,
|
||||
"gzipBytes": 23276,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-card-DdUxnMX-.js",
|
||||
"sha256": "eea3b14c28ac1f495e685d7b708ac3ee9c585efdc2cefc1454d962454cbfa91b",
|
||||
"rawBytes": 996305,
|
||||
"gzipBytes": 280990,
|
||||
"path": "houseplan-assets/houseplan-card-DzlQl4_k.js",
|
||||
"sha256": "ac14483abe54719e12506df7eef1f26f4fd570c92600ea781159e445249ecca9",
|
||||
"rawBytes": 1001346,
|
||||
"gzipBytes": 282298,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": [
|
||||
"houseplan-assets/de-CeeGlSdH.js",
|
||||
"houseplan-assets/editor-CWRRIfyY.js",
|
||||
"houseplan-assets/fr-BIGiyq-a.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-DmHZCYVI.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-zkpLLWyG.js"
|
||||
"houseplan-assets/de-Dq2fGpol.js",
|
||||
"houseplan-assets/editor-DrQ9S89m.js",
|
||||
"houseplan-assets/fr-Bq4OMxB4.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-C6fRvS15.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-HmSdEh0z.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-editor-runtime-DmHZCYVI.js",
|
||||
"sha256": "cc614c3f22050e61668b34887dd6bfe2be7f84619b4b57202674f45c5790b42f",
|
||||
"path": "houseplan-assets/houseplan-editor-runtime-C6fRvS15.js",
|
||||
"sha256": "aeef2fb0c3cec33286d9aee59488c69713483da5e93d5a47b28b67e5637354e6",
|
||||
"rawBytes": 524015,
|
||||
"gzipBytes": 135245,
|
||||
"gzipBytes": 135244,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/backdrop-pick-DAhkK64J.js",
|
||||
"houseplan-assets/houseplan-card-DdUxnMX-.js"
|
||||
"houseplan-assets/backdrop-pick-CJ3kZCi2.js",
|
||||
"houseplan-assets/houseplan-card-DzlQl4_k.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-onboarding-runtime-zkpLLWyG.js",
|
||||
"sha256": "4cf023f49c8315f3ca2866086169677326c450ad96aac0a36f299f811df7f5bc",
|
||||
"path": "houseplan-assets/houseplan-onboarding-runtime-HmSdEh0z.js",
|
||||
"sha256": "d7aba6728494fdc25e17a614eba3e91bb8132ee8248d8970fe6030565ec8c36b",
|
||||
"rawBytes": 27120,
|
||||
"gzipBytes": 6721,
|
||||
"gzipBytes": 6718,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/backdrop-pick-DAhkK64J.js",
|
||||
"houseplan-assets/houseplan-card-DdUxnMX-.js"
|
||||
"houseplan-assets/backdrop-pick-CJ3kZCi2.js",
|
||||
"houseplan-assets/houseplan-card-DzlQl4_k.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-card.js",
|
||||
"sha256": "b526f327c3c51c30c72fc2fdef92c48f82a9f6334839ee24d07120895d46386f",
|
||||
"sha256": "2bdf546fd8a2b8fe97577f166d3dd1720dc7777201accb899856e1ea3e96c14d",
|
||||
"rawBytes": 1183,
|
||||
"gzipBytes": 795,
|
||||
"gzipBytes": 796,
|
||||
"isEntry": true,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-DdUxnMX-.js"
|
||||
"houseplan-assets/houseplan-card-DzlQl4_k.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="a75fd51476e6981375898466243f1f67acd5af627334883a490aeed45b89813f";import{b as e,l as o,t,d7 as s,A as a,d8 as i,d9 as l,da as n,E as r,c}from"./houseplan-card-DdUxnMX-.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 n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{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 n();if("warm"===e)return r;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__="099688ecae2a6f015325a6ffa40c7112bd2726309f7760aec7d6b2534ad2f347";import{b as e,l as o,t,d7 as s,A as a,d8 as i,d9 as l,da as n,E as r,c}from"./houseplan-card-DzlQl4_k.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 n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{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 n();if("warm"===e)return r;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
|
||||
.hass=${this.hass}
|
||||
.data=${this._formData}
|
||||
.schema=${e}
|
||||
@@ -1,4 +1,4 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="a75fd51476e6981375898466243f1f67acd5af627334883a490aeed45b89813f";import{l as t,ar as s,A as e,t as o,c as a,bV as l,bW as i,bX as h,bY as c,ao as n,bZ as p,c2 as r,b_ as _,b$ as d,c0 as u,c1 as g,cX as m,an as b,ap as $,cY as v}from"./houseplan-card-DdUxnMX-.js";import{i as f,c as y,e as w,r as D,a as S,b as k,s as C,t as M}from"./backdrop-pick-DAhkK64J.js";const F=1e3,x=t=>{if(!t.trim())return null;const s=Number(t.replace(",","."));return Number.isFinite(s)?s:null},T="a75fd51476e6981375898466243f1f67acd5af627334883a490aeed45b89813f";class I{constructor(t){this.host=t,this._toggleServerPlans=async()=>{const t=this.host._spaceDialog;if(t)if(t.pickSaved)this.host._spaceDialog={...t,pickSaved:!1};else{this.host._spaceDialog={...t,pickSaved:!0,savedBusy:!0};try{const t=await this.host.hass.callWS({type:"houseplan/plans/list"}),s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,saved:t?.plans||[],savedBusy:!1})}catch(t){const s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,saved:[],savedBusy:!1}),this.host._showToast(this.host._t("toast.plans_list_failed",{err:this.host._errText(t)}))}}}}_help(l){const i=`${l}.aria`,h=t(this.host.hass,this.host._config?.language);return s(h,l)&&s(h,i)?a`<hp-help data-help-key=${l}
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="099688ecae2a6f015325a6ffa40c7112bd2726309f7760aec7d6b2534ad2f347";import{l as t,ar as s,A as e,t as o,c as a,bV as l,bW as i,bX as h,bY as c,ao as n,bZ as p,c2 as r,b_ as _,b$ as d,c0 as u,c1 as g,cX as m,an as b,ap as $,cY as v}from"./houseplan-card-DzlQl4_k.js";import{i as f,c as y,e as w,r as D,a as S,b as k,s as C,t as M}from"./backdrop-pick-CJ3kZCi2.js";const F=1e3,x=t=>{if(!t.trim())return null;const s=Number(t.replace(",","."));return Number.isFinite(s)?s:null},T="099688ecae2a6f015325a6ffa40c7112bd2726309f7760aec7d6b2534ad2f347";class I{constructor(t){this.host=t,this._toggleServerPlans=async()=>{const t=this.host._spaceDialog;if(t)if(t.pickSaved)this.host._spaceDialog={...t,pickSaved:!1};else{this.host._spaceDialog={...t,pickSaved:!0,savedBusy:!0};try{const t=await this.host.hass.callWS({type:"houseplan/plans/list"}),s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,saved:t?.plans||[],savedBusy:!1})}catch(t){const s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,saved:[],savedBusy:!1}),this.host._showToast(this.host._t("toast.plans_list_failed",{err:this.host._errText(t)}))}}}}_help(l){const i=`${l}.aria`,h=t(this.host.hass,this.host._config?.language);return s(h,l)&&s(h,i)?a`<hp-help data-help-key=${l}
|
||||
.text=${o(h,l)} .ariaLabel=${o(h,i)}></hp-help>`:e}_openSpaceDialog(t,s){if(!this.host._serverStorage||!this.host._serverCfg)return void this.host._showToast(this.host._t("toast.integration_missing"));if("edit"===t){const e=this.host._serverCfg.spaces.find(t=>t.id===s);if(!e)return;const o=l(e),a=e.settings?.custom_fill&&"object"==typeof e.settings.custom_fill?i(e.settings.custom_fill):null,r="none"===o.fill?{...a||h,a:0}:a;return void(this.host._spaceDialog={mode:t,spaceId:s,title:e.title,planUrl:e.plan_url||null,planFile:null,source:e.plan_url?"file":"draw",showBorders:o.showBorders,showNames:o.showNames,zeroWallStyle:p(e),displayTouched:!0,hideDecor:o.hideDecor,hideOpenings:o.hideOpenings,roomColor:o.color,roomOpacity:o.opacity,fillMode:"none"===o.fill?"custom":o.fill,customFill:r,glowEnabled:o.glow,bgColor:o.bgColor,bgMode:"static"===e.settings?.bg_mode||"daynight"===e.settings?.bg_mode?e.settings.bg_mode:null,northDeg:n({},e.settings),sunRays:"boolean"==typeof e.settings?.sun_rays?e.settings.sun_rays:null,tempMin:o.tempMin,tempMax:o.tempMax,showLqi:o.showLqi??this.host._config?.show_signal??!0,cardFontScale:o.cardFontScale,labelTemp:o.labelTemp,labelHum:o.labelHum,labelLqi:o.labelLqi,labelLight:o.labelLight,cellCm:Number(e.cell_cm)>0?Number(e.cell_cm):5,cellCmInput:c(Number(e.cell_cm)>0?Number(e.cell_cm):5,this.host._imperial),cellCmTouched:!1,busy:!1})}const e=r(this.host._imperial);this.host._spaceDialog={mode:t,title:"",planUrl:null,planFile:null,...f(),hideDecor:!1,hideOpenings:!1,zeroWallStyle:"dashed",roomColor:g,roomOpacity:u,fillMode:"custom",customFill:{...h,a:0},glowEnabled:!0,bgColor:null,bgMode:"daynight",northDeg:null,sunRays:null,tempMin:d,tempMax:_,showLqi:this.host._config?.show_signal??!0,cardFontScale:1,labelTemp:!1,labelHum:!1,labelLqi:!1,labelLight:!1,cellCm:e,cellCmInput:c(e,this.host._imperial),cellCmTouched:!1,busy:!1}}async _pickPlanFile(t){const s=t.target,e=s.files?.[0];if(!e||!this.host._spaceDialog)return;s.value="";const o=await y(e);if("reject"===o.kind)return void this.host._showToast(this.host._t("toast.plan_formats"));if("guard"===o.kind)return void(this.host._backdropGuard=o.state);const a=await w(e,o.ext,e.name);this.host._spaceDialog&&(this.host._spaceDialog={...this.host._spaceDialog,planFile:a})}_renderBackdropGuard(){return D(this.host,t=>{this.host._spaceDialog&&(this.host._spaceDialog={...this.host._spaceDialog,planFile:t})},()=>{this.host._backdropGuard=null},this.host.hass)??e}_useServerPlan(t){const s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,planUrl:t,planFile:null,pickSaved:!1,savedAspect:void 0},this.host._aspectJob=this._readPlanAspect(t))}async _readPlanAspect(t){for(let s=0;s<40;s++){const s=this.host._display(t);if(s){const e=await new Promise(t=>{const e=new Image;e.onload=()=>t(e.naturalWidth&&e.naturalHeight?e.naturalWidth/e.naturalHeight:0),e.onerror=()=>t(0),e.src=s}),o=this.host._spaceDialog;return o&&o.planUrl===t&&Number.isFinite(e)&&e>0?(this.host._spaceDialog={...o,savedAspect:e},e):0}if(await new Promise(t=>setTimeout(t,150)),this.host._spaceDialog?.planUrl!==t)return 0}return 0}async _deleteServerPlan(t){if(confirm(this.host._t("confirm.delete_plan",{name:t})))try{await this.host.hass.callWS({type:"houseplan/plans/delete",name:t});const s=this.host._spaceDialog;s?.saved&&(this.host._spaceDialog={...s,saved:s.saved.filter(s=>s.name!==t)})}catch(t){this.host._showToast(this.host._t("toast.plan_delete_failed",{err:this.host._errText(t)}))}}_renderServerPlans(t){if(t.savedBusy)return a`<div class="savedplans muted">${this.host._t("space.loading")}</div>`;const s=t.saved||[];if(!s.length)return a`<div class="savedplans muted">${this.host._t("space.no_saved")}</div>`;return a`<div class="savedplans">
|
||||
${s.map(s=>{return a`
|
||||
<div class="savedplan ${s.url===t.planUrl?"cur":""}">
|
||||
@@ -1 +1 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="a75fd51476e6981375898466243f1f67acd5af627334883a490aeed45b89813f";try{await import("./houseplan-assets/houseplan-card-DdUxnMX-.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__="099688ecae2a6f015325a6ffa40c7112bd2726309f7760aec7d6b2534ad2f347";try{await import("./houseplan-assets/houseplan-card-DzlQl4_k.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)}
|
||||
|
||||
@@ -1471,6 +1471,18 @@ retarget. Reduced motion commits the exact target atomically. Visibility loss,
|
||||
space change, recovery and disconnect cancel or settle this same owner rather
|
||||
than leaving CSS timers or WAAPI animations behind.
|
||||
|
||||
## Camera-only transition ownership (#82)
|
||||
|
||||
`src/viewport-transition.ts` owns a separate one-token/one-RAF controller for
|
||||
discrete zoom commands inside an already settled mode. It shares #101's easing
|
||||
primitive but only interpolates reactive `{zoom, viewBox}`: no chrome,
|
||||
background, layer opacity or CSS transform. Wheel retargets from the camera
|
||||
actually presented while accumulating from the pending target; pointer/pinch,
|
||||
mode, space, projection, resize, structural adoption, visibility and teardown
|
||||
are explicit ownership boundaries. The component remains the sole writer of
|
||||
camera state and persists one exact target only after settle. The controller
|
||||
lives in the core View bundle and does not import the lazy editor runtime.
|
||||
|
||||
|
||||
## Settings tiers (owner's principle, 2026-07-26)
|
||||
|
||||
|
||||
@@ -251,6 +251,18 @@ produce a zero-sized SVG `viewBox`.
|
||||
* **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.
|
||||
* **Discrete camera motion** (#82) — wheel, `−`/`+`, Fit all, the home arrow
|
||||
and kiosk double-tap 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
|
||||
the camera actually on screen, accumulates from its pending target and keeps
|
||||
the current pointer anchor. It never queues transitions or uses a CSS scale.
|
||||
Pinch and pan remain direct 1:1 gestures. Pointerdown freezes the presented
|
||||
camera; mode, space, projection, resize, structural adoption, hidden state
|
||||
and disconnect cancel or settle it before taking ownership. Reduced motion
|
||||
always commits the exact target immediately. View persists only the settled
|
||||
target once; editor camera remains session-only.
|
||||
* **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
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Wheel, zoom buttons, **Fit all**, the home arrow and kiosk double-tap now
|
||||
move the plan through a short smooth camera transition. Pinch and pan remain
|
||||
direct, and reduced-motion systems still update instantly
|
||||
([#82](https://github.com/Matysh/houseplan-card/issues/82)).
|
||||
|
||||
- The Device editor now has its own Undo/Redo history for marker positions.
|
||||
One completed drag is one step; cancelled or failed moves add nothing, and
|
||||
the 50-step session history is available from persistent toolbar buttons or
|
||||
|
||||
@@ -8,6 +8,12 @@
|
||||
|
||||
## Не выпущено
|
||||
|
||||
- Колесо, кнопки масштаба, «Показать всё», стрелка возврата и двойной тап в
|
||||
киоске теперь плавно перемещают камеру за короткую анимацию. Щипок и
|
||||
панорамирование остаются непосредственными, а при системном уменьшении
|
||||
движения масштаб меняется мгновенно
|
||||
([#82](https://github.com/Matysh/houseplan-card/issues/82)).
|
||||
|
||||
- В редакторе устройств появилась отдельная история отмены и повтора позиций
|
||||
маркеров. Одно завершённое перетаскивание — один шаг; отменённые и неудачные
|
||||
перемещения в историю не попадают. До 50 шагов текущей сессии доступны через
|
||||
|
||||
@@ -45,7 +45,9 @@ metadata). Only an explicit owner-approved emergency hotfix may skip this gate.
|
||||
while reload/return from another HA route always starts in View (#93).
|
||||
**Kiosk mode** (v1.41.0): `kiosk: true` — no
|
||||
header/editors, swipe between spaces, double-tap zoom reset, `cycle: N`
|
||||
carousel, per-screen size multipliers in localStorage.
|
||||
carousel, per-screen size multipliers in localStorage. Discrete wheel,
|
||||
button, fit/home and kiosk-reset camera commands use one short retargetable
|
||||
transition; direct pan/pinch and reduced motion remain immediate (#82).
|
||||
- **Independent Glow overlay** (#55/#19, refined locally by #61/#65–#67): dark-room
|
||||
base is used only when the effective fill resolver returns `null`, including
|
||||
dynamic modes without usable data; resolved LQI/light/temp/custom colors keep
|
||||
|
||||
@@ -1940,6 +1940,11 @@ separately promised workflows:
|
||||
## Zoom / pan / labels
|
||||
|
||||
- [ ] Wheel zoom at cursor; +/− buttons; fit button resets; badge shows %
|
||||
- [ ] Discrete zoom has an intermediate frame and exact legacy target; rapid
|
||||
wheel retarget/reversal keeps its live pointer anchor and one final
|
||||
persistence write; pointerdown/pinch, mode/space/projection/resize,
|
||||
hidden and reduced motion leave no stale camera animation
|
||||
[auto: smoke_smooth_zoom]
|
||||
- [ ] Pinch zoom + two-finger pan on touch; one-finger pan when zoomed
|
||||
- [ ] Zoom level persists per space (localStorage), restored on reload
|
||||
- [ ] Window resize / sidebar collapse refits without distortion
|
||||
|
||||
@@ -231,6 +231,12 @@ 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
|
||||
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.
|
||||
|
||||
### Cancel and undo
|
||||
|
||||
- `Esc` finishes an active Walls chain without deleting its accepted segments.
|
||||
|
||||
@@ -262,6 +262,11 @@ desktop: для точного рисования, Resize, модификато
|
||||
|
||||
Особенности:
|
||||
|
||||
- колесо, кнопки `−`/`+`, «Показать всё», стрелка возврата и двойной тап в
|
||||
киоске используют короткий плавный переход камеры; частые события колеса
|
||||
меняют текущую цель, а не выстраиваются в очередь;
|
||||
- щипок и панорамирование по-прежнему следуют за пальцами без задержки; при
|
||||
включённом в системе уменьшении движения любой масштаб меняется мгновенно;
|
||||
- панорамирование работает при любом масштабе;
|
||||
- масштаб режима просмотра запоминается отдельно для каждого пространства на текущем клиенте;
|
||||
- масштаб и панорама внутри редактора — рабочие и не заменяют сохранённый вид просмотра;
|
||||
|
||||
|
Before Width: | Height: | Size: 164 KiB After Width: | Height: | Size: 164 KiB |
|
Before Width: | Height: | Size: 241 KiB After Width: | Height: | Size: 241 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 116 KiB |
@@ -3,8 +3,8 @@
|
||||
"fixture": "synthetic-only",
|
||||
"chromium": "151.0.7922.34",
|
||||
"oxipng": "oxipng 10.2.0",
|
||||
"sourceFingerprint": "bb60991ea760ae67b18d25ba190e232420ada3c20b5bb45abc9c2ccc85ff86b2",
|
||||
"captureScriptSha256": "fe76c8e2e27501e9a377d7aa15dd6cba6a018d55c07deb602dbee097f0a0a5ba",
|
||||
"sourceFingerprint": "d27cbb20bea10b3d0a75f904be32de014d651e0c4e98a869e24c78fc0021a235",
|
||||
"captureScriptSha256": "9229c467130e1f3d3abd042e2dcabaca9d4d5a918d0d77eadc814848df597ba6",
|
||||
"command": "npm run build && node demo/docs/capture.mjs",
|
||||
"scenarios": {
|
||||
"view-desktop": {
|
||||
@@ -15,8 +15,8 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "bb60991ea760ae67b18d25ba190e232420ada3c20b5bb45abc9c2ccc85ff86b2",
|
||||
"imageSha256": "f8c9541c9d7fde462d0efba63dff70944dc03df76ce988eecaf2b269e2863a96"
|
||||
"sourceSha256": "d27cbb20bea10b3d0a75f904be32de014d651e0c4e98a869e24c78fc0021a235",
|
||||
"imageSha256": "9119ab87502038f787529f621c39e1e0d01f3bc3b0289051c3791a1886e97a6b"
|
||||
},
|
||||
"view-touch": {
|
||||
"file": "02-view-touch.png",
|
||||
@@ -26,7 +26,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "bb60991ea760ae67b18d25ba190e232420ada3c20b5bb45abc9c2ccc85ff86b2",
|
||||
"sourceSha256": "d27cbb20bea10b3d0a75f904be32de014d651e0c4e98a869e24c78fc0021a235",
|
||||
"imageSha256": "21c6fe7d79ff5c878ade8e98a64cc90dfd7bcc66338c864fb5a0bd411471313d"
|
||||
},
|
||||
"space-create": {
|
||||
@@ -37,7 +37,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "bb60991ea760ae67b18d25ba190e232420ada3c20b5bb45abc9c2ccc85ff86b2",
|
||||
"sourceSha256": "d27cbb20bea10b3d0a75f904be32de014d651e0c4e98a869e24c78fc0021a235",
|
||||
"imageSha256": "fb32ea4a785fedb0e99b0bca6dbcf19022b32ced763ed875a57eb453be550bf5"
|
||||
},
|
||||
"room-contour-close": {
|
||||
@@ -48,7 +48,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "bb60991ea760ae67b18d25ba190e232420ada3c20b5bb45abc9c2ccc85ff86b2",
|
||||
"sourceSha256": "d27cbb20bea10b3d0a75f904be32de014d651e0c4e98a869e24c78fc0021a235",
|
||||
"imageSha256": "75e0bc3dedff68e29be46531df0cc205f69988e86229580cc021017932049b34"
|
||||
},
|
||||
"plan-context-tray": {
|
||||
@@ -59,7 +59,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "bb60991ea760ae67b18d25ba190e232420ada3c20b5bb45abc9c2ccc85ff86b2",
|
||||
"sourceSha256": "d27cbb20bea10b3d0a75f904be32de014d651e0c4e98a869e24c78fc0021a235",
|
||||
"imageSha256": "5fc29f4a71572ed43221131cd6e5f9a3da19fcc9bf63c879b12664bee98e292a"
|
||||
},
|
||||
"device-editor": {
|
||||
@@ -70,8 +70,8 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "bb60991ea760ae67b18d25ba190e232420ada3c20b5bb45abc9c2ccc85ff86b2",
|
||||
"imageSha256": "f9a8c003ed0fbd6ba7d26cd5642207beabb78a3b62b972e35505ac95830a9a95"
|
||||
"sourceSha256": "d27cbb20bea10b3d0a75f904be32de014d651e0c4e98a869e24c78fc0021a235",
|
||||
"imageSha256": "49e14ee30284b44bc914a67c368abef6d8ac26ef320079e985f1edd36c7985d7"
|
||||
},
|
||||
"device-display-preview": {
|
||||
"file": "06-device-display-preview.png",
|
||||
@@ -81,7 +81,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "bb60991ea760ae67b18d25ba190e232420ada3c20b5bb45abc9c2ccc85ff86b2",
|
||||
"sourceSha256": "d27cbb20bea10b3d0a75f904be32de014d651e0c4e98a869e24c78fc0021a235",
|
||||
"imageSha256": "7185c75cf0e3629c7bc880381d1eab1d1f67a2484ca3d17e86ad53e0ed6387c8"
|
||||
},
|
||||
"background-editor": {
|
||||
@@ -92,7 +92,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "bb60991ea760ae67b18d25ba190e232420ada3c20b5bb45abc9c2ccc85ff86b2",
|
||||
"sourceSha256": "d27cbb20bea10b3d0a75f904be32de014d651e0c4e98a869e24c78fc0021a235",
|
||||
"imageSha256": "3d0d088f89da53a73efd13ef5bb5403f407011ab918e88671974459b5967ca25"
|
||||
},
|
||||
"room-card": {
|
||||
@@ -103,8 +103,8 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "bb60991ea760ae67b18d25ba190e232420ada3c20b5bb45abc9c2ccc85ff86b2",
|
||||
"imageSha256": "f1bc10b478f7afc68e70cb4c37148f680b0bbd41d50a7d63a21067fc952ff134"
|
||||
"sourceSha256": "d27cbb20bea10b3d0a75f904be32de014d651e0c4e98a869e24c78fc0021a235",
|
||||
"imageSha256": "93d88ed84e2e3f9071e94e4b74cc3f3a86c7f898a76d1fb26bcc499e7dc4f538"
|
||||
},
|
||||
"device-info": {
|
||||
"file": "09-device-info.png",
|
||||
@@ -114,8 +114,8 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "bb60991ea760ae67b18d25ba190e232420ada3c20b5bb45abc9c2ccc85ff86b2",
|
||||
"imageSha256": "2634cd7ead5720ce46ea4d6fa8f31006b740c520a73ddc97afe9a6d9d3b49778"
|
||||
"sourceSha256": "d27cbb20bea10b3d0a75f904be32de014d651e0c4e98a869e24c78fc0021a235",
|
||||
"imageSha256": "4d2257e49b28d3be517ed382269569b0e3bc18cec9d84d56b5f407c505a9b398"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
# CODE-REVIEW #82 · r1
|
||||
|
||||
- **Issue:** https://github.com/Matysh/houseplan-card/issues/82 «Плавное масштабирование zoom/fit/reset»
|
||||
- **ТЗ:** `docs/specs/082-smooth-zoom.md` (зелёное ревью `SPEC-REVIEW-82-r1`, 2026-08-30)
|
||||
- **Заход:** r1 (это первый код-ревью этой задачи; предыдущих циклов нет)
|
||||
- **SHA материала:** `779382afec951ce8f84a4d62a8eb700cf4c1bc4a` (`git rev-parse HEAD` перед подведением итогов)
|
||||
- **Диапазон:** `origin/dev...HEAD`, 12 коммитов (`895b120e`…`779382af`)
|
||||
- **Причина полного разбора (не по дельте):** это первый код-ревью цикл этой задачи — §2.10 применяется со второго раунда. Отдельно ветка была ребейзнута конвейером до ревью (2 коммита dev: `9653ba17 → 779382af`), что по §7.2 само по себе требует полного разбора, а не только разбора добавленных commit'ов.
|
||||
|
||||
## Скоуп диффа
|
||||
|
||||
`git diff origin/dev...HEAD --stat`: 55 файлов, из них по классам:
|
||||
|
||||
- **A (продукт):** `src/houseplan-card.ts` (+178/-99), `src/mode-transition.ts` (переименование `ease` → экспортируемый `easeTransitionProgress`), новый `src/viewport-transition.ts` (212 строк, чистый camera-only контроллер).
|
||||
- **B (гейты/тесты):** новый `test/viewport-transition.test.mjs`, новый `demo/smoke_smooth_zoom.mjs`, правки в `demo/smoke_infinite_canvas.mjs`, `demo/smoke_canvas_frame.mjs`, `demo/smoke_kiosk.mjs`, `demo/smoke_kiosk_pan_lock.mjs` (ожидание settle камеры), `demo/golden/harness.mjs`, `demo/docs/capture.mjs` (то же самое для golden/docs-захвата), `tsconfig.test.json`.
|
||||
- **C (документация):** `docs/ARCHITECTURE.md`, `docs/CANVAS.md`, `docs/CHANGELOG.md`/`.ru.md`, `docs/STATUS.md`, `docs/TESTING.md`, `docs/USER-GUIDE.md`/`.ru.md`, `docs/specs/README.md`, `docs/specs/082-smooth-zoom.md`, `docs/reviews/SPEC-REVIEW-82-r1.md`.
|
||||
- **D (генерируемое):** `dist/**`, `custom_components/houseplan/frontend/**`, `demo/golden/baselines/**` (8 файлов + `baselines-index.json`), `docs/images/*.png` + `screenshots.json`.
|
||||
|
||||
Продуктовый код сосредоточен в одном новом модуле плюс точечных правках `houseplan-card.ts` — контракт §8 «Единый camera-only controller» выдержан буквально: `CameraTransitionController` не знает о Lit/localStorage/режимах; `houseplan-card.ts` остаётся единственным писателем `_zoom`/`_view`.
|
||||
|
||||
## Как проверялось
|
||||
|
||||
Зелёного Validate на `779382af` на момент начала ревью не было — прогнал гейты сам.
|
||||
|
||||
| Гейт | Команда | Результат |
|
||||
| --- | --- | --- |
|
||||
| Typecheck | `npx tsc --noEmit` | чисто, без ошибок |
|
||||
| Unit-тесты | `npm test` | 1642 всего: 1641 pass, 1 skip, 0 fail |
|
||||
| Build + сверка бандла | `npm run build && cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js` | идентичны (третья копия `demo/srv/assets/**` не коммитится, #255 — синхронизирована `npm run bundle:sync` для локального прогона смоков) |
|
||||
| `no-new-any` | `node scripts/no-new-any.mjs --base origin/dev --head HEAD` | 368 добавленных строк в 3 файлах, новых `any` нет |
|
||||
| `check-docs` | `node scripts/check-docs.mjs` | пройден (7 файлов, 10 внешних ссылок); `sourceFingerprint`/`captureScriptSha256` в `docs/images/screenshots.json` пересчитаны, `imageSha256` изменился только там, где реально поменялась картинка |
|
||||
| `smoke-select` | `node scripts/smoke-select.mjs --base origin/dev --head HEAD` | 49 прямых совпадений, 33 слабых связи (полный вывод приложен ниже) |
|
||||
| Browser smoke (по необходимости, см. ниже) | `node demo/smoke_*.mjs` (15 файлов) | все зелёные |
|
||||
| `single-source-numbers` | `node --test test/single-source-numbers.test.mjs` | 3/3 pass |
|
||||
| `golden:verify` | `npm run golden:verify` | все сценарии `passed`, 0 расхождений, exit 0 |
|
||||
| `pytest tests_backend` | — | не прогонял: диф не касается `custom_components/**/*.py` |
|
||||
| `model-invariants` | — | не прогонял: диф не трогает геометрию комнат/стен/`layout`/`marker.space`/`open_spans` — только камеру (viewport presentation) |
|
||||
| Performance-профиль / screencast (AC13) | — | не прогонял: §21 ТЗ и §18 план тестов прямо относят этот гейт к предрелизному Full Performance/screencast прогону, не к код-ревью; тот же прецедент уже принят для #73 и зафиксирован как Low в `SPEC-REVIEW-82-r1` |
|
||||
|
||||
### Разбор вывода `smoke-select.mjs`
|
||||
|
||||
Изменено 3 файла в `src/**`, 57 символов проекта на изменённых строках → порог «широкого» символа 41, матрица 208 смоков.
|
||||
|
||||
**Прогнал (15 из 49 прямых совпадений + релевантные регрессии из плана автотестов ТЗ §18):**
|
||||
|
||||
- `demo/smoke_smooth_zoom.mjs` — новый, основной для AC1–AC12, AC14.
|
||||
- `demo/smoke_zoom_out.mjs`, `demo/smoke_infinite_canvas.mjs`, `demo/smoke_canvas_frame.mjs` — прямое совпадение **и** правлены в этом же диффе (ожидание settle камеры) → обязательны.
|
||||
- `demo/smoke_kiosk.mjs`, `demo/smoke_kiosk_pan_lock.mjs` — прямое совпадение, правлены в диффе, и это «kiosk pan-lock», прямо названный в плане тестов ТЗ §18.
|
||||
- `demo/smoke_visual_continuity.mjs`, `demo/smoke_warm_remount.mjs` — «#73 visual continuity / warm remount» из плана §18 (в выводе инструмента отсутствуют — связь не по имени символа, а по контракту; прогнал по требованию ТЗ, а не по подсказке инструмента).
|
||||
- `demo/smoke_mode_transition.mjs` — «#101 View/editor mode transition» из плана §18 (слабая связь по инструменту, обязательна по ТЗ).
|
||||
- `demo/smoke_isometric_contract.mjs` — «flat/isometric projection switch» из плана §18 (слабая связь по инструменту).
|
||||
- `demo/smoke_glow.mjs`, `demo/smoke_glow_geometry_resilience.mjs` — «heavy Glow camera path» из плана §18 (отсутствуют в обоих списках инструмента — НЕОПРЕДЕЛЁННОСТЬ по символам, связь по контракту AC11).
|
||||
- `demo/smoke_furniture.mjs`, `demo/smoke_opening_preview.mjs` — «opening/furniture preview interruption» из плана §18 (прямые совпадения).
|
||||
- `demo/smoke_editor_gestures.mjs` — прямое совпадение (`_zoomAt`), проверяет pinch/pan в editor-контексте (AC2).
|
||||
|
||||
**Не прогонял** оставшиеся ~36 прямых совпадений и все 33 слабые связи: это преимущественно смоки геометрии стен/проёмов/decor, где единственная точка касания — общий `_baseVb`/`_viewOr`/`_modeTransitionBusy`, не задетые этим диффом по существу (диф не меняет их логику, только оборачивает существующий `_zoomAt`/`_resetZoom` в контроллер). Полный список — предрелизная обязанность (§8), не гейт ревью при таком объёме диффа.
|
||||
|
||||
## Находки
|
||||
|
||||
Блокирующих находок нет.
|
||||
|
||||
### Low (не блокируют, зафиксированы с решением ревьюера)
|
||||
|
||||
1. **Коммит `55cf36d6 fix: preserve structural adoption guard` — не функциональный фикс.** Диф этого коммита в `src/houseplan-card.ts` — чистая перестановка порядка двух независимых операторов внутри одного и того же `if (configChanged)` (`_cancelCameraTransition(false)` вызывается после `_geometryHistory.clear()`/`_pendingPhysicalWrites.clear()` вместо «до»). `_cancelCameraTransition` не читает и не пишет geometry history, поэтому порядок не влияет на наблюдаемое поведение — обе версии эквивалентны семантически. Коммит промаркирован `fix:`, хотя по факту ничего не чинит; наблюдение чисто по формулировке сообщения коммита, к продукту претензий нет. Решение: не возвращать, отметить и снять.
|
||||
2. **AC13 (performance/screencast для тяжёлых Glow/flat/isometric fixtures) закрыт только предрелizным гейтом**, не автотестом в этом диффе — уже принято как Low в `SPEC-REVIEW-82-r1` (прецедент #73), подтверждаю то же решение на уровне кода: `src/viewport-transition.ts` не делает ничего, что могло бы задеть Glow/geometry rebuild (чистая интерполяция `{zoom, viewBox}`, никаких side-effects на кадр), и `glowAndStructuralFrameStayStable` в `smoke_smooth_zoom.mjs` подтверждает отсутствие rebuild на обычном фикстуре. Решение: не блокирует, предрелизный Full Performance гейт остаётся обязательным перед бетой.
|
||||
|
||||
## Проверено и корректно (по каждому AC)
|
||||
|
||||
| AC | Доказательство | Как проверено |
|
||||
| --- | --- | --- |
|
||||
| AC1 | Unit (`interpolateCameraState` start/mid/end) + `smoke_smooth_zoom`: `buttonHasIntermediateFrame`, `buttonSettlesAtExactTarget`, `wheelHasIntermediateFrames`, `fitHomeAndDoubleTapAnimate`, `farFitUsesTheSameTransition` | исполнено, все true |
|
||||
| AC2 | `smoke_smooth_zoom`: `pinchIsDirectWithoutPostAnimation`; `smoke_editor_gestures`: `panWorksAndDoesNotDraw`, `pinchZoomsInPlanEditor` | исполнено |
|
||||
| AC3 | Единая точка записи `_zoom`/`_view` в `_applyCameraTransitionFrame`/`_settleCameraTransition`, которую читают все слои рендера (нет отдельного copy камеры для SVG/HTML) | проверено чтением: нет второго источника координат в диффе |
|
||||
| AC4 | Unit (`retarget starts from presented state and cancels the obsolete RAF`) + `smoke_smooth_zoom`: `wheelRetargetsRunningTween`, `wheelReversalKeepsAnchor` (≤0.5 px), `wheelStreamPersistsOnce` | исполнено |
|
||||
| AC5 | Unit (`cancel policies...`) + `smoke_smooth_zoom`: `exactLimitsAndFitAreNoops` (RAF=0, saves=0 на fit/min/max) | исполнено |
|
||||
| AC6 | pointerdown/hidden/projection — `smoke_smooth_zoom`: `pointerdownFreezesPresentedFrame`, `hiddenCommitsTargetOnce`, `projectionCancelsAndIsoSettlesExactly`. Mode/space/resize/adoption — код читает `_cancelCameraTransition(false)` в `_cancelModeTransition`, `_commitSpace`, `_refitView`, `_adoptStructuralResponses` (оба `configChanged`/`layoutChanged` пути) | смешанно: исполнено (pointer/hidden/projection) + проверено чтением (mode/space/resize/adoption) |
|
||||
| AC7 | `smoke_warm_remount`, `smoke_visual_continuity` — все проверки зелёные без затрагивания камеры-контроллера (atomic paths используют `_applyView`/прямые пути, не `CameraTransitionController`) | исполнено |
|
||||
| AC8 | Unit (duration=0 → `canAnimate=false` → немедленный settle) + `smoke_smooth_zoom`: `reducedMotionIsImmediate` (до и во время команды) | исполнено |
|
||||
| AC9 | `_saveZoom()` вызывается ровно один раз на settle (не на кадр); ранний `if (this._mode !== 'view') return` в `_saveZoom` — не тронут, продолжает не писать editor zoom. `smoke_smooth_zoom`: `buttonPersistsOnce`, `wheelStreamPersistsOnce` (оба `=== 1`) | исполнено + проверено чтением для editor-ветки |
|
||||
| AC10 | `smoke_isometric_contract` зелёный без изменений; `smoke_smooth_zoom`: `isoFinal` сравнивается с `_cameraTargetAt(...).target` — точное совпадение | исполнено |
|
||||
| AC11 | `smoke_smooth_zoom`: `glowAndStructuralFrameStayStable` (глубокое сравнение JSON до/после zoom-команды); `smoke_glow`, `smoke_glow_geometry_resilience` зелёные | исполнено |
|
||||
| AC12 | Unit `cancel policies...` (RAF=0 после `cancel(false)`, идентично `dispose()`); `disconnectedCallback` вызывает `_cameraTransition.dispose()` | проверено чтением + эквивалентный unit-путь |
|
||||
| AC13 | См. Low-находку №2 — предрелизный гейт, не код-ревью | не прогонялось (осознанно, см. находки) |
|
||||
| AC14 | `smoke_smooth_zoom`: `coldViewDoesNotLoadEditorRuntime` (проверка `performance.getEntriesByType('resource')` на отсутствие `houseplan-editor-runtime-*`) | исполнено |
|
||||
|
||||
Дополнительно по §8 ревью-инструкции: **«одно число — один источник»** — zoom badge (`Math.round(this._zoom * 100)}%`) остаётся единственным местом чтения `_zoom`; других мест, где масштаб пересчитывается отдельно для отображения, диф не добавил. `test/single-source-numbers.test.mjs` зелёный.
|
||||
|
||||
Терминология сверена с `docs/USER-GUIDE.md`/`.ru.md` и `src/i18n/en.json`/`ru.json`: «Fit all» / «Вписать всё» (`title.zoom_fit`), нет расхождений.
|
||||
|
||||
Release-артефакты (§21 ТЗ) на месте и в одном коммите с поведением: `CHANGELOG.md`/`.ru.md` (в `c4b8022c`, том же, что и `User-Visible: yes`), `USER-GUIDE.md`/`.ru.md`, `CANVAS.md`, `ARCHITECTURE.md`, `TESTING.md`, `STATUS.md`. Golden-коммит `779382af` несёт обязательные `Release:`/`Baseline-Reviewed:` трейлеры и внятную причину (8 изменений унаследованы от `dev`, не связаны с фичей, 139 сценариев не тронуты).
|
||||
|
||||
## Чего не проверял
|
||||
|
||||
- Полный набор из 208 browser-смоков — не по теме, не по AC; прогнал только «прямые совпадения + названные в ТЗ» (см. таблицу smoke-select выше). Оставшиеся ~36 прямых совпадений — geometry/decor-смоки, не проверял.
|
||||
- `pytest tests_backend` — диф не касается Python.
|
||||
- `model-invariants` — диф не касается геометрии/ссылок/`layout`.
|
||||
- Performance-профиль / screencast для AC13 — сознательно оставлено предрелизному гейту (см. Low №2).
|
||||
- Реальный ручной прогон в браузере (Home Assistant инстанс) — не выполнялся, ревью проходит без ручного тестирования по процессу; вопрос «работает ли» закрыт автотестами + browser-smoke выше.
|
||||
- Не проверял содержимое `Baseline-Reviewed` CI-прогона (`33319326145`) построчно — доверился описанию коммита и совпадению `139 сценариев не изменились` с локальным `golden:verify` (все `passed`).
|
||||
|
||||
## Итог
|
||||
|
||||
Реализация точно следует контракту ТЗ §8–§13: отдельный camera-only controller, один RAF/token owner, retarget от представленного кадра с накоплением от pending target, явные границы отмены/владения, persistence только на settle с сохранением существующего editor-guard. Все AC либо доказаны исполненным автотестом, либо разобраны по коду с точным указанием строк. Единственные находки — Low, не входящие в скоуп блокировки. Вердикт: зелёный.
|
||||
@@ -0,0 +1,195 @@
|
||||
# CODE-REVIEW-82-r2
|
||||
|
||||
- **Issue:** #82 «Плавное масштабирование плана: анимация zoom/fit/reset»
|
||||
- **Этап:** code (PROCESS.md §2.7)
|
||||
- **Заход:** r2 · блокирующих циклов израсходовано 0 из 4 (r1 был зелёным, бюджет не тратил, #227)
|
||||
- **Материал:** `origin/dev...HEAD`, HEAD = `cc73252aa73007db3bd636b02eec3dd8ece871b2`
|
||||
- **origin/dev на момент ревью:** `e39cd3dfa1453be140ab809cd4344f9474bd0b2c`
|
||||
- **Диапазон:** 13 коммитов, `8a18e812`…`cc73252a`
|
||||
- **Вердикт:** зелёный · High: 0 · Medium: 0
|
||||
|
||||
## Почему разбор полный, а не по дельте
|
||||
|
||||
После зелёного r1 (вердикт на SHA `779382af`, комментарий 2026-08-30T15:51:14Z)
|
||||
ветка была ребейзнута на ушедший вперёд `dev` (`e39cd3df`), а после этого
|
||||
довешено ещё 6 коммитов, которых r1 не видел (`a945ca47`…`b5324ce4`, см. ниже).
|
||||
Ребейз на ушедший вперёд dev — один из явных случаев, для которых инструкция
|
||||
требует полный разбор, а не разбор по дельте (§7.2: после ребейза это другой
|
||||
код). Поэтому весь диапазон `origin/dev...HEAD` перечитан и перепроверен
|
||||
заново, включая часть, которую r1 уже одобрил, а не только новые 6 коммитов.
|
||||
|
||||
Отдельное наблюдение по процессу (не находка к коду, Low): комментарий
|
||||
владельца «Финальный rebase… Единственный конфликт был в
|
||||
docs/images/screenshots.json… исходный код и поведение камеры не
|
||||
конфликтовали» описывает состояние ДО того, как в ветку попали ещё 6 коммитов
|
||||
(`a945ca47`…`b5324ce4`), один из которых (`64f54fd4`) меняет реальное поведение
|
||||
`src/houseplan-card.ts` (см. AC1/AC5 ниже), а не только тесты и скриншоты.
|
||||
Ни один из этих 6 коммитов не описан в отдельном комментарии issue. Это не
|
||||
блокирует ревью — полный разбор ниже покрывает эти коммиты по существу — но
|
||||
статус-комментарии в этом раунде не отражали фактический diff на момент
|
||||
возврата задачи в S7.
|
||||
|
||||
## Что проверено и как
|
||||
|
||||
### Дешёвые гейты
|
||||
|
||||
Validate на точном HEAD `cc73252a` зелёный
|
||||
(https://github.com/Matysh/houseplan-card/actions/runs/33320914612), поэтому
|
||||
`tsc --noEmit`, `npm test` (1642, см. предыдущий зелёный прогон) и
|
||||
`npm run build` со сверкой копий бандла не перегонялись как отдельная
|
||||
проверка. Тем не менее `npm run bundle:sync` (= `tsc --noEmit && rollup -c` +
|
||||
раскладка) выполнен локально по ходу подготовки к golden — прошёл чисто,
|
||||
`dist`/`custom_components/.../frontend`/`demo/srv/assets` идентичны после
|
||||
сборки, `git status` остался чистым (значит закоммиченные бандлы уже
|
||||
соответствуют исходникам этого HEAD).
|
||||
|
||||
`node scripts/check-docs.mjs` — выполнен (diff трогает `src/**`):
|
||||
`Documentation checks passed (7 files, 10 external links)`.
|
||||
|
||||
### Golden (не покрывается перечнем «дешёвых» гейтов из шапки этого раунда)
|
||||
|
||||
Diff меняет рендер (camera-контроллер, 8 «унаследованных из dev» golden-baseline
|
||||
и 4 doc-скриншота, включая правку `screenshots.json`). Запущен
|
||||
`npm run golden:verify` — **147/147 passed**, включая все 8 файлов из коммита
|
||||
«test: accept inherited dev golden updates» (`device-dialog-desktop-de`,
|
||||
`device-dialog-mobile-ru`, `toggle-entity-dialog-mobile-ru`,
|
||||
`device-ripple-color-popover-mobile-ru`, `tray-medium-group-en` и т.д. — все
|
||||
`passed` в выводе). Это подтверждает утверждение коммита не как заявление
|
||||
автора, а как воспроизведённый прогон: текущий код рендерит ровно то, что
|
||||
принято в качестве эталона, независимо от того, что сами PNG побайтово
|
||||
отличаются от версии в `origin/dev` (шумовая перекодировка/иной прогон
|
||||
захвата — не дефект).
|
||||
|
||||
### Browser smoke — выбор по диффу
|
||||
|
||||
`node scripts/smoke-select.mjs --base origin/dev --head HEAD`: 49 прямых
|
||||
совпадений, 33 слабые связи (общий символ вроде `_modeTransitionBusy`,
|
||||
`stopPropagation`). Прогнаны:
|
||||
|
||||
- все смок-файлы, которые сам diff **редактирует** (значит, r1 их в этом виде
|
||||
не видел): `smoke_canvas_frame`, `smoke_infinite_canvas`, `smoke_kiosk`,
|
||||
`smoke_kiosk_pan_lock` — все правки только добавляют `await` на
|
||||
устаканивание камеры перед проверкой существующих инвариантов, поведение не
|
||||
меняют;
|
||||
- главный доказатель AC — `smoke_smooth_zoom` (250 строк, покрывает
|
||||
AC1–AC12, AC14 согласно ТЗ §18);
|
||||
- обязательный минимум регрессий из ТЗ §18 и уже проверенный r1 набор:
|
||||
`smoke_zoom_out`, `smoke_editor_gestures`, `smoke_furniture`,
|
||||
`smoke_opening_preview`, `smoke_visual_continuity` (#73),
|
||||
`smoke_warm_remount`, `smoke_mode_transition` (#101),
|
||||
`smoke_isometric_contract`, `smoke_glow`, `smoke_glow_geometry_resilience`.
|
||||
|
||||
Все 15 — **зелёные** (полный вывод каждого — все ключи `true`/`OK`).
|
||||
|
||||
Остальные 34 «прямых» совпадения из смок-select не прогнаны: они привязаны к
|
||||
`_baseVb`/`_viewOr`/`_cursorPt`/`_modeTransitionBusy` — символам, которые
|
||||
внутри строки почти не меняли поведение (только сигнатуры точек входа и
|
||||
уборка opening-курсора при старте команды камеры, без изменения самой логики
|
||||
черчения/декора/wall-thickness). Слабые связи (33) не прогонялись — общее имя
|
||||
без прямого попадания в изменённый символ.
|
||||
|
||||
### Что не прогонялось и почему
|
||||
|
||||
- `python -m pytest tests_backend` — diff не трогает `custom_components/**/*.py`
|
||||
(только сгенерированный JS-бандл внутри `custom_components/.../frontend`).
|
||||
- `npm run invariants` — diff не трогает геометрию (нет правок рёбер комнат,
|
||||
записей толщины, `layout`, `marker.space`, `open_spans`); камера работает
|
||||
только с `viewBox`/`zoom`.
|
||||
- Performance-профиль/screencast для AC13 — не прогонялся. ТЗ §21 и матрица
|
||||
AC13 сами относят это к предрелизному Full Performance гейту, не к
|
||||
автотесту в цикле ревью; то же решение уже дважды принято (r1 spec-review и
|
||||
r1 code-review). Косвенно AC13-риск (Glow/geometry rebuild на кадре)
|
||||
проверен `glowAndStructuralFrameStayStable` в `smoke_smooth_zoom` и
|
||||
`smoke_glow_geometry_resilience` — оба зелёные.
|
||||
- `test/single-source-numbers.test.mjs` — механически входит в `npm test`
|
||||
(уже зелёный по Validate). Смысловая проверка сделана вручную: zoom badge
|
||||
(`src/houseplan-card.ts:11517`, `Math.round(this._zoom * 100)}%`) — единственное
|
||||
место, где процент зума выводится пользователю; он читает `this._zoom`
|
||||
напрямую, то же поле, которое `_applyCameraTransitionFrame` обновляет на
|
||||
каждом кадре. Второго источника числа не найдено.
|
||||
|
||||
## AC1–AC14: доказательства
|
||||
|
||||
| AC | Статус | Доказательство |
|
||||
| --- | --- | --- |
|
||||
| AC1 | Выполнен | `smoke_smooth_zoom`: `buttonHasIntermediateFrame`, `fitHomeAndDoubleTapAnimate`, `farFitUsesTheSameTransition` — все `true`; код `_stepZoom`/`_resetZoom`/`_fitAll`/`_fitFar` в `src/houseplan-card.ts:6270-6345` строят target существующей `fitView`/`_clampView` и передают его в `CameraTransitionController` |
|
||||
| AC2 | Выполнен | `smoke_smooth_zoom.pinchIsDirectWithoutPostAnimation`, `smoke_editor_gestures` (pinch/pan в редакторе) — зелёные; `_zoomAt()` (immediate-путь) не создаёт transition |
|
||||
| AC3 | Выполнен | Прочитано кодом: единственные reactive `_zoom`/`_view` пишутся только в `_applyCameraTransitionFrame`/`_settleCameraTransition`/`_zoomAt`, все SVG/HTML/hit-слои читают их через общий рендер-путь; `smoke_smooth_zoom.pointerdownFreezesPresentedFrame` подтверждает, что pointerdown видит реально представленный кадр, а не устаревший target |
|
||||
| AC4 | Выполнен | `smoke_smooth_zoom.wheelRetargetsRunningTween` (разные token, `reverseFrom≈presentedBeforeReverse`), `wheelReversalKeepsAnchor` (≤0.5px) |
|
||||
| AC5 | Выполнен | `smoke_smooth_zoom.exactLimitsAndFitAreNoops` (`active=false`, `saves=0` на fit/max/min); код `_startCameraTransition` (houseplan-card.ts:1207-1223) возвращает `false` без RAF/`_saveZoom`, если target совпадает с текущим или с уже бегущим target |
|
||||
| AC6 | Выполнен | `pointerdownFreezesPresentedFrame`, `projectionCancelsAndIsoSettlesExactly`, `hiddenCommitsTargetOnce` — все `true`; код: `_cancelCameraTransition` вызван в `_stagePointerDown`, `_commitSpace`, `_setProjection`-пути (через `_cancelModeTransition`/adoption), `_refitView`, `_pageVisibility('hidden')`, config/layout adoption (`b3abfc67`) |
|
||||
| AC7 | Выполнен | `smoke_warm_remount`, `smoke_visual_continuity` (#73) — зелёные без изменений поведения |
|
||||
| AC8 | Выполнен | `reducedMotionIsImmediate` (`true`); `_onMotionChange` вызывает `_cancelCameraTransition(true)` при включении reduced motion, `_startCameraTransition` передаёт `duration=0` при `_reducedMotion` |
|
||||
| AC9 | Выполнен | `buttonPersistsOnce`/`wheelStreamPersistsOnce` (`saves===1`); `_saveZoom()` вызывается только из `_settleCameraTransition`, один раз на settle |
|
||||
| AC10 | Выполнен | `projectionCancelsAndIsoSettlesExactly` — итоговый `viewBox`/`zoom` точно совпадает с независимо вычисленным `_cameraTargetAt` |
|
||||
| AC11 | Выполнен | `glowAndStructuralFrameStayStable` (`JSON.stringify` до/после равны); `smoke_glow`, `smoke_glow_geometry_resilience` зелёные |
|
||||
| AC12 | Выполнен | `disconnectedCallback` вызывает `this._cameraTransition.dispose()` (houseplan-card.ts:2745); unit `viewport-transition.test.mjs` — `dispose()`→`cancel(false)` не оставляет RAF (`fake.callbacks.size===0` в тесте «cancel policies») |
|
||||
| AC13 | Deferred (не автотест здесь) | То же решение, что в r1 spec- и code-review: закрывается предрелизным Full Performance/screencast гейтом, не циклом ревью. Косвенно риск закрыт `glowAndStructuralFrameStayStable` |
|
||||
| AC14 | Выполнен | `coldViewDoesNotLoadEditorRuntime` (`true`, проверка через `performance.getEntriesByType('resource')`); `src/houseplan-editor-runtime.ts` не тронут диапазоном |
|
||||
|
||||
## Дельта сверх того, что видел r1
|
||||
|
||||
Сверх `c465eae8`/`54403704`/`b3abfc67` (уже одобрены r1 под именами
|
||||
`895b120e…779382af`) в диапазон добавлено:
|
||||
|
||||
- `a945ca47` — `demo/docs/capture.mjs`: ждёт `card._cameraTransition?.active`
|
||||
перед снимком, иначе бросает. Тестовая инфраструктура, поведение продукта
|
||||
не меняет.
|
||||
- `64f54fd4` — **единственный коммит с правкой продуктового кода** сверх
|
||||
того, что видел r1: `_fitFar()`/`_fitAll()` получили безусловный
|
||||
`this.requestUpdate()` перед `_resetZoom(...)` (houseplan-card.ts:5975,
|
||||
5985). Причина: `_showFar` — обычное приватное поле, не Lit `@state()`
|
||||
(houseplan-card.ts:5755), поэтому раньше рендер после клика по far-hint
|
||||
зависел от того, что `_resetZoom()` синхронно меняла `_zoom`/`_view` и тем
|
||||
самым просила update. С #82 `_startCameraTransition` может вернуть `false`
|
||||
без единого вызова хуков (когда camera-target уже совпадает с текущим) —
|
||||
и тогда без явного `requestUpdate()` смена `_showFar` не попала бы в
|
||||
рендер. Остальные правки того же коммита — `await` на устаканивание камеры
|
||||
в `demo/golden/harness.mjs`, `smoke_canvas_frame`, `smoke_infinite_canvas`,
|
||||
`smoke_kiosk` — тестовая инфраструктура.
|
||||
**Low, не блокирует:** конкретно этот no-op-путь (far-hint нажат, когда
|
||||
camera-target уже равен текущему — например повторный клик до истечения
|
||||
предыдущего fit-far) не покрыт отдельной smoke-проверкой; существующие
|
||||
тесты far-hint (`smoke_infinite_canvas.hintGoneAfterShow`) всегда бьют по
|
||||
сценарию с реальным движением камеры. Правка защитная и дешёвая
|
||||
(лишний `requestUpdate()` не создаёт видимых артефактов), риск регрессии
|
||||
низкий, поэтому не блокирует зелёный вердикт.
|
||||
- `4c4399a7`, `4e24e266`, `9171644f` — три последовательных пересъёма
|
||||
doc-скриншотов (`08-room-card.png`, `09-device-info.png`,
|
||||
`06-device-editor.png`) с обновлением `screenshots.json`; `check-docs.mjs`
|
||||
зелёный на финальном состоянии.
|
||||
- `b5324ce4` — 8 golden-baseline из `origin/dev`, подтверждены прогоном
|
||||
golden:verify выше (не только заявлением коммита).
|
||||
|
||||
## Закрытие раунда r1
|
||||
|
||||
| Находка r1 | Чем закрыта | Где это видно |
|
||||
| --- | --- | --- |
|
||||
| `55cf36d6`/`b3abfc67 fix: preserve structural adoption guard` — по факту перестановка двух независимых операторов без наблюдаемой разницы поведения, замечание только к формулировке сообщения коммита | Не блокировала r1, не блокирует и сейчас; независимо перечитан diff коммита `b3abfc67` в этом раунде — подтверждаю: переставлен только порядок `this._cancelCameraTransition(false)` относительно очистки `_geometryHistory`/`_devicePositionHistory`/`_pendingPhysicalWrites`, эти операции не пересекаются по состоянию | `git show b3abfc67 -- src/houseplan-card.ts` |
|
||||
| AC13 закрыт только предрелizным Full Performance гейтом, не автотестом в цикле | Тот же прецедент подтверждён и на этом SHA — решение не изменилось | Раздел AC13 выше |
|
||||
|
||||
## Унаследовано из r1
|
||||
|
||||
Поскольку ребейз на ушедший вперёд `dev` требует полного разбора (§7.2), из
|
||||
r1 ничего не принято «на слово» без собственной перепроверки в этом
|
||||
раунде — весь диапазон, включая уже одобренные r1 коммиты, перечитан заново
|
||||
и его гейты (golden, smoke, check-docs, build) перезапущены в этом раунде.
|
||||
Без повторной проверки принято только решение уровня продукта/процесса,
|
||||
которое не является предметом код-ревью:
|
||||
|
||||
- продуктовая корректность ТЗ (структура §7.1, формулировки AC, отсутствие
|
||||
недекларированных догадок) — из `SPEC-REVIEW-82-r1.md`
|
||||
(`docs/reviews/SPEC-REVIEW-82-r1.md`, ТЗ на SHA `141d79a9`); код-ревью не
|
||||
переоценивает качество ТЗ, только соответствие ему;
|
||||
- прецедент «AC13 закрывается предрелизным Full Performance гейтом» — принят
|
||||
ещё в `SPEC-REVIEW-82-r1.md` и переподтверждён `CODE-REVIEW-82-r1.md`; в
|
||||
этом раунде не пересматривается заново, только сверено, что новых
|
||||
Glow/geometry-путей в дельте нет (см. выше).
|
||||
|
||||
## Итог
|
||||
|
||||
High: 0. Medium: 0. Low: 3 (2 унаследованы и переподтверждены, 1 новая —
|
||||
непокрытый smoke-тестом no-op-путь `_fitFar()`/`_fitAll()`, не блокирует).
|
||||
Все AC1–AC12, AC14 подтверждены исполненным автотестом или разобраны по коду
|
||||
с указанием строк; AC13 корректно отложен на предрелизный гейт по уже
|
||||
принятому прецеденту. Вердикт: зелёный.
|
||||
@@ -0,0 +1,183 @@
|
||||
# SPEC-REVIEW-82-r1 — Плавное масштабирование плана (issue #82)
|
||||
|
||||
- **Issue:** https://github.com/Matysh/houseplan-card/issues/82
|
||||
- **Артефакт ТЗ:** `docs/specs/082-smooth-zoom.md`, коммит `141d79a923f5703eb3beceebdabbd5508821f0fe`
|
||||
- **Заход:** r1 · блокирующих циклов израсходовано 0 из 4 (полный трек, лимит 4)
|
||||
- **Ревьюер:** Claude, роль «Ревьюер ТЗ» (PROCESS.md §2.4, §6)
|
||||
|
||||
## Скоуп ревью
|
||||
|
||||
Задача полного трека (нет метки `small`), поэтому ТЗ живёт файлом
|
||||
`docs/specs/082-smooth-zoom.md`, а не в теле issue. Это первый заход
|
||||
ревью на этом ТЗ (r1), поэтому §2.10 (разбор по дельте) не применяется —
|
||||
разбор полный. Диф на пути к текущему SHA:
|
||||
|
||||
```
|
||||
git diff 453c3e3d..141d79a9 -- docs/specs/082-smooth-zoom.md # 352+/232- строк, актуализация ТЗ
|
||||
```
|
||||
|
||||
Диф не трогает `src/**`, `custom_components/**`, `test/**`, `demo/**` —
|
||||
только `docs/specs/082-smooth-zoom.md`. Класс C (документация), гейты
|
||||
типа `typecheck`/`test`/`build`/`check-docs` к этому диффу неприменимы:
|
||||
менять нечего, кода не существует. См. «Чего не проверял».
|
||||
|
||||
## Как проверялось
|
||||
|
||||
1. Прочитан `docs/SCOPE.md`, `AGENTS.md`, `PROCESS.md` (§1, §2.3–2.10, §4,
|
||||
§5, §7, §12).
|
||||
2. Прочитано тело issue #82 и все три комментария (аналитика 2026-08-14,
|
||||
заведение ТЗ 2026-08-15, актуализация 2026-08-30).
|
||||
3. Прочитан полный текущий текст `docs/specs/082-smooth-zoom.md` (24
|
||||
раздела).
|
||||
4. Прочитан канонический `docs/CANVAS.md` (§5 Zoom and pan, «View/editor
|
||||
camera handoff», §4 content frame) и `docs/TOUCH-SUPPORT.md` (raздел
|
||||
про View/editor visual transition, deliberate degradation rule).
|
||||
5. Проверены фактические утверждения ТЗ о текущем коде против
|
||||
`src/houseplan-card.ts` (`_zoomAt`, `_onWheel`, `_stepZoom`,
|
||||
`_resetZoom`, `_fitAll`, `_fitFar`, `_baseVb`, `_clampView`,
|
||||
`ZOOM_MAX`/`ZOOM_MIN`, `LS_ZOOM`, `_saveZoom`, lazy `import('./houseplan-editor-runtime')`)
|
||||
и `src/mode-transition.ts` (`ModeTransitionController`,
|
||||
`interpolateModeVisualState` — уже интерполирует `pixelsPerUnit` в
|
||||
log-space и center линейно тем же easing, что подтверждает
|
||||
реализуемость предложенного §9 механизма).
|
||||
6. Проверена терминология кнопок/подсказок против `src/i18n/ru.json`
|
||||
(`title.zoom_fit` = «Вписать всё», `canvas.show_far` = «Показать») —
|
||||
совпадает с матрицей §6 ТЗ.
|
||||
7. Проверено существование смоков, на которые ссылается план тестов
|
||||
(`demo/smoke_mode_transition.mjs`, `demo/smoke_visual_continuity.mjs`,
|
||||
`demo/smoke_zoom_out.mjs`, `demo/smoke_pan_any_zoom.mjs`,
|
||||
`demo/screencast_visual_continuity.mjs`, `npm run continuity:screencast`
|
||||
в `docs/TESTING.md`) — не изобретены, это существующий инструментарий
|
||||
#73.
|
||||
8. Проверены обязательные разделы ТЗ по PROCESS.md §7.1 построчно
|
||||
(список ниже).
|
||||
|
||||
## Проверка обязательных разделов (§7.1)
|
||||
|
||||
| Раздел §7.1 | Есть в ТЗ | Где |
|
||||
| --- | --- | --- |
|
||||
| Сценарий (персона/поверхность/момент) | ✅ | §1 |
|
||||
| Что человек увидит до/после | ✅ | §2 |
|
||||
| Проблема | ✅ | §3 |
|
||||
| Скоуп и не-скоуп | ✅ | §4 (цели) + §5 (не входит) |
|
||||
| Контракт поведения / UX | ✅ | §6–§13 |
|
||||
| Модель данных и миграция | ✅ | §14 — «не меняется, миграции нет» |
|
||||
| i18n | ✅ | §20 — новых ключей нет |
|
||||
| AC1…ACn с доказательством | ✅ | §17, 14 критериев, у каждого назван способ |
|
||||
| План автотестов | ✅ | §18 (unit / browser smoke / golden-perf) |
|
||||
| Риски | ✅ | §22, 8 рисков с мерой |
|
||||
| Откат | ✅ | §23 |
|
||||
| Release-артефакты | ✅ | §21 |
|
||||
|
||||
Все обязательные разделы присутствуют и содержательны, не переписывают
|
||||
issue дословно.
|
||||
|
||||
## Находки
|
||||
|
||||
Блокирующих (High) находок нет. В скоупе-Medium находок нет. Вне
|
||||
скоупа — нет.
|
||||
|
||||
Ниже — три Low-наблюдения; ни одно не требует правки ТЗ, оставлены с
|
||||
записью решения ревьюера (не блокируют переход в `S5-ready`).
|
||||
|
||||
### Low-1 — AC13 частично доказывается гейтом, недоступным на этой стадии цикла
|
||||
|
||||
`AC13` называет доказательством «Performance profile + screencast
|
||||
smoke». Per `docs/STATUS.md`, CDP-скриншот-профиль (`continuity:screencast`)
|
||||
— **stable-release-only** гейт, а не гейт код-ревью или даже бета-гейта;
|
||||
то есть формально AC13 не может быть закрыт автотестом до промоушена в
|
||||
стабильный релиз. Это не изобретение автора ТЗ: тот же паттерн уже
|
||||
принят для #73 (`docs/superpowers/specs/2026-08-10-plan-visual-continuity-design.md`
|
||||
явно делит проверку на обязательный CI rAF sampler и pre-stable CDP
|
||||
screencast). Решение ревьюера: не блокировать — прецедент уже
|
||||
принят продуктом, а перформанс-часть AC13 (RAF/rebuild-счётчики) всё
|
||||
равно доказывается раньше, в pre-beta профиле (§8). Отметить только,
|
||||
что на код-ревью AC13 будет разобран по коду плюс перформанс-профиль,
|
||||
а screencast-часть закроется не раньше стабильного гейта — это стоит
|
||||
явно писать в хендоффе, а не подразумевать.
|
||||
|
||||
### Low-2 — общий easing-хелпер между `ModeTransitionController` и новым camera controller не специфицирован технически
|
||||
|
||||
§24 помечает разделение camera/mode-контроллеров с общим easing как
|
||||
«принято предположительно, поменять свободно» — формально корректно
|
||||
по PROCESS.md §7.1 (технический вопрос, не продуктовый). Замечание
|
||||
чисто наблюдательное: `interpolateModeVisualState` в
|
||||
`src/mode-transition.ts:124-156` уже реализует ровно ту же схему
|
||||
(log-space для масштаба, линейно для центра, общий `ease()`), так что
|
||||
риск, который §22 таблицы называет «Wheel ощущается медленным» /
|
||||
«SVG и HTML расходятся», технически прецедентно снят — эта же формула
|
||||
уже работает в проде для перехода режимов. Не требует правки ТЗ.
|
||||
|
||||
### Low-3 — «дальний hint «Показать»» добавлен в матрицу §6, не упомянутый в исходном тексте issue
|
||||
|
||||
Матрица §6 ТЗ добавляет девятую строку («Far hint «Показать»»)
|
||||
относительно восьми строк в исходном issue. Проверено по коду:
|
||||
`_fitFar()` (`src/houseplan-card.ts:5879`) сам вызывает `_resetZoom()`
|
||||
— тот же примитив, что и «Вписать всё»/home-arrow/double-tap, так что
|
||||
это не новая функциональность, а актуализация того, что «Вписать всё» в
|
||||
issue уже подразумевало устройство разных источников одного и того же
|
||||
действия. Не расширение скоупа, не требует нового вопроса владельцу.
|
||||
|
||||
## Что проверено и корректно
|
||||
|
||||
- Все фактические утверждения раздела 3 «Актуальность и проблема» о
|
||||
текущем коде (`_zoomAt`, `_onWheel`, `_stepZoom`, `_resetZoom`,
|
||||
`_fitAll`/`_fitFar`, lazy editor runtime, общий `_view` для
|
||||
flat/isometric) — сверены с исходным кодом и точны.
|
||||
- Константы `MIN_ZOOM = 1/3`, `ZOOM_MAX = 8`, easing
|
||||
`cubic-bezier(0.2, 0.7, 0.2, 1)` — совпадают с `docs/CANVAS.md` §5 и
|
||||
`src/mode-transition.ts`.
|
||||
- Persistence-контракт §13 («editor zoom не пишется в View intent»,
|
||||
«`_saveZoom()` работает только в `mode === 'view'`») сверен с
|
||||
`src/houseplan-card.ts:6212-6228` — комментарий в коде подтверждает
|
||||
тот же инвариант дословно.
|
||||
- Терминология кнопок («Вписать всё», «Показать») сверена с
|
||||
`src/i18n/ru.json` — совпадает буквально.
|
||||
- `docs/TOUCH-SUPPORT.md` не противоречит требованию §12 «stage не
|
||||
получает overlay/inert» во время camera-only tween: канонический
|
||||
`inert` относится к отдельному, другому контракту View/editor
|
||||
mode-перехода (#101), а не к zoom-действию внутри режима — ТЗ
|
||||
корректно разграничивает эти два случая в §8.
|
||||
- AC1–AC14 однозначны, у каждого указан способ доказательства; ни
|
||||
одна формулировка не читается как два разных критерия одновременно.
|
||||
- Раздел 24 «Принятые технические предположения» корректно отделяет
|
||||
технические решения от продуктовых; продуктовых вопросов к
|
||||
владельцу действительно не осталось — сценарии из issue (interruption
|
||||
matrix, accessibility, no-op) уже были фактами, подтверждёнными
|
||||
комментарием владельца 2026-08-14 («вопросы: нет»).
|
||||
- Раздел «Не входит в задачу» корректно исключает inertia/kinetic pan,
|
||||
анимацию mode/space/projection-переходов, новый storage-формат,
|
||||
что не даёт скоупу расползтись на смежные issue (#101, #73, #89/122).
|
||||
- Двунаправленные ссылки issue ↔ ТЗ на месте (`docs/specs/README.md`
|
||||
строка 115, тело ТЗ строка 3).
|
||||
|
||||
## Чего не проверял
|
||||
|
||||
- **Код-гейты `typecheck`/`test`/`build`/`check-docs` не прогонялись** —
|
||||
диф этого раунда состоит только из `docs/specs/082-smooth-zoom.md`
|
||||
(класс C), продуктового кода/тестов нет, прогонять нечего. Это
|
||||
сознательное решение по объёму, а не пропуск: `git diff --stat`
|
||||
выше подтверждает единственный изменённый файл.
|
||||
- Не проверялась реализуемость точной wheel-anchor математики
|
||||
(0.5 CSS px) на реальном RAF-цикле — это вопрос будущей реализации
|
||||
и код-ревью («тест умеет падать»), не спецификации: ТЗ формулирует
|
||||
требование измеримо (AC4, unit на presented-state retarget), способ
|
||||
доказательства назван.
|
||||
- Не запускал browser/perf/golden-смоки — на этой стадии кода нет,
|
||||
им неоткуда взяться; они будут частью реализации (§18) и предметом
|
||||
код-ревью.
|
||||
- Не проверял детально `iso-projection.ts` построчно на предмет
|
||||
скрытых зависимостей от zoom помимо общего `_view`/`_zoom`
|
||||
состояния — ограничился подтверждением, что модуль не содержит
|
||||
собственных ссылок на `viewBox`/`zoom` (grep пуст), что достаточно
|
||||
для вывода «камера едина для flat/iso» из §9/AC10.
|
||||
|
||||
## Вердикт
|
||||
|
||||
ТЗ полное, разделы §7.1 закрыты, критерии приёмки однозначны и
|
||||
снабжены способом доказательства, продуктовых вопросов к владельцу не
|
||||
осталось, фактические утверждения о текущем коде и канонических
|
||||
документах проверены и точны. Блокирующих находок нет.
|
||||
|
||||
**Вердикт: зелёный · заход r1 · блокирующих циклов 0/4 · High: 0 ·
|
||||
Medium: 0 → в задаче**
|
||||
@@ -0,0 +1,402 @@
|
||||
# Issue #82 — плавное масштабирование плана
|
||||
|
||||
- **Issue:** https://github.com/Matysh/houseplan-card/issues/82
|
||||
- **Актуализировано:** 2026-08-30 для `dev` / v1.69.0
|
||||
- **Приоритет:** P2
|
||||
- **Тип:** feature / polish, полный трек
|
||||
- **Пользовательское изменение:** да
|
||||
|
||||
## 1. Сценарий и персона
|
||||
|
||||
Житель или гость рассматривает дом в View либо kiosk и приближает нужный
|
||||
фрагмент колесом, кнопками или двойным tap. Администратор делает то же в одном
|
||||
из desktop-first редакторов, чтобы точнее разместить объект. В обоих случаях
|
||||
дискретное изменение масштаба сейчас происходит мгновенно, и глаз на короткое
|
||||
время теряет объект и направление движения камеры.
|
||||
|
||||
View и kiosk остаются полностью поддерживаемыми на touch. Редакторы остаются
|
||||
desktop-first по `docs/TOUCH-SUPPORT.md`; задача не расширяет обещание полного
|
||||
touch-редактирования.
|
||||
|
||||
## 2. Что человек увидит до и после
|
||||
|
||||
**До:** колесо, кнопки, «Вписать всё», стрелка возврата к потерянному плану и
|
||||
двойной tap скачком заменяют показанный фрагмент.
|
||||
|
||||
**После:** те же действия за 160–220 мс плавно ведут к тому же конечному
|
||||
фрагменту, а pan и pinch по-прежнему без задержки следуют за рукой.
|
||||
|
||||
## 3. Актуальность и проблема
|
||||
|
||||
Задача полностью актуальна на текущем `dev`:
|
||||
|
||||
- `_zoomAt()` немедленно заменяет `_zoom` и `_view`;
|
||||
- `_onWheel()` и `_stepZoom()` сразу вызывают `_zoomAt()`;
|
||||
- `_resetZoom()` сразу ставит zoom `1` и текущий content fit;
|
||||
- `_fitAll()`, `_fitFar()`, home-arrow и kiosk double-tap проходят через
|
||||
`_resetZoom()`;
|
||||
- `ModeTransitionController` из #101 плавно меняет режимы, но намеренно не
|
||||
обслуживает zoom внутри одного режима;
|
||||
- интерактивная камера находится в основном `houseplan-card`, а lazy
|
||||
`houseplan-editor-runtime` лишь вызывает её контракты. Для View не требуется
|
||||
загружать editor chunk;
|
||||
- flat и isometric уже используют одну текущую `_view`; новый переход обязан
|
||||
работать в координатах активной проекции и не менять их преобразование.
|
||||
|
||||
Проблема относится к частой навигации J1/J2 из `docs/SCOPE.md`. Это не
|
||||
критическая ошибка, но заметный polish: короткое движение сохраняет
|
||||
пространственный контекст и делает дорогой визуально продукт спокойнее.
|
||||
|
||||
## 4. Цели
|
||||
|
||||
1. Плавно анимировать только дискретные пользовательские zoom/fit/reset.
|
||||
2. Объединять rapid wheel в один retargetable переход без очереди.
|
||||
3. Сохранить точную текущую математику zoom, anchor, fit, clamp и projection.
|
||||
4. Оставить pinch и pan прямыми 1:1.
|
||||
5. Согласовать camera transition с #73, #101, warm remount, lazy editor и
|
||||
visual continuity.
|
||||
6. Не перезапускать Glow, device resolution или тяжёлую геометрию на кадрах
|
||||
камеры.
|
||||
|
||||
## 5. Не входит в задачу
|
||||
|
||||
- inertia, kinetic pan, bounce и overscroll;
|
||||
- плавный pan как самостоятельная функция;
|
||||
- focus-on-room и другие новые команды камеры;
|
||||
- анимация пространства, flat ↔ isometric, View ↔ editor, initial mount,
|
||||
resume или reconnect;
|
||||
- изменение `MIN_ZOOM`, `ZOOM_MAX`, `PAN_SLACK`, content frame либо outlier
|
||||
policy;
|
||||
- изменение относительных размеров marker, furniture strokes или подписей;
|
||||
- настройка duration/easing пользователем;
|
||||
- новый storage/config/backend формат;
|
||||
- изменение визуальной семантики Glow или degradation изображения на время
|
||||
zoom;
|
||||
- интерактивный zoom в `houseplan-space-card`: у static card его нет.
|
||||
|
||||
## 6. Матрица поведения
|
||||
|
||||
| Источник | Результат | Якорь | Длительность |
|
||||
| --- | --- | --- | --- |
|
||||
| Кнопки `−` / `+` | один плавный zoom step | центр stage | 180 мс |
|
||||
| Mouse wheel | retargetable zoom | текущая позиция курсора | до 160 мс после последнего input |
|
||||
| Trackpad wheel stream | один непрерывно retargetable tween | текущая позиция курсора | без очереди |
|
||||
| «Вписать всё» / средняя кнопка | к актуальному core frame | центр target frame | 220 мс |
|
||||
| Far hint «Показать» | к frame со всеми outliers | центр target frame | 220 мс |
|
||||
| Home-arrow | к актуальному frame | центр target frame | 220 мс |
|
||||
| Kiosk double-tap | к zoom `1` / fit | центр target frame | 220 мс |
|
||||
| Pinch | без tween | midpoint пальцев | direct |
|
||||
| Pan / swipe | без tween и inertia | pointer | direct |
|
||||
|
||||
Duration — compile-time UI constants. После инструментального измерения их
|
||||
можно унифицировать внутри 160–220 мс без нового продуктового решения, если
|
||||
сохраняется ощущение короткого перехода.
|
||||
|
||||
## 7. Сценарии без анимации
|
||||
|
||||
Viewport применяется атомарно при:
|
||||
|
||||
- initial mount, чтении сохранённого zoom и cold/warm restore;
|
||||
- visibility resume, reconnect и continuity recovery;
|
||||
- смене пространства;
|
||||
- flat ↔ isometric;
|
||||
- View ↔ editor и editor ↔ editor: ими владеет #101;
|
||||
- `ResizeObserver`, изменении toolbar/context-tray height и resize окна;
|
||||
- принятии config/layout revision и изменении content frame;
|
||||
- нулевом или нестабильном stage;
|
||||
- `prefers-reduced-motion: reduce`.
|
||||
|
||||
Эти пути не получают промежуточный fit flash, veil или отложенное проигрывание
|
||||
старого camera target.
|
||||
|
||||
## 8. Единый camera-only controller
|
||||
|
||||
Добавляется pure camera transition controller, отдельный от
|
||||
`ModeTransitionController`, но использующий ту же perceptual easing и тот же
|
||||
принцип одного RAF/token owner.
|
||||
|
||||
```ts
|
||||
interface CameraState {
|
||||
zoom: number;
|
||||
viewBox: { x: number; y: number; w: number; h: number };
|
||||
}
|
||||
|
||||
interface CameraTransition {
|
||||
from: CameraState;
|
||||
to: CameraState;
|
||||
startedAt: number;
|
||||
durationMs: number;
|
||||
reason: 'button' | 'wheel' | 'fit' | 'home' | 'double-tap';
|
||||
}
|
||||
```
|
||||
|
||||
Компонент остаётся единственным владельцем reactive `_zoom` и `_view`.
|
||||
Controller вычисляет представленный кадр и lifecycle, но не знает о Lit,
|
||||
localStorage, режимах или Home Assistant.
|
||||
|
||||
Одновременно действует не более одного владельца камеры:
|
||||
|
||||
- camera transition не запускается во время подготовки/running перехода
|
||||
режима;
|
||||
- начало mode/space/projection/structural transition отменяет camera
|
||||
transition согласно §11;
|
||||
- camera transition не меняет `_modeTransitionVisual`, размеры chrome,
|
||||
background или opacity слоёв;
|
||||
- core View не импортирует lazy editor runtime ради анимации.
|
||||
|
||||
## 9. Target и интерполяция
|
||||
|
||||
Target строится до запуска перехода существующими `fitView`, `_baseVb()`,
|
||||
`MIN_ZOOM`, `ZOOM_MAX`, `_clampView()` и anchor-математикой `_zoomAt()`.
|
||||
Альтернативной формулы fit/clamp у #82 нет.
|
||||
|
||||
- easing соответствует `cubic-bezier(0.2, 0.7, 0.2, 1)` из #101;
|
||||
- zoom интерполируется в log-space;
|
||||
- camera center интерполируется линейно с тем же eased progress;
|
||||
- width/height выводятся из интерполированного zoom и текущего aspect;
|
||||
- промежуточный кадр проходит текущий clamp;
|
||||
- последний кадр присваивает exact target, исключая накопленную ошибку;
|
||||
- NaN, infinity, вырожденный stage или отсутствие RAF ведут к безопасному
|
||||
immediate target, а не к сломанному DOM;
|
||||
- pure helpers принимают управляемый clock/progress для детерминированных
|
||||
unit-тестов.
|
||||
|
||||
Независимый CSS `transform: scale()` запрещён как конечный или временный
|
||||
источник истины: он иначе масштабирует SVG strokes, blur, HTML markers и hit
|
||||
targets и создаёт финальный commit jump.
|
||||
|
||||
## 10. Wheel retargeting и anchor
|
||||
|
||||
Каждое новое wheel-событие:
|
||||
|
||||
1. берёт реально представленный camera state running tween;
|
||||
2. накапливает zoom от предыдущего target, а не от запаздывающего кадра;
|
||||
3. вычисляет world-point под актуальным pointer в представленном viewport;
|
||||
4. строит новый target так, чтобы этот point остался под pointer;
|
||||
5. заменяет transition без queue и второго RAF;
|
||||
6. допускает немедленный разворот направления.
|
||||
|
||||
Без clamp anchor остаётся на месте с ошибкой не более 0.5 CSS px. При clamp
|
||||
смещение допускается только на ограниченной оси. `deltaMode` нормализуется так,
|
||||
чтобы line/page wheel не создавал многосекундную очередь; текущий один event =
|
||||
один factor остаётся совместимым.
|
||||
|
||||
## 11. Прерывания и конкуренция
|
||||
|
||||
- Новый discrete zoom retarget-ит running camera transition.
|
||||
- Pointerdown, который начинает pan, pinch, draw, drag или selection, сначала
|
||||
фиксирует представленный кадр и отменяет tween без скачка.
|
||||
- Pinch всегда использует immediate `_zoomAt()` и не получает post-animation.
|
||||
- Mode/space/projection change, resize, config/layout adoption, continuity
|
||||
recovery и viewport restore отменяют camera transition и выполняют свой
|
||||
существующий атомарный контракт.
|
||||
- Если документ становится hidden, user target коммитится сразу; при возврате
|
||||
старый tween не продолжается.
|
||||
- `disconnectedCallback()` отменяет RAF и очищает transition state.
|
||||
- Изменение media query на reduced motion во время tween коммитит target сразу.
|
||||
- Повторное действие на точном min/max или exact fit — no-op: RAF и запись
|
||||
storage не создаются.
|
||||
- Escape не отменяет самостоятельный wheel/button zoom, если не запускает
|
||||
действие, которое само владеет viewport.
|
||||
|
||||
## 12. Интерактивность и слои
|
||||
|
||||
Во время camera tween stage не получает overlay или `inert`:
|
||||
|
||||
- SVG, HTML markers, room labels, hit targets, Glow и editor overlays читают
|
||||
один представленный `_zoom` / `_view`;
|
||||
- zoom badge показывает текущий нарисованный процент и exact target после
|
||||
settle;
|
||||
- hover, tooltip и selection не отстают от пикселей;
|
||||
- pointerdown продолжает действие из реально показанной камеры;
|
||||
- opening/furniture/draw previews очищаются один раз при zoom-команде либо
|
||||
прямом жесте, а не на каждом RAF;
|
||||
- click-through по прежнему положению объекта невозможен.
|
||||
|
||||
Glow source resolution, opacity, blending, shadows и live fade не меняются.
|
||||
Camera frames не меняют structural fingerprint, config/layout epoch, device
|
||||
graph, wall model, projection cache или Glow source set. Нельзя временно
|
||||
скрывать backdrop, стены, decor, устройства либо эффекты ради скорости.
|
||||
|
||||
## 13. Persistence
|
||||
|
||||
- View записывает итоговый target после settle, один раз на transition.
|
||||
- Retargetable wheel stream также даёт одну финальную запись.
|
||||
- Editor zoom не записывается в View intent.
|
||||
- Формат `LS_ZOOM` и warm viewport memo не меняется.
|
||||
- Structural cancellation не сохраняет stale camera target.
|
||||
- Immediate pinch/pan используют существующий gesture persistence contract;
|
||||
его отдельная оптимизация не входит в #82.
|
||||
- No-op не переписывает localStorage.
|
||||
|
||||
## 14. Модель данных, миграция и compatibility
|
||||
|
||||
Server config, layout, backend API, normalized model и файлы плана не меняются.
|
||||
Новых сохраняемых полей нет, миграция отсутствует. Старые и новые карточки
|
||||
читают один и тот же `LS_ZOOM`; отличие только presentation-time.
|
||||
|
||||
## 15. Touch и accessibility
|
||||
|
||||
- Pinch остаётся без lag и строго следует midpoint.
|
||||
- Kiosk double-tap анимируется только после подтверждённого single-pointer
|
||||
gesture и не конкурирует с pinch/swipe lock.
|
||||
- `prefers-reduced-motion: reduce`, включая его изменение во время перехода,
|
||||
всегда ведёт к immediate exact target.
|
||||
- Keyboard activation кнопок использует тот же transition path.
|
||||
- Анимация не переносит DOM focus и не меняет accessible names.
|
||||
- View/kiosk touch smoke release-blocking; touch editors — safety regression
|
||||
check по `docs/TOUCH-SUPPORT.md`.
|
||||
|
||||
## 16. Edge cases
|
||||
|
||||
- точные `MIN_ZOOM = 1/3` и `ZOOM_MAX = 8`;
|
||||
- zoom `1` со смещённым center;
|
||||
- content полностью вне viewport и home-arrow;
|
||||
- core frame против all/outlier frame;
|
||||
- wide, tall, diagonal, line-only и degenerate content;
|
||||
- пустое пространство с fallback `view_box`;
|
||||
- rapid wheel в обе стороны и меняющийся anchor;
|
||||
- wheel + pointerdown в одном frame;
|
||||
- pinch во время tween;
|
||||
- resize toolbar/stage, mode/space switch и projection switch;
|
||||
- visibility hidden сразу после старта;
|
||||
- flat/isometric, light/dark, kiosk, View и три editor mode;
|
||||
- отсутствующий RAF/performance clock в тестовом окружении.
|
||||
|
||||
## 17. Acceptance criteria и доказательства
|
||||
|
||||
| AC | Критерий | Обязательное доказательство |
|
||||
| --- | --- | --- |
|
||||
| AC1 | `−/+`, wheel, fit, far-fit, home и kiosk double-tap имеют хотя бы один промежуточный camera frame и прежний exact target | Unit + browser smoke |
|
||||
| AC2 | Pinch и pan остаются direct, без lag и post-animation | Browser smoke |
|
||||
| AC3 | Все SVG/HTML/effect/hit слои используют один camera state; видимый объект кликается во время tween | Browser smoke + code review |
|
||||
| AC4 | Rapid wheel использует один RAF, retarget без queue, сохраняет pointer anchor и допускает reversal | Unit + browser smoke |
|
||||
| AC5 | Exact min/max/fit no-op не запускает RAF и не пишет storage | Unit |
|
||||
| AC6 | Pointer, mode, space, projection, resize, adoption, hidden и disconnect завершают/отменяют transition по матрице §11 без jump | Unit + browser smoke |
|
||||
| AC7 | Mount/resume/reconnect/warm restore остаются атомарными и не проигрывают старый target | Browser smoke continuity regression |
|
||||
| AC8 | Reduced motion до и во время tween даёт immediate exact target | Unit + browser smoke |
|
||||
| AC9 | View пишет один итоговый zoom; editor и cancelled structural target не пишут View intent | Unit + browser smoke |
|
||||
| AC10 | Flat и isometric приходят к тем же конечным viewBox/zoom, что до #82 | Unit + browser smoke |
|
||||
| AC11 | Glow source count, opacity/blend, hover, backdrop и device state не меняются из-за camera frame | Browser smoke + performance counters |
|
||||
| AC12 | Disconnect не оставляет RAF, timer или listener | Unit |
|
||||
| AC13 | Heavy flat/isometric/Glow fixtures завершают transition без blank, black, stale frame и без structural rebuild на RAF | Performance profile + screencast smoke |
|
||||
| AC14 | Основной View не запрашивает lazy editor chunk только из-за zoom | Browser network assertion |
|
||||
|
||||
## 18. План автотестов
|
||||
|
||||
### Unit
|
||||
|
||||
Новый `test/viewport-transition.test.mjs` проверяет:
|
||||
|
||||
- easing/interpolation start, mid, end и exact target;
|
||||
- monotonic log zoom и linear center;
|
||||
- anchor preservation, clamp exception и aspect;
|
||||
- retarget from presented state, accumulated target и reversal;
|
||||
- единственный RAF/no queue;
|
||||
- min/max/fit no-op;
|
||||
- reduced-motion/immediate fallback;
|
||||
- cancel, commit-target, hidden и dispose;
|
||||
- invalid/degenerate inputs.
|
||||
|
||||
Существующие `mode-transition.test.mjs` остаются зелёными; общая easing helper
|
||||
не может изменить их согласованные кадры.
|
||||
|
||||
### Browser smoke
|
||||
|
||||
Новый `demo/smoke_smooth_zoom.mjs` на production component проверяет AC1–AC12
|
||||
и AC14. Clock управляется через controller/harness, поэтому assertions не
|
||||
зависят от попадания в случайную wall-clock миллисекунду.
|
||||
|
||||
Регрессии выбираются через `npm run smokes:select`; обязательный минимум:
|
||||
|
||||
- zoom-out / kiosk pan-lock;
|
||||
- #73 visual continuity / warm remount;
|
||||
- #101 View/editor mode transition;
|
||||
- flat/isometric projection switch;
|
||||
- heavy Glow camera path;
|
||||
- opening/furniture preview interruption.
|
||||
|
||||
### Golden и performance
|
||||
|
||||
Final pixels не меняются, поэтому новые golden baselines не принимаются.
|
||||
Плавность доказывается deterministic DOM/screencast smoke с промежуточным
|
||||
кадром. На canonical heavy Glow и large-house flat/isometric profiles:
|
||||
|
||||
- один camera RAF;
|
||||
- ноль device/registry/wall/projection/Glow-source rebuild на frame;
|
||||
- settle не позже duration + двух реально доступных кадров после main-thread
|
||||
stall;
|
||||
- нет blank/black frame и continuity overlay;
|
||||
- действующие bundle и runtime budgets не ухудшаются сверх их допуска.
|
||||
|
||||
## 19. Затронутые модули
|
||||
|
||||
Планируемые точки изменения:
|
||||
|
||||
- `src/viewport-transition.ts` — pure camera controller/helpers;
|
||||
- `src/mode-transition.ts` — только общий экспорт easing primitive, если это
|
||||
позволит избежать расхождения двух одинаковых curves;
|
||||
- `src/houseplan-card.ts` — target calculation, lifecycle, handlers и
|
||||
interruption boundary;
|
||||
- `src/houseplan-editor-runtime.ts` — только host-port/cancellation call, если
|
||||
этого потребует mode switch; бизнес-логика zoom остаётся в core;
|
||||
- unit и browser smoke из §18;
|
||||
- документация и changelog из §21.
|
||||
|
||||
## 20. i18n
|
||||
|
||||
Новых подписей, кнопок и сообщений нет. Новые i18n keys не требуются. Текущие
|
||||
RU/EN/DE/FR accessible names и tooltips остаются неизменными.
|
||||
|
||||
## 21. Release-артефакты
|
||||
|
||||
- `docs/CHANGELOG.md` и `docs/CHANGELOG.ru.md`: короткий user-visible пункт со
|
||||
ссылкой на #82;
|
||||
- `docs/USER-GUIDE.md` и `docs/USER-GUIDE.ru.md`: таблица zoom/gesture и
|
||||
reduced-motion;
|
||||
- `docs/CANVAS.md`: camera-only animator, ownership и interruption contract;
|
||||
- `docs/TESTING.md`: deterministic transition и continuity regressions;
|
||||
- `docs/ARCHITECTURE.md`: один абзац о разделении mode и camera controllers;
|
||||
- `docs/STATUS.md`: обновить feature surface, если текущая политика релизной
|
||||
ветки требует;
|
||||
- docs screenshot fingerprint обновляется штатным CI/accept flow; новые
|
||||
пользовательские screenshots не нужны, поскольку финальный кадр тот же;
|
||||
- перед beta выполняются штатные Full Performance, browser/golden и touch
|
||||
release gates. Golden baseline не переакцептуется.
|
||||
|
||||
## 22. Риски и меры
|
||||
|
||||
| Риск | Мера |
|
||||
| --- | --- |
|
||||
| Два controller одновременно пишут `_view` | строгая взаимная отмена mode/camera ownership |
|
||||
| SVG и HTML расходятся | только reactive `_zoom`/`_view`, без CSS scale |
|
||||
| Wheel ощущается медленным | retarget от presented state и target accumulation |
|
||||
| Pointer выбирает старую позицию | cancel/freeze at presented frame на pointerdown |
|
||||
| Glow/geometry rebuild на каждом RAF | counters в smoke/performance и стабильные fingerprints |
|
||||
| Resume проигрывает старый tween | explicit hidden/disconnect/continuity cancellation |
|
||||
| Lazy editor попадает в View bundle | controller живёт в core, network assertion AC14 |
|
||||
| Isometric camera drift | exact old target и projection regression AC10 |
|
||||
|
||||
## 23. Откат
|
||||
|
||||
Откат возвращает handlers к immediate `_zoomAt()` / `_resetZoom()` и удаляет
|
||||
camera controller. Config, layout, localStorage schema и backend не требуют
|
||||
обратной миграции. Если performance gate выявляет недопустимую стоимость,
|
||||
feature можно целиком откатить одним frontend commit без потери данных.
|
||||
|
||||
## 24. Принятые технические предположения
|
||||
|
||||
Эти решения не являются отдельным продуктовым контрактом и могут быть изменены
|
||||
по ревью:
|
||||
|
||||
- camera controller отделён от `ModeTransitionController`, но curve helper
|
||||
общий;
|
||||
- `_zoom` отражает представленный кадр, отдельное поле controller хранит target
|
||||
для wheel accumulation;
|
||||
- структурная навигация имеет приоритет над camera tween;
|
||||
- current stage aspect authoritative на каждом frame;
|
||||
- финальный target вычисляется существующей камерой до запуска tween;
|
||||
- #152 либо будущий focus-on-room сможет переиспользовать target API, но не
|
||||
входит в реализацию #82.
|
||||
|
||||
Продуктовых вопросов к владельцу нет.
|
||||
@@ -112,6 +112,7 @@ GitHub Issues и GitHub Projects (v2) остаются единственным
|
||||
| [#68](https://github.com/Matysh/houseplan-card/issues/68) Подсказки к настройкам | [068-help-affordance.md](068-help-affordance.md) |
|
||||
| [#74](https://github.com/Matysh/houseplan-card/issues/74) Position-only Undo/Redo устройств | [074-device-position-undo.md](074-device-position-undo.md) |
|
||||
| [#75](https://github.com/Matysh/houseplan-card/issues/75) + [#76](https://github.com/Matysh/houseplan-card/issues/76) Единый поток размещения проёмов | [075-076-opening-placement-flow.md](075-076-opening-placement-flow.md) |
|
||||
| [#82](https://github.com/Matysh/houseplan-card/issues/82) Плавное масштабирование zoom/fit/reset | [082-smooth-zoom.md](082-smooth-zoom.md) |
|
||||
| [#84](https://github.com/Matysh/houseplan-card/issues/84) Источник «Всегда» без сущности HA + [#88](https://github.com/Matysh/houseplan-card/issues/88) ведущая сущность | [084-passive-forced-light-sources.md](084-passive-forced-light-sources.md) |
|
||||
| [#86](https://github.com/Matysh/houseplan-card/issues/86) Подсказки к настройкам, партия 1 | [086-settings-help-content-party1.md](086-settings-help-content-party1.md) |
|
||||
| [#90](https://github.com/Matysh/houseplan-card/issues/90) Управляемый бейдж со значением | [090-device-value-badge.md](090-device-value-badge.md) |
|
||||
|
||||
@@ -293,6 +293,10 @@ import {
|
||||
ModeTransitionController, viewportFromViewBox,
|
||||
type HouseplanMode, type ModeTransitionState, type ModeVisualState, type ModeViewBox,
|
||||
} from './mode-transition';
|
||||
import {
|
||||
CameraTransitionController, cameraTargetAtAnchor, sameCameraState,
|
||||
type CameraState, type CameraTransitionReason, type CameraTransitionState,
|
||||
} from './viewport-transition';
|
||||
import { EditorRuntimeLoader, lazyLoadFailureMessage, type EditorRuntimeLoaderState } from './editor-runtime-loader';
|
||||
import type { BackdropGuardState } from './backdrop-pick';
|
||||
|
||||
@@ -406,6 +410,9 @@ const BOOT_MAX_MS = 1200;
|
||||
/** AUD-1552-02: post-reveal grace during which late chrome shifts glide
|
||||
* (CSS height transition on the stage) instead of snapping. */
|
||||
const BOOT_SOFT_MS = 1500;
|
||||
const CAMERA_BUTTON_MS = 180;
|
||||
const CAMERA_WHEEL_MS = 160;
|
||||
const CAMERA_FIT_MS = 220;
|
||||
/** Numeric editor fields must consume the whole value: `50abc` is invalid,
|
||||
* not a surprisingly accepted 50. Decimal comma remains supported. */
|
||||
const strictNumber = (value: string): number | null => {
|
||||
@@ -1149,6 +1156,7 @@ export class HouseplanCard extends LitElement {
|
||||
private _onMotionChange = (event: MediaQueryListEvent): void => {
|
||||
this._reducedMotion = event.matches;
|
||||
this._cancelDevicePressFeedback();
|
||||
if (event.matches) this._cancelCameraTransition(true);
|
||||
// A preference change to reduced motion is authoritative immediately: do
|
||||
// not leave a 220 ms camera/chrome tween running until its old deadline.
|
||||
if (event.matches && this._modeTransitionPreparing) {
|
||||
@@ -1179,6 +1187,13 @@ export class HouseplanCard extends LitElement {
|
||||
frame: (state) => this._applyModeTransitionFrame(state),
|
||||
settled: (state) => this._settleModeTransition(state),
|
||||
});
|
||||
/** #82: one camera-only transition inside a settled mode. It never owns
|
||||
* chrome/background coordinates — those remain exclusive to #101. */
|
||||
private _cameraTransitionFit: ModeViewBox | null = null;
|
||||
private readonly _cameraTransition = new CameraTransitionController({
|
||||
frame: (state) => this._applyCameraTransitionFrame(state),
|
||||
settled: (state) => this._settleCameraTransition(state),
|
||||
});
|
||||
/** Explicit second-level toolbar group. No current tools are grouped yet;
|
||||
* the shared host/API prevents future editors from inventing dropdowns. */
|
||||
private declare _editorSecondary: import('./editor-secondary').EditorSecondaryController;
|
||||
@@ -1192,6 +1207,72 @@ export class HouseplanCard extends LitElement {
|
||||
return this._modeTransitionPreparing || this._modeTransition.active;
|
||||
}
|
||||
|
||||
private _cameraState(): CameraState {
|
||||
const view = this._viewOr(this._baseVb());
|
||||
return { zoom: this._zoom, viewBox: { ...view } };
|
||||
}
|
||||
|
||||
private _normalizeCameraState(state: CameraState): CameraState {
|
||||
const fit = this._cameraTransitionFit || fitView(this._baseVb(), this._stageAspect());
|
||||
const zoom = Math.min(HouseplanCard.ZOOM_MAX,
|
||||
Math.max(HouseplanCard.ZOOM_MIN, state.zoom));
|
||||
return { zoom, viewBox: this._clampView({ ...state.viewBox }, fit) };
|
||||
}
|
||||
|
||||
private _applyCameraTransitionFrame(state: CameraTransitionState): void {
|
||||
const presented = this._normalizeCameraState(state.presented);
|
||||
// Retargeting must start from the camera that was actually painted, not
|
||||
// from an unclamped interpolation hidden inside the controller.
|
||||
state.presented = presented;
|
||||
this._zoom = presented.zoom;
|
||||
this._view = { ...presented.viewBox };
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private _settleCameraTransition(state: CameraTransitionState): void {
|
||||
const target = this._normalizeCameraState(state.to);
|
||||
this._zoom = target.zoom;
|
||||
this._view = { ...target.viewBox };
|
||||
this._cameraTransitionFit = null;
|
||||
this._saveZoom();
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private _cancelCameraTransition(commitTarget = false): void {
|
||||
this._cameraTransition.cancel(commitTarget);
|
||||
this._cameraTransitionFit = null;
|
||||
}
|
||||
|
||||
/** A discrete camera command may follow a rapid mode click. Finish that
|
||||
* short structural transition first so two controllers never write `_view`
|
||||
* in the same frame. */
|
||||
private _prepareCameraCommand(): void {
|
||||
if (this._modeTransitionBusy) this._cancelModeTransition(true);
|
||||
if (this._tool === 'opening') {
|
||||
this._cursorPt = null;
|
||||
this._clearOpeningPlacement(false);
|
||||
}
|
||||
}
|
||||
|
||||
private _startCameraTransition(
|
||||
target: CameraState,
|
||||
fit: ModeViewBox,
|
||||
reason: CameraTransitionReason,
|
||||
duration: number,
|
||||
): boolean {
|
||||
const current = this._cameraState();
|
||||
const runningTarget = this._cameraTransition.target;
|
||||
if (runningTarget && sameCameraState(runningTarget, target)) return false;
|
||||
if (sameCameraState(current, target)) {
|
||||
this._cancelCameraTransition(false);
|
||||
return false;
|
||||
}
|
||||
this._cameraTransitionFit = { ...fit };
|
||||
this._cameraTransition.start(current, target, reason,
|
||||
this._reducedMotion ? 0 : duration);
|
||||
return true;
|
||||
}
|
||||
|
||||
private _cssColor(value: string | null | undefined, fallback: string): string {
|
||||
const text = String(value || '').trim();
|
||||
if (!text) return fallback;
|
||||
@@ -1317,6 +1398,7 @@ export class HouseplanCard extends LitElement {
|
||||
}
|
||||
|
||||
private _cancelModeTransition(commitTarget = true): void {
|
||||
this._cancelCameraTransition(false);
|
||||
const hadControllerState = !!this._modeTransition.state;
|
||||
this._modeTransitionRequest++;
|
||||
this._modeTransitionPreparing = false;
|
||||
@@ -1466,6 +1548,7 @@ export class HouseplanCard extends LitElement {
|
||||
private _commitSpace(id: string, authority = false): boolean {
|
||||
if (!this._canCommitSpace(id, authority)) return false;
|
||||
if (id !== this._space) {
|
||||
this._cancelCameraTransition(false);
|
||||
this._clearTransientHover(true);
|
||||
this._cancelDevicePressFeedback();
|
||||
this._editorRuntime?._clearFurniturePreview();
|
||||
@@ -2277,6 +2360,7 @@ export class HouseplanCard extends LitElement {
|
||||
if (signal.kind === 'hidden') {
|
||||
this._clearTransientHover(true);
|
||||
this._cancelDevicePressFeedback();
|
||||
this._cancelCameraTransition(true);
|
||||
if (this._modeTransitionPreparing) {
|
||||
// There is no measured controller endpoint to commit yet. Keep the
|
||||
// already queued measurement but force its synchronous atomic path;
|
||||
@@ -2658,6 +2742,8 @@ export class HouseplanCard extends LitElement {
|
||||
this._editorRuntimeLoadingTimer = undefined;
|
||||
this._editorRuntimeLoadingVisible = false;
|
||||
this._modeTransition.dispose();
|
||||
this._cameraTransition.dispose();
|
||||
this._cameraTransitionFit = null;
|
||||
this._modeTransitionVisual = null;
|
||||
this._modeTransitionPreparing = false;
|
||||
this._modeTransitionForceAtomic = false;
|
||||
@@ -4096,6 +4182,10 @@ export class HouseplanCard extends LitElement {
|
||||
this._devicePositionHistory.clear();
|
||||
this._cancelDeviceDrag();
|
||||
this._pendingPhysicalWrites.clear();
|
||||
// #82: a new structural baseline owns the viewport. Freeze the last
|
||||
// painted camera frame before replacing geometry so an obsolete target
|
||||
// cannot settle against the new content frame.
|
||||
this._cancelCameraTransition(false);
|
||||
if (this._serverCfg) this._clearGeometryGesture();
|
||||
this._serverCfg = nextConfig;
|
||||
this._seedDecorStyle(this._serverCfg);
|
||||
@@ -4123,6 +4213,7 @@ export class HouseplanCard extends LitElement {
|
||||
layoutChanged = nextLayoutFingerprint !== (this._layoutContentFingerprint
|
||||
|| contentFingerprint(this._layout));
|
||||
if (layoutChanged) {
|
||||
this._cancelCameraTransition(false);
|
||||
this._devicePositionHistory.clear();
|
||||
this._cancelDeviceDrag();
|
||||
this._layout = nextLayout;
|
||||
@@ -5879,16 +5970,20 @@ export class HouseplanCard extends LitElement {
|
||||
private _fitFar(): void {
|
||||
this._showFar = true;
|
||||
this._frame = null;
|
||||
// The camera command may be a no-op at the fitted target. The hint still
|
||||
// changed state and must disappear without waiting for another render.
|
||||
this.requestUpdate();
|
||||
this._resetZoom();
|
||||
}
|
||||
|
||||
/** «Вписать всё» (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(): void {
|
||||
private _fitAll(reason: 'fit' | 'home' = 'fit'): void {
|
||||
this._showFar = true;
|
||||
this._frame = null;
|
||||
this._resetZoom();
|
||||
this.requestUpdate();
|
||||
this._resetZoom(reason);
|
||||
}
|
||||
|
||||
/** Unobtrusive inline hint: some objects stand an order of magnitude away
|
||||
@@ -5919,7 +6014,7 @@ export class HouseplanCard extends LitElement {
|
||||
const top = 50 + Math.sin(ang) * 38;
|
||||
return html`<button class="homearrow" title=${this._t('canvas.home_tip')}
|
||||
style="left:${left.toFixed(1)}%;top:${top.toFixed(1)}%"
|
||||
@click=${(e: Event) => { e.stopPropagation(); this._fitAll(); }}>
|
||||
@click=${(e: Event) => { e.stopPropagation(); this._fitAll('home'); }}>
|
||||
<ha-icon icon="mdi:arrow-right-thick" style="transform:rotate(${((ang * 180) / Math.PI).toFixed(1)}deg)"></ha-icon>
|
||||
</button>`;
|
||||
}
|
||||
@@ -5991,6 +6086,7 @@ export class HouseplanCard extends LitElement {
|
||||
|
||||
/** Set the zoom (centered on vb point cx,cy, or on the center of the current view). */
|
||||
private _applyView(zoom: number, cx?: number, cy?: number): boolean {
|
||||
this._cancelCameraTransition(false);
|
||||
const vb = this._baseVb();
|
||||
const fit = fitView(vb, this._stageAspect());
|
||||
const z = Math.min(HouseplanCard.ZOOM_MAX, Math.max(HouseplanCard.ZOOM_MIN, zoom));
|
||||
@@ -6105,6 +6201,9 @@ export class HouseplanCard extends LitElement {
|
||||
/** Recompute the view for a new scene size, preserving zoom and center. */
|
||||
private _refitView(): void {
|
||||
if (this._modeTransitionBusy || this._warmModeRequest) return;
|
||||
// Resize is structural: keep the currently painted camera, cancel its old
|
||||
// target, then let the existing refit path own the new stage geometry.
|
||||
this._cancelCameraTransition(false);
|
||||
const stage = this._stageEl;
|
||||
// ResizeObserver may deliver a zero/transitional box while a browser tab
|
||||
// is frozen or while Lovelace replaces the card. Mutating `_view` from
|
||||
@@ -6160,53 +6259,82 @@ export class HouseplanCard extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
/** Change the zoom while keeping the point (sx,sy relative to the scene) in place. */
|
||||
private _zoomAt(sx: number, sy: number, newZoom: number): void {
|
||||
private _cameraTargetAt(
|
||||
sx: number,
|
||||
sy: number,
|
||||
newZoom: number,
|
||||
): { target: CameraState; fit: ModeViewBox } | null {
|
||||
const stage = this._stageEl;
|
||||
if (!stage) return;
|
||||
if (!stage || stage.clientWidth <= 0 || stage.clientHeight <= 0) return null;
|
||||
const vb = this._baseVb();
|
||||
const fit = fitView(vb, this._stageAspect());
|
||||
const z = Math.min(HouseplanCard.ZOOM_MAX, Math.max(HouseplanCard.ZOOM_MIN, newZoom));
|
||||
const target = cameraTargetAtAnchor(
|
||||
this._cameraState(), z, fit,
|
||||
stage.clientWidth, stage.clientHeight, sx, sy,
|
||||
);
|
||||
if (!target) return null;
|
||||
target.viewBox = this._clampView(target.viewBox, fit);
|
||||
return { target, fit };
|
||||
}
|
||||
|
||||
/** Immediate path for direct pinch: keep the point under the fingers. */
|
||||
private _zoomAt(sx: number, sy: number, newZoom: number): void {
|
||||
this._cancelCameraTransition(false);
|
||||
const result = this._cameraTargetAt(sx, sy, newZoom);
|
||||
if (!result) return;
|
||||
if (this._tool === 'opening') {
|
||||
this._cursorPt = null;
|
||||
this._clearOpeningPlacement(false);
|
||||
}
|
||||
const w = stage.clientWidth, h = stage.clientHeight;
|
||||
const pt = this._screenToVb(sx, sy);
|
||||
const nw = fit.w / z, nh = fit.h / z;
|
||||
this._zoom = z;
|
||||
this._view = this._clampView({ x: pt[0] - (sx / w) * nw, y: pt[1] - (sy / h) * nh, w: nw, h: nh }, fit);
|
||||
this._zoom = result.target.zoom;
|
||||
this._view = { ...result.target.viewBox };
|
||||
}
|
||||
|
||||
private _onWheel(ev: WheelEvent): void {
|
||||
this._prepareCameraCommand();
|
||||
const stage = this._stageEl;
|
||||
if (!stage) return;
|
||||
ev.preventDefault();
|
||||
const r = stage.getBoundingClientRect();
|
||||
const factor = ev.deltaY < 0 ? 1.15 : 1 / 1.15;
|
||||
this._zoomAt(ev.clientX - r.left, ev.clientY - r.top, this._zoom * factor);
|
||||
this._saveZoom();
|
||||
const baseZoom = this._cameraTransition.target?.zoom ?? this._zoom;
|
||||
const result = this._cameraTargetAt(
|
||||
ev.clientX - r.left, ev.clientY - r.top, baseZoom * factor,
|
||||
);
|
||||
if (result) this._startCameraTransition(
|
||||
result.target, result.fit, 'wheel', CAMERA_WHEEL_MS,
|
||||
);
|
||||
}
|
||||
|
||||
private _stepZoom(delta: number): void {
|
||||
this._prepareCameraCommand();
|
||||
const stage = this._stageEl;
|
||||
if (!stage) return;
|
||||
this._zoomAt(stage.clientWidth / 2, stage.clientHeight / 2, this._zoom * (delta > 0 ? 1.4 : 1 / 1.4));
|
||||
this._saveZoom();
|
||||
const baseZoom = this._cameraTransition.target?.zoom ?? this._zoom;
|
||||
const result = this._cameraTargetAt(
|
||||
stage.clientWidth / 2,
|
||||
stage.clientHeight / 2,
|
||||
baseZoom * (delta > 0 ? 1.4 : 1 / 1.4),
|
||||
);
|
||||
if (result) this._startCameraTransition(
|
||||
result.target, result.fit, 'button', CAMERA_BUTTON_MS,
|
||||
);
|
||||
}
|
||||
|
||||
/** «Вписать всё» (docs/CANVAS.md §8) — the toolbar's middle button and the
|
||||
* old "reset zoom" in one: frame the content at zoom 1, centred. With
|
||||
* `all` it also takes in the far strays the opening view leaves out. */
|
||||
private _resetZoom(): void {
|
||||
private _resetZoom(reason: 'fit' | 'home' | 'double-tap' = 'fit'): void {
|
||||
this._prepareCameraCommand();
|
||||
const vb = this._baseVb();
|
||||
if (this._tool === 'opening') {
|
||||
this._cursorPt = null;
|
||||
this._clearOpeningPlacement(false);
|
||||
}
|
||||
this._zoom = 1;
|
||||
this._view = fitView(vb, this._stageAspect());
|
||||
this._saveZoom();
|
||||
const fit = fitView(vb, this._stageAspect());
|
||||
this._startCameraTransition(
|
||||
{ zoom: 1, viewBox: { ...fit } },
|
||||
fit,
|
||||
reason,
|
||||
CAMERA_FIT_MS,
|
||||
);
|
||||
}
|
||||
|
||||
/** Save the current space zoom to localStorage (view mode only). */
|
||||
@@ -6229,6 +6357,7 @@ export class HouseplanCard extends LitElement {
|
||||
|
||||
/** Restore the saved space zoom and center the plan. */
|
||||
private _restoreZoom(): void {
|
||||
this._cancelCameraTransition(false);
|
||||
const z = this._zoomBySpace[this._space] || 1;
|
||||
this._zoom = z;
|
||||
const stage = this._stageEl;
|
||||
@@ -6255,6 +6384,7 @@ export class HouseplanCard extends LitElement {
|
||||
}
|
||||
|
||||
private _stagePointerDown(ev: PointerEvent): void {
|
||||
this._cancelCameraTransition(false);
|
||||
if (this._vacFit) return; // no pan/swipe while fitting the robot map
|
||||
// The shared secondary controller owns palette dismissal in window
|
||||
// capture, including the matching synthetic click. Do not duplicate that
|
||||
@@ -6450,7 +6580,7 @@ export class HouseplanCard extends LitElement {
|
||||
// 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();
|
||||
if (now - this._lastTap < 350) this._resetZoom('double-tap');
|
||||
this._lastTap = now;
|
||||
}
|
||||
// The lock is FINAL (audit DEV-1DA1-02). `_stagePointerMove` decided
|
||||
|
||||
@@ -64,7 +64,7 @@ const cubicCoordinate = (t: number, p1: number, p2: number): number => {
|
||||
return 3 * u * u * t * p1 + 3 * u * t * t * p2 + t * t * t;
|
||||
};
|
||||
|
||||
const ease = (p: number): number => {
|
||||
export const easeTransitionProgress = (p: number): number => {
|
||||
// Exact CSS cubic-bezier(0.2, 0.7, 0.2, 1): animation progress is the
|
||||
// curve's x coordinate, so solve x(t) and then evaluate y(t). A bounded
|
||||
// bisection is deterministic, monotonic and negligible beside one render.
|
||||
@@ -126,7 +126,7 @@ export function interpolateModeVisualState(
|
||||
to: ModeVisualState,
|
||||
progress: number,
|
||||
): ModeVisualState {
|
||||
const p = ease(progress);
|
||||
const p = easeTransitionProgress(progress);
|
||||
const stageWidth = lerp(from.stageWidth, to.stageWidth, p);
|
||||
const stageHeight = lerp(from.stageHeight, to.stageHeight, p);
|
||||
// Screen scale interpolates logarithmically, so zoom speed is perceptually
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import { easeTransitionProgress } from './mode-transition';
|
||||
|
||||
export interface CameraViewBox { x: number; y: number; w: number; h: number }
|
||||
|
||||
export interface CameraState {
|
||||
zoom: number;
|
||||
viewBox: CameraViewBox;
|
||||
}
|
||||
|
||||
export type CameraTransitionReason = 'button' | 'wheel' | 'fit' | 'home' | 'double-tap';
|
||||
export type CameraTransitionPhase = 'running' | 'settling';
|
||||
|
||||
export interface CameraTransitionState {
|
||||
token: number;
|
||||
phase: CameraTransitionPhase;
|
||||
from: CameraState;
|
||||
to: CameraState;
|
||||
presented: CameraState;
|
||||
startedAt: number;
|
||||
duration: number;
|
||||
reason: CameraTransitionReason;
|
||||
}
|
||||
|
||||
export interface CameraTransitionHooks {
|
||||
frame: (state: CameraTransitionState) => void;
|
||||
settled: (state: CameraTransitionState) => void;
|
||||
}
|
||||
|
||||
export interface CameraTransitionClock {
|
||||
now: () => number;
|
||||
requestFrame: (callback: FrameRequestCallback) => number | null;
|
||||
cancelFrame: (handle: number) => void;
|
||||
}
|
||||
|
||||
const browserClock: CameraTransitionClock = {
|
||||
now: () => typeof performance !== 'undefined' ? performance.now() : Date.now(),
|
||||
requestFrame: (callback) => typeof requestAnimationFrame === 'function'
|
||||
? requestAnimationFrame(callback) : null,
|
||||
cancelFrame: (handle) => {
|
||||
if (typeof cancelAnimationFrame === 'function') cancelAnimationFrame(handle);
|
||||
},
|
||||
};
|
||||
|
||||
const finite = (value: number): boolean => Number.isFinite(value);
|
||||
|
||||
export function validCameraState(state: CameraState | null | undefined): state is CameraState {
|
||||
return !!state && finite(state.zoom) && state.zoom > 0
|
||||
&& finite(state.viewBox.x) && finite(state.viewBox.y)
|
||||
&& finite(state.viewBox.w) && state.viewBox.w > 0
|
||||
&& finite(state.viewBox.h) && state.viewBox.h > 0;
|
||||
}
|
||||
|
||||
export function cloneCameraState(state: CameraState): CameraState {
|
||||
return { zoom: state.zoom, viewBox: { ...state.viewBox } };
|
||||
}
|
||||
|
||||
export function sameCameraState(a: CameraState, b: CameraState): boolean {
|
||||
return Math.abs(a.zoom - b.zoom) < 1e-9
|
||||
&& Math.abs(a.viewBox.x - b.viewBox.x) < 1e-6
|
||||
&& Math.abs(a.viewBox.y - b.viewBox.y) < 1e-6
|
||||
&& Math.abs(a.viewBox.w - b.viewBox.w) < 1e-6
|
||||
&& Math.abs(a.viewBox.h - b.viewBox.h) < 1e-6;
|
||||
}
|
||||
|
||||
const lerp = (a: number, b: number, progress: number): number => a + (b - a) * progress;
|
||||
const logLerp = (a: number, b: number, progress: number): number =>
|
||||
Math.exp(lerp(Math.log(a), Math.log(b), progress));
|
||||
|
||||
export function interpolateCameraState(
|
||||
from: CameraState,
|
||||
to: CameraState,
|
||||
progress: number,
|
||||
): CameraState {
|
||||
if (!validCameraState(from)) return cloneCameraState(to);
|
||||
if (!validCameraState(to)) return cloneCameraState(from);
|
||||
const p = easeTransitionProgress(progress);
|
||||
if (p <= 0) return cloneCameraState(from);
|
||||
if (p >= 1) return cloneCameraState(to);
|
||||
const fromCenterX = from.viewBox.x + from.viewBox.w / 2;
|
||||
const fromCenterY = from.viewBox.y + from.viewBox.h / 2;
|
||||
const toCenterX = to.viewBox.x + to.viewBox.w / 2;
|
||||
const toCenterY = to.viewBox.y + to.viewBox.h / 2;
|
||||
const w = logLerp(from.viewBox.w, to.viewBox.w, p);
|
||||
const h = logLerp(from.viewBox.h, to.viewBox.h, p);
|
||||
const centerX = lerp(fromCenterX, toCenterX, p);
|
||||
const centerY = lerp(fromCenterY, toCenterY, p);
|
||||
return {
|
||||
zoom: logLerp(from.zoom, to.zoom, p),
|
||||
viewBox: { x: centerX - w / 2, y: centerY - h / 2, w, h },
|
||||
};
|
||||
}
|
||||
|
||||
/** Build an unclamped target which leaves the presented world point under the anchor. */
|
||||
export function cameraTargetAtAnchor(
|
||||
from: CameraState,
|
||||
targetZoom: number,
|
||||
fit: CameraViewBox,
|
||||
stageWidth: number,
|
||||
stageHeight: number,
|
||||
anchorX: number,
|
||||
anchorY: number,
|
||||
): CameraState | null {
|
||||
if (!validCameraState(from) || !validCameraState({ zoom: targetZoom, viewBox: fit })
|
||||
|| !finite(stageWidth) || stageWidth <= 0 || !finite(stageHeight) || stageHeight <= 0
|
||||
|| !finite(anchorX) || !finite(anchorY)) return null;
|
||||
const xRatio = anchorX / stageWidth;
|
||||
const yRatio = anchorY / stageHeight;
|
||||
const worldX = from.viewBox.x + xRatio * from.viewBox.w;
|
||||
const worldY = from.viewBox.y + yRatio * from.viewBox.h;
|
||||
const w = fit.w / targetZoom;
|
||||
const h = fit.h / targetZoom;
|
||||
return {
|
||||
zoom: targetZoom,
|
||||
viewBox: { x: worldX - xRatio * w, y: worldY - yRatio * h, w, h },
|
||||
};
|
||||
}
|
||||
|
||||
/** One token/RAF owner for camera-only transitions inside a settled UI mode. */
|
||||
export class CameraTransitionController {
|
||||
private _token = 0;
|
||||
private _raf: number | null = null;
|
||||
private _state: CameraTransitionState | null = null;
|
||||
|
||||
public constructor(
|
||||
private readonly _hooks: CameraTransitionHooks,
|
||||
private readonly _clock: CameraTransitionClock = browserClock,
|
||||
) {}
|
||||
|
||||
public get state(): CameraTransitionState | null { return this._state; }
|
||||
public get active(): boolean { return this._state?.phase === 'running'; }
|
||||
public get presented(): CameraState | null { return this._state?.presented || null; }
|
||||
public get target(): CameraState | null { return this._state?.to || null; }
|
||||
|
||||
public start(
|
||||
fromInput: CameraState,
|
||||
toInput: CameraState,
|
||||
reason: CameraTransitionReason,
|
||||
duration: number,
|
||||
): number {
|
||||
this.cancel(false);
|
||||
const token = ++this._token;
|
||||
const from = cloneCameraState(fromInput);
|
||||
const to = cloneCameraState(toInput);
|
||||
const canAnimate = duration > 0 && validCameraState(from) && validCameraState(to);
|
||||
const now = this._clock.now();
|
||||
const state: CameraTransitionState = {
|
||||
token,
|
||||
phase: canAnimate ? 'running' : 'settling',
|
||||
from,
|
||||
to,
|
||||
presented: canAnimate ? cloneCameraState(from) : cloneCameraState(to),
|
||||
startedAt: now,
|
||||
duration: canAnimate ? duration : 0,
|
||||
reason,
|
||||
};
|
||||
this._state = state;
|
||||
this._hooks.frame(state);
|
||||
if (!canAnimate) {
|
||||
this._settle(token);
|
||||
return token;
|
||||
}
|
||||
const tick = (time: number): void => {
|
||||
const current = this._state;
|
||||
if (!current || current.token !== token) return;
|
||||
const raw = Math.max(0, Math.min(1, (time - current.startedAt) / current.duration));
|
||||
current.presented = raw >= 1
|
||||
? cloneCameraState(current.to)
|
||||
: interpolateCameraState(current.from, current.to, raw);
|
||||
current.phase = raw >= 1 ? 'settling' : 'running';
|
||||
this._hooks.frame(current);
|
||||
if (raw >= 1) {
|
||||
this._raf = null;
|
||||
this._settle(token);
|
||||
return;
|
||||
}
|
||||
this._raf = this._clock.requestFrame(tick);
|
||||
if (this._raf === null) this._settle(token);
|
||||
};
|
||||
this._raf = this._clock.requestFrame(tick);
|
||||
if (this._raf === null) {
|
||||
state.phase = 'settling';
|
||||
state.presented = cloneCameraState(to);
|
||||
this._hooks.frame(state);
|
||||
this._settle(token);
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
public cancel(commitTarget = true): void {
|
||||
if (this._raf !== null) this._clock.cancelFrame(this._raf);
|
||||
this._raf = null;
|
||||
const state = this._state;
|
||||
this._state = null;
|
||||
this._token++;
|
||||
if (!state || !commitTarget) return;
|
||||
state.phase = 'settling';
|
||||
state.presented = cloneCameraState(state.to);
|
||||
this._hooks.frame(state);
|
||||
this._hooks.settled(state);
|
||||
}
|
||||
|
||||
public dispose(): void { this.cancel(false); }
|
||||
|
||||
private _settle(token: number): void {
|
||||
const state = this._state;
|
||||
if (!state || state.token !== token) return;
|
||||
state.phase = 'settling';
|
||||
state.presented = cloneCameraState(state.to);
|
||||
this._hooks.settled(state);
|
||||
if (this._state?.token === token) this._state = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
CameraTransitionController,
|
||||
cameraTargetAtAnchor,
|
||||
interpolateCameraState,
|
||||
sameCameraState,
|
||||
validCameraState,
|
||||
} from '../test-build/viewport-transition.js';
|
||||
|
||||
const camera = (zoom, x, y, w, h) => ({ zoom, viewBox: { x, y, w, h } });
|
||||
|
||||
function fakeClock() {
|
||||
let now = 0;
|
||||
let sequence = 0;
|
||||
const callbacks = new Map();
|
||||
return {
|
||||
clock: {
|
||||
now: () => now,
|
||||
requestFrame: (callback) => { callbacks.set(++sequence, callback); return sequence; },
|
||||
cancelFrame: (handle) => callbacks.delete(handle),
|
||||
},
|
||||
callbacks,
|
||||
step(time) {
|
||||
now = time;
|
||||
const entry = callbacks.entries().next().value;
|
||||
assert.ok(entry, 'one animation frame is scheduled');
|
||||
callbacks.delete(entry[0]);
|
||||
entry[1](time);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('camera interpolation uses perceptual zoom, linear center and exact endpoints', () => {
|
||||
const from = camera(1, 0, 0, 800, 600);
|
||||
const to = camera(4, 500, 350, 200, 150);
|
||||
assert.deepEqual(interpolateCameraState(from, to, 0), from);
|
||||
assert.deepEqual(interpolateCameraState(from, to, 1), to);
|
||||
const mid = interpolateCameraState(from, to, 0.5);
|
||||
assert.ok(mid.zoom > 1 && mid.zoom < 4);
|
||||
assert.ok(mid.viewBox.w < 800 && mid.viewBox.w > 200);
|
||||
assert.equal(mid.viewBox.w / mid.viewBox.h, 4 / 3);
|
||||
assert.ok(mid.viewBox.x > from.viewBox.x && mid.viewBox.x < to.viewBox.x);
|
||||
});
|
||||
|
||||
test('anchor target preserves the presented world point without clamp', () => {
|
||||
const from = camera(1, 100, 40, 800, 600);
|
||||
const fit = { x: 0, y: 0, w: 800, h: 600 };
|
||||
const target = cameraTargetAtAnchor(from, 2, fit, 800, 600, 120, 450);
|
||||
assert.ok(target);
|
||||
const before = [
|
||||
from.viewBox.x + 120 / 800 * from.viewBox.w,
|
||||
from.viewBox.y + 450 / 600 * from.viewBox.h,
|
||||
];
|
||||
const after = [
|
||||
target.viewBox.x + 120 / 800 * target.viewBox.w,
|
||||
target.viewBox.y + 450 / 600 * target.viewBox.h,
|
||||
];
|
||||
assert.ok(Math.abs(before[0] - after[0]) < 1e-9);
|
||||
assert.ok(Math.abs(before[1] - after[1]) < 1e-9);
|
||||
});
|
||||
|
||||
test('controller settles exactly and owns one RAF', () => {
|
||||
const fake = fakeClock();
|
||||
const frames = [];
|
||||
const settled = [];
|
||||
const controller = new CameraTransitionController({
|
||||
frame: (state) => frames.push(state.presented),
|
||||
settled: (state) => settled.push(state.presented),
|
||||
}, fake.clock);
|
||||
const from = camera(1, 0, 0, 800, 600);
|
||||
const to = camera(2, 200, 150, 400, 300);
|
||||
controller.start(from, to, 'button', 100);
|
||||
assert.equal(controller.active, true);
|
||||
assert.equal(fake.callbacks.size, 1);
|
||||
fake.step(50);
|
||||
assert.equal(fake.callbacks.size, 1);
|
||||
assert.ok(frames.at(-1).zoom > 1 && frames.at(-1).zoom < 2);
|
||||
fake.step(100);
|
||||
assert.deepEqual(settled, [to]);
|
||||
assert.equal(controller.active, false);
|
||||
assert.equal(fake.callbacks.size, 0);
|
||||
});
|
||||
|
||||
test('retarget starts from presented state and cancels the obsolete RAF', () => {
|
||||
const fake = fakeClock();
|
||||
const frames = [];
|
||||
const controller = new CameraTransitionController({
|
||||
frame: (state) => frames.push(state.presented),
|
||||
settled: () => {},
|
||||
}, fake.clock);
|
||||
const from = camera(1, 0, 0, 800, 600);
|
||||
const first = camera(2, 200, 150, 400, 300);
|
||||
const reversed = camera(0.75, -133, -100, 1066, 800);
|
||||
controller.start(from, first, 'wheel', 100);
|
||||
fake.step(40);
|
||||
const presented = controller.presented;
|
||||
assert.ok(presented);
|
||||
controller.start(presented, reversed, 'wheel', 100);
|
||||
assert.deepEqual(frames.at(-1), presented);
|
||||
assert.equal(fake.callbacks.size, 1);
|
||||
assert.deepEqual(controller.target, reversed);
|
||||
});
|
||||
|
||||
test('cancel policies either freeze or commit one exact target', () => {
|
||||
const fake = fakeClock();
|
||||
const settled = [];
|
||||
const controller = new CameraTransitionController({
|
||||
frame: () => {},
|
||||
settled: (state) => settled.push(state.presented),
|
||||
}, fake.clock);
|
||||
const from = camera(1, 0, 0, 800, 600);
|
||||
const to = camera(2, 200, 150, 400, 300);
|
||||
controller.start(from, to, 'fit', 100);
|
||||
controller.cancel(false);
|
||||
assert.equal(settled.length, 0);
|
||||
assert.equal(fake.callbacks.size, 0);
|
||||
controller.start(from, to, 'fit', 100);
|
||||
controller.cancel(true);
|
||||
assert.deepEqual(settled, [to]);
|
||||
assert.equal(fake.callbacks.size, 0);
|
||||
});
|
||||
|
||||
test('invalid input and missing RAF settle safely without a loop', () => {
|
||||
const from = camera(1, 0, 0, 800, 600);
|
||||
const to = camera(2, 200, 150, 400, 300);
|
||||
assert.equal(validCameraState(camera(0, 0, 0, 0, 0)), false);
|
||||
assert.equal(cameraTargetAtAnchor(from, 2, to.viewBox, 0, 600, 10, 10), null);
|
||||
const settled = [];
|
||||
const controller = new CameraTransitionController({
|
||||
frame: () => {},
|
||||
settled: (state) => settled.push(state.presented),
|
||||
}, { now: () => 0, requestFrame: () => null, cancelFrame: () => {} });
|
||||
controller.start(from, to, 'button', 180);
|
||||
assert.deepEqual(settled, [to]);
|
||||
assert.equal(controller.active, false);
|
||||
assert.equal(sameCameraState(to, { zoom: 2, viewBox: { ...to.viewBox } }), true);
|
||||
});
|
||||
@@ -25,7 +25,7 @@
|
||||
"src/space-geometry.ts", "src/junction-limits.ts",
|
||||
"src/space-order.ts", "src/card-editor-validation.ts",
|
||||
"src/signing.ts", "src/initial-load.ts", "src/space-model-selection.ts", "src/space-dialog.ts",
|
||||
"src/visual-continuity.ts", "src/mode-transition.ts", "src/editor-runtime-loader.ts", "src/pointer-modality.ts",
|
||||
"src/visual-continuity.ts", "src/mode-transition.ts", "src/viewport-transition.ts", "src/editor-runtime-loader.ts", "src/pointer-modality.ts",
|
||||
"src/render-device-snapshot.ts",
|
||||
"src/command-stack.ts", "src/device-position-history.ts",
|
||||
"src/coordinate-canonicalization.ts",
|
||||
|
||||