feat: add opt-in Glow to space card

Issue: #374
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-08-29 14:46:52 +03:00
parent 8914190c56
commit 0dfc74244b
55 changed files with 12101 additions and 11199 deletions
+21 -1
View File
@@ -27,7 +27,7 @@ jobs:
name: Бенчмарки рендера и геометрии
# Every profile keeps base and candidate sequential on one hosted runner.
# Independent profile pairs run in parallel: cross-profile timing is never
# compared, while serialising all five pairs cannot fit the job timeout.
# compared, while serialising all profile pairs cannot fit the job timeout.
runs-on: ubuntu-latest
timeout-minutes: 60
strategy:
@@ -39,6 +39,8 @@ jobs:
- plan-snap
- blend
- overlay
- space-default
- space-glow
steps:
- name: Check out candidate
uses: actions/checkout@v7
@@ -200,6 +202,18 @@ jobs:
cp ../artifacts/performance/overlay-candidate.json ../artifacts/performance/overlay-baseline.json
fi
;;
space-default)
npm run benchmark:glow -- --profile=large-space-card-default-v1 --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/space-default-baseline.json
npm run benchmark:glow -- --profile=large-space-card-default-v1 --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/space-default-candidate.json
;;
space-glow)
npm run benchmark:glow -- --profile=large-space-card-glow-v1 --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/space-glow-baseline.json
npm run benchmark:glow -- --profile=large-space-card-glow-v1 --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/space-glow-candidate.json
if ! grep -q "light_pools" ../baseline/src/space-card.ts; then
echo "Base predates opt-in static Glow; bootstrap relative baseline, keep absolute gate"
cp ../artifacts/performance/space-glow-candidate.json ../artifacts/performance/space-glow-baseline.json
fi
;;
*)
echo "::error::Unknown performance profile: $PROFILE"
exit 1
@@ -227,6 +241,12 @@ jobs:
overlay)
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-house-glow-overlay.json --baseline=../artifacts/performance/overlay-baseline.json --candidate=../artifacts/performance/overlay-candidate.json --output=../artifacts/performance/overlay-comparison.json
;;
space-default)
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-space-card-default.json --baseline=../artifacts/performance/space-default-baseline.json --candidate=../artifacts/performance/space-default-candidate.json --output=../artifacts/performance/space-default-comparison.json
;;
space-glow)
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-space-card-glow.json --baseline=../artifacts/performance/space-glow-baseline.json --candidate=../artifacts/performance/space-glow-candidate.json --output=../artifacts/performance/space-glow-comparison.json
;;
esac
- name: Upload full performance report
+2
View File
@@ -615,9 +615,11 @@ jobs:
- name: Capture the heaviest Glow state
run: |
npm run benchmark:glow -- --profile=large-house-glow-overlay-v1 --variants=60 --samples=3 --warmups=1 --output=artifacts/performance-smoke/candidate.json
npm run benchmark:glow -- --profile=large-space-card-glow-v1 --variants=60 --samples=3 --warmups=1 --output=artifacts/performance-smoke/space-candidate.json
- name: Enforce absolute smoke ceilings
run: |
npm run benchmark:compare -- --absolute-only --budgets=demo/performance/budgets-glow-smoke.json --candidate=artifacts/performance-smoke/candidate.json --output=artifacts/performance-smoke/comparison.json
npm run benchmark:compare -- --absolute-only --budgets=demo/performance/budgets-space-glow-smoke.json --candidate=artifacts/performance-smoke/space-candidate.json --output=artifacts/performance-smoke/space-comparison.json
# #330 AC7: перф-контракт ограничений стыков — быстрый (без браузера),
# ловит возврат квадратичных путей в оба зеркала прямо на пуше.
- name: Бюджеты ограничений стыков (#330)
@@ -1,125 +1,125 @@
{
"schema": 1,
"fingerprint": "0f7edde68a64b82e24b51acbb2ba3050dbe2684c43fd3cb3428ee481b9de3d0f",
"fingerprint": "b4ba5baf06ec1abf83649f9b5611b92b935e58d54158c6eb4476d412e71f242b",
"entry": "houseplan-card.js",
"initialViewFiles": [
"houseplan-assets/houseplan-card-DVfaouS8.js",
"houseplan-assets/houseplan-card-BGHWJhG-.js",
"houseplan-card.js"
],
"initialViewGzipBytes": 273927,
"initialViewGzipBytes": 275795,
"lazyFiles": [
"houseplan-assets/backdrop-pick-DIYZEnDX.js",
"houseplan-assets/de-BNdN-RRm.js",
"houseplan-assets/editor-D4sIJVSN.js",
"houseplan-assets/fr-Ct3lI4wy.js",
"houseplan-assets/houseplan-editor-runtime-CdrLJACD.js",
"houseplan-assets/houseplan-onboarding-runtime-l_2tbTu9.js"
"houseplan-assets/backdrop-pick-YagMeews.js",
"houseplan-assets/de-BgnDBk9_.js",
"houseplan-assets/editor-B-i7d_fa.js",
"houseplan-assets/fr-CHRDAlq_.js",
"houseplan-assets/houseplan-editor-runtime-CiHb9hUW.js",
"houseplan-assets/houseplan-onboarding-runtime-asE73Ooz.js"
],
"lazyGzipBytes": 191420,
"lazyGzipBytes": 191494,
"lazyEditorFiles": [
"houseplan-assets/backdrop-pick-DIYZEnDX.js",
"houseplan-assets/editor-D4sIJVSN.js",
"houseplan-assets/houseplan-editor-runtime-CdrLJACD.js"
"houseplan-assets/backdrop-pick-YagMeews.js",
"houseplan-assets/editor-B-i7d_fa.js",
"houseplan-assets/houseplan-editor-runtime-CiHb9hUW.js"
],
"lazyEditorGzipBytes": 139724,
"lazyEditorGzipBytes": 139736,
"lazyOnboardingFiles": [
"houseplan-assets/backdrop-pick-DIYZEnDX.js",
"houseplan-assets/houseplan-onboarding-runtime-l_2tbTu9.js"
"houseplan-assets/backdrop-pick-YagMeews.js",
"houseplan-assets/houseplan-onboarding-runtime-asE73Ooz.js"
],
"lazyOnboardingGzipBytes": 9450,
"lazyOnboardingGzipBytes": 9454,
"lazyLocaleFiles": [
"houseplan-assets/de-BNdN-RRm.js",
"houseplan-assets/fr-Ct3lI4wy.js"
"houseplan-assets/de-BgnDBk9_.js",
"houseplan-assets/fr-CHRDAlq_.js"
],
"lazyLocaleGzipBytes": 45200,
"lazyLocaleGzipBytes": 45262,
"files": [
{
"path": "houseplan-assets/backdrop-pick-DIYZEnDX.js",
"sha256": "36d8b3a260416b62b4374b6919a0351609f305a2a520bdd66730d30b9771b06a",
"path": "houseplan-assets/backdrop-pick-YagMeews.js",
"sha256": "c8b2ce1daed814c2eeb1decbe0829e124e9ab4b7d996836925f85dbf956b319e",
"rawBytes": 6967,
"gzipBytes": 2954,
"gzipBytes": 2958,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-DVfaouS8.js"
"houseplan-assets/houseplan-card-BGHWJhG-.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/de-BNdN-RRm.js",
"sha256": "80b714e7674f479be1ab8f85ec17e5837c5fbb4b443782ceaf09d75dfb0aeff8",
"rawBytes": 74912,
"gzipBytes": 22810,
"path": "houseplan-assets/de-BgnDBk9_.js",
"sha256": "9e3c38ccabd0d892e54e9f67844e2a8aa8258449e58e94aba2dd8eaa3be4a00a",
"rawBytes": 74963,
"gzipBytes": 22841,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/editor-D4sIJVSN.js",
"sha256": "4fae099689ad8b84e00f316674ef4ffa7453b05724c3cc5e4bfd0c93a393aad6",
"path": "houseplan-assets/editor-B-i7d_fa.js",
"sha256": "d97b29999b0e6f071b432c223fe3032027525b64612e901e44644fbd8a3a7af6",
"rawBytes": 3831,
"gzipBytes": 1581,
"gzipBytes": 1586,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-DVfaouS8.js"
"houseplan-assets/houseplan-card-BGHWJhG-.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/fr-Ct3lI4wy.js",
"sha256": "27ce761a57309ee4a21cfed2e0c8dd4923268e397024b1e654313ae88b5a0c94",
"rawBytes": 77034,
"gzipBytes": 22390,
"path": "houseplan-assets/fr-CHRDAlq_.js",
"sha256": "e07dadedfbe7d76eac1b6dccb17b9ab9ae65e7c234f9754c6bd9b36bd6e896c0",
"rawBytes": 77091,
"gzipBytes": 22421,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-card-DVfaouS8.js",
"sha256": "358f2f5044dce46a9ba33638245d94f156b5d5a713149c0af18cd81bf75e5745",
"rawBytes": 964793,
"gzipBytes": 273134,
"path": "houseplan-assets/houseplan-card-BGHWJhG-.js",
"sha256": "141037436a8135e2464bd1146e5eb75111eff7bcaa68fb7e24520eebe4d0c305",
"rawBytes": 971365,
"gzipBytes": 274998,
"isEntry": false,
"imports": [],
"dynamicImports": [
"houseplan-assets/de-BNdN-RRm.js",
"houseplan-assets/editor-D4sIJVSN.js",
"houseplan-assets/fr-Ct3lI4wy.js",
"houseplan-assets/houseplan-editor-runtime-CdrLJACD.js",
"houseplan-assets/houseplan-onboarding-runtime-l_2tbTu9.js"
"houseplan-assets/de-BgnDBk9_.js",
"houseplan-assets/editor-B-i7d_fa.js",
"houseplan-assets/fr-CHRDAlq_.js",
"houseplan-assets/houseplan-editor-runtime-CiHb9hUW.js",
"houseplan-assets/houseplan-onboarding-runtime-asE73Ooz.js"
]
},
{
"path": "houseplan-assets/houseplan-editor-runtime-CdrLJACD.js",
"sha256": "c2da6c50e1d8878574d7c5b9a6e46ee2d3db32dca6ae1c9a0505f873028dec93",
"path": "houseplan-assets/houseplan-editor-runtime-CiHb9hUW.js",
"sha256": "f45742e37f3eaa8176b9001ba33fb601e27eee299ccf3d0f539253a181e578ab",
"rawBytes": 523581,
"gzipBytes": 135189,
"gzipBytes": 135192,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-DIYZEnDX.js",
"houseplan-assets/houseplan-card-DVfaouS8.js"
"houseplan-assets/backdrop-pick-YagMeews.js",
"houseplan-assets/houseplan-card-BGHWJhG-.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-onboarding-runtime-l_2tbTu9.js",
"sha256": "044745db6ea2019526757e130a79a6dbb8003f434d42f99eedffc2a2dd0dcefa",
"path": "houseplan-assets/houseplan-onboarding-runtime-asE73Ooz.js",
"sha256": "536304d3b2877ed85e8afc4518aaf896691bcfe6e8dad5d9fde245f3c3f81f5e",
"rawBytes": 26195,
"gzipBytes": 6496,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-DIYZEnDX.js",
"houseplan-assets/houseplan-card-DVfaouS8.js"
"houseplan-assets/backdrop-pick-YagMeews.js",
"houseplan-assets/houseplan-card-BGHWJhG-.js"
],
"dynamicImports": []
},
{
"path": "houseplan-card.js",
"sha256": "d36cadb50f439e2fe8cd08daf0357a0cc48dd9c2f221f081a7705b7bc6de4384",
"sha256": "4faca52c747736e4e54f85f95acc75af508b63e3ab1820ee4c09fc0ba88f208f",
"rawBytes": 1183,
"gzipBytes": 793,
"gzipBytes": 797,
"isEntry": true,
"imports": [
"houseplan-assets/houseplan-card-DVfaouS8.js"
"houseplan-assets/houseplan-card-BGHWJhG-.js"
],
"dynamicImports": []
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="0f7edde68a64b82e24b51acbb2ba3050dbe2684c43fd3cb3428ee481b9de3d0f";import{c as e,l as o,t,cZ as s,A as a,c_ as i,c$ as l,d0 as n,E as r,b as c}from"./houseplan-card-DVfaouS8.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__="b4ba5baf06ec1abf83649f9b5611b92b935e58d54158c6eb4476d412e71f242b";import{c as e,l as o,t,cZ as s,A as a,c_ as i,c$ as l,d0 as n,E as r,b as c}from"./houseplan-card-BGHWJhG-.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}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="0f7edde68a64b82e24b51acbb2ba3050dbe2684c43fd3cb3428ee481b9de3d0f";import{bT as t,bU as s,bV as e,bW as o,aq as a,bX as l,c0 as i,bY as h,bZ as c,b_ as n,b$ as r,A as p,b as _,cR as d,ap as u,ar as g,cS as m}from"./houseplan-card-DVfaouS8.js";import{i as b,c as $,e as v,r as f,a as y,b as w,s as D,t as S}from"./backdrop-pick-DIYZEnDX.js";const k=1e3,C=t=>{if(!t.trim())return null;const s=Number(t.replace(",","."));return Number.isFinite(s)?s:null},M="0f7edde68a64b82e24b51acbb2ba3050dbe2684c43fd3cb3428ee481b9de3d0f";class F{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)}))}}}}_openSpaceDialog(p,_){if(!this.host._serverStorage||!this.host._serverCfg)return void this.host._showToast(this.host._t("toast.integration_missing"));if("edit"===p){const i=this.host._serverCfg.spaces.find(t=>t.id===_);if(!i)return;const h=t(i),c=i.settings?.custom_fill&&"object"==typeof i.settings.custom_fill?s(i.settings.custom_fill):null,n="none"===h.fill?{...c||e,a:0}:c;return void(this.host._spaceDialog={mode:p,spaceId:_,title:i.title,planUrl:i.plan_url||null,planFile:null,source:i.plan_url?"file":"draw",showBorders:h.showBorders,showNames:h.showNames,zeroWallStyle:l(i),displayTouched:!0,hideDecor:h.hideDecor,hideOpenings:h.hideOpenings,roomColor:h.color,roomOpacity:h.opacity,fillMode:"none"===h.fill?"custom":h.fill,customFill:n,glowEnabled:h.glow,bgColor:h.bgColor,bgMode:"static"===i.settings?.bg_mode||"daynight"===i.settings?.bg_mode?i.settings.bg_mode:null,northDeg:a({},i.settings),sunRays:"boolean"==typeof i.settings?.sun_rays?i.settings.sun_rays:null,tempMin:h.tempMin,tempMax:h.tempMax,showLqi:h.showLqi??this.host._config?.show_signal??!0,cardFontScale:h.cardFontScale,labelTemp:h.labelTemp,labelHum:h.labelHum,labelLqi:h.labelLqi,labelLight:h.labelLight,cellCm:Number(i.cell_cm)>0?Number(i.cell_cm):5,cellCmInput:o(Number(i.cell_cm)>0?Number(i.cell_cm):5,this.host._imperial),cellCmTouched:!1,busy:!1})}const d=i(this.host._imperial);this.host._spaceDialog={mode:p,title:"",planUrl:null,planFile:null,...b(),hideDecor:!1,hideOpenings:!1,zeroWallStyle:"dashed",roomColor:r,roomOpacity:n,fillMode:"custom",customFill:{...e,a:0},glowEnabled:!0,bgColor:null,bgMode:"daynight",northDeg:null,sunRays:null,tempMin:c,tempMax:h,showLqi:this.host._config?.show_signal??!0,cardFontScale:1,labelTemp:!1,labelHum:!1,labelLqi:!1,labelLight:!1,cellCm:d,cellCmInput:o(d,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 $(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 v(e,o.ext,e.name);this.host._spaceDialog&&(this.host._spaceDialog={...this.host._spaceDialog,planFile:a})}_renderBackdropGuard(){return f(this.host,t=>{this.host._spaceDialog&&(this.host._spaceDialog={...this.host._spaceDialog,planFile:t})},()=>{this.host._backdropGuard=null},this.host.hass)??p}_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 _`<div class="savedplans muted">${this.host._t("space.loading")}</div>`;const s=t.saved||[];if(!s.length)return _`<div class="savedplans muted">${this.host._t("space.no_saved")}</div>`;return _`<div class="savedplans">
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="b4ba5baf06ec1abf83649f9b5611b92b935e58d54158c6eb4476d412e71f242b";import{bT as t,bU as s,bV as e,bW as o,aq as a,bX as l,c0 as i,bY as h,bZ as c,b_ as n,b$ as r,A as p,b as _,cR as d,ap as u,ar as g,cS as m}from"./houseplan-card-BGHWJhG-.js";import{i as b,c as $,e as v,r as f,a as y,b as w,s as D,t as S}from"./backdrop-pick-YagMeews.js";const k=1e3,C=t=>{if(!t.trim())return null;const s=Number(t.replace(",","."));return Number.isFinite(s)?s:null},M="b4ba5baf06ec1abf83649f9b5611b92b935e58d54158c6eb4476d412e71f242b";class F{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)}))}}}}_openSpaceDialog(p,_){if(!this.host._serverStorage||!this.host._serverCfg)return void this.host._showToast(this.host._t("toast.integration_missing"));if("edit"===p){const i=this.host._serverCfg.spaces.find(t=>t.id===_);if(!i)return;const h=t(i),c=i.settings?.custom_fill&&"object"==typeof i.settings.custom_fill?s(i.settings.custom_fill):null,n="none"===h.fill?{...c||e,a:0}:c;return void(this.host._spaceDialog={mode:p,spaceId:_,title:i.title,planUrl:i.plan_url||null,planFile:null,source:i.plan_url?"file":"draw",showBorders:h.showBorders,showNames:h.showNames,zeroWallStyle:l(i),displayTouched:!0,hideDecor:h.hideDecor,hideOpenings:h.hideOpenings,roomColor:h.color,roomOpacity:h.opacity,fillMode:"none"===h.fill?"custom":h.fill,customFill:n,glowEnabled:h.glow,bgColor:h.bgColor,bgMode:"static"===i.settings?.bg_mode||"daynight"===i.settings?.bg_mode?i.settings.bg_mode:null,northDeg:a({},i.settings),sunRays:"boolean"==typeof i.settings?.sun_rays?i.settings.sun_rays:null,tempMin:h.tempMin,tempMax:h.tempMax,showLqi:h.showLqi??this.host._config?.show_signal??!0,cardFontScale:h.cardFontScale,labelTemp:h.labelTemp,labelHum:h.labelHum,labelLqi:h.labelLqi,labelLight:h.labelLight,cellCm:Number(i.cell_cm)>0?Number(i.cell_cm):5,cellCmInput:o(Number(i.cell_cm)>0?Number(i.cell_cm):5,this.host._imperial),cellCmTouched:!1,busy:!1})}const d=i(this.host._imperial);this.host._spaceDialog={mode:p,title:"",planUrl:null,planFile:null,...b(),hideDecor:!1,hideOpenings:!1,zeroWallStyle:"dashed",roomColor:r,roomOpacity:n,fillMode:"custom",customFill:{...e,a:0},glowEnabled:!0,bgColor:null,bgMode:"daynight",northDeg:null,sunRays:null,tempMin:c,tempMax:h,showLqi:this.host._config?.show_signal??!0,cardFontScale:1,labelTemp:!1,labelHum:!1,labelLqi:!1,labelLight:!1,cellCm:d,cellCmInput:o(d,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 $(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 v(e,o.ext,e.name);this.host._spaceDialog&&(this.host._spaceDialog={...this.host._spaceDialog,planFile:a})}_renderBackdropGuard(){return f(this.host,t=>{this.host._spaceDialog&&(this.host._spaceDialog={...this.host._spaceDialog,planFile:t})},()=>{this.host._backdropGuard=null},this.host.hass)??p}_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 _`<div class="savedplans muted">${this.host._t("space.loading")}</div>`;const s=t.saved||[];if(!s.length)return _`<div class="savedplans muted">${this.host._t("space.no_saved")}</div>`;return _`<div class="savedplans">
${s.map(s=>{return _`
<div class="savedplan ${s.url===t.planUrl?"cur":""}">
<img src=${this.host._display(s.url)} alt="" loading="lazy" decoding="async" />
@@ -1 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="0f7edde68a64b82e24b51acbb2ba3050dbe2684c43fd3cb3428ee481b9de3d0f";try{await import("./houseplan-assets/houseplan-card-DVfaouS8.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__="b4ba5baf06ec1abf83649f9b5611b92b935e58d54158c6eb4476d412e71f242b";try{await import("./houseplan-assets/houseplan-card-BGHWJhG-.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)}
+33 -11
View File
@@ -7,12 +7,19 @@ import { launch } from './serve.mjs';
import { assertFreshDemoBundle } from './bundle-freshness.mjs';
import { summarizeLongTasks, summarizeTimings } from './performance/evaluate.mjs';
import { makeLargeHouseFixture } from './fixtures/large-house.mjs';
import { assertCardContract, GLOW_CARD_CONTRACT } from './performance/card-contract.mjs';
import {
assertCardContract, GLOW_CARD_CONTRACT, SPACE_GLOW_CARD_CONTRACT,
} from './performance/card-contract.mjs';
const valueArg = (name) => process.argv.find((arg) => arg.startsWith(`--${name}=`))?.slice(name.length + 3);
const profile = valueArg('profile') || 'large-light-blend-v1';
if (!['large-light-blend-v1', 'large-house-glow-overlay-v1'].includes(profile))
if (![
'large-light-blend-v1', 'large-house-glow-overlay-v1',
'large-space-card-default-v1', 'large-space-card-glow-v1',
].includes(profile))
throw new Error(`unknown Glow profile: ${profile}`);
const staticCardProfile = profile.startsWith('large-space-card-');
const staticGlowProfile = profile === 'large-space-card-glow-v1';
const parsedSamples = Number(valueArg('samples'));
const parsedWarmups = Number(valueArg('warmups'));
const samples = Math.max(1, Math.min(20, Number.isFinite(parsedSamples) && parsedSamples > 0 ? parsedSamples : 7));
@@ -196,22 +203,34 @@ try {
config: { unit_system: { length: 'km' } },
});
const cacheSnapshot = (card) => ({
cleanFloor: card._cleanFloorCache?.size ?? 0,
glowClip: card._glowClipCache?.size ?? 0,
wallUnion: card._wallUnionCache ? 1 : 0,
openingTunnel: card._openingTunnelCache ? 1 : 0,
openingWallIndex: card._openingWallIndexCache ? 1 : 0,
...(card._glowRuntimeState ? {
glowClip: card._glowRuntimeState.clipCache?.size ?? 0,
} : {
cleanFloor: card._cleanFloorCache?.size ?? 0,
glowClip: card._glowClipCache?.size ?? 0,
wallUnion: card._wallUnionCache ? 1 : 0,
openingTunnel: card._openingTunnelCache ? 1 : 0,
openingWallIndex: card._openingWallIndexCache ? 1 : 0,
}),
});
window.__card?.remove?.();
localStorage.clear();
const host = document.getElementById('host');
const result = { sample, longTasks: {}, renderCounts: {}, poolCounts: {} };
const card = document.createElement('houseplan-card');
card.setConfig({ type: 'custom:houseplan-card', title: `Glow ${profile}`, icon_size: 2.4 });
const staticCardProfile = profile.startsWith('large-space-card-');
const card = document.createElement(staticCardProfile ? 'houseplan-space-card' : 'houseplan-card');
card.setConfig(staticCardProfile ? {
type: 'custom:houseplan-space-card', space: fixture.config.spaces[0].id,
show_button: false, light_pools: profile === 'large-space-card-glow-v1',
} : {
type: 'custom:houseplan-card', title: `Glow ${profile}`, icon_size: 2.4,
});
host.replaceChildren(card);
card.hass = hassFor(statesFor(1));
await until(() => staticCardProfile
? !!card._snap && card._devices?.length > 0
: card._loadOk && card._devices?.length === fixture.deviceCount);
window.__hpAssertCardContract(card, cardContract);
await until(() => card._loadOk && card._devices?.length === fixture.deviceCount);
if ('_glowScreenBlend' in card) {
const probeDeadline = performance.now() + 2500;
while (!card._glowScreenBlend && performance.now() < probeDeadline)
@@ -259,7 +278,10 @@ try {
result.renderedDevices = card._devices?.length ?? 0;
result.screenBlend = card._glowScreenBlend === true;
return result;
}, { fixture, profile, sample, cardContract: GLOW_CARD_CONTRACT });
}, {
fixture, profile, sample,
cardContract: staticCardProfile ? SPACE_GLOW_CARD_CONTRACT : GLOW_CARD_CONTRACT,
});
const captureStarted = performance.now();
await page.screenshot({ type: 'png' });
row.screenshotCaptureMs = Number((performance.now() - captureStarted).toFixed(2));
+11 -2
View File
@@ -150,8 +150,8 @@ legitimate fixture extension without weakening the separate zero-growth gate.
## Glow profiles
Both Glow profiles run deterministic 1/10/30/60-pool variants at DPR 1 and
Chromium CPU throttling x4, but deliberately exercise different fixtures:
The full-card Glow profiles run deterministic 1/10/30/60-pool variants at DPR
1 and Chromium CPU throttling x4, but deliberately exercise different fixtures:
- `large-light-blend-v1` compares the isolated screen group with the previous
normal-layer implementation on the shared frontend/backend schema fixture
@@ -160,9 +160,18 @@ Chromium CPU throttling x4, but deliberately exercise different fixtures:
independent Glow on the existing 60-room/200-device large-house fixture,
without changing `large-house-v1`.
The same runner also owns the two static-card profiles introduced with #374:
- `large-space-card-default-v1` proves that the default-off card keeps the
historical no-visibility-work path and a zero-entry Glow cache;
- `large-space-card-glow-v1` measures the explicit `light_pools:true` path on
one large static space, including HA ticks, heap/cache growth and capture.
```bash
npm run benchmark:glow -- --profile=large-light-blend-v1 --output=artifacts/performance/glow.json
npm run benchmark:glow -- --profile=large-house-glow-overlay-v1 --output=artifacts/performance/overlay.json
npm run benchmark:glow -- --profile=large-space-card-default-v1 --output=artifacts/performance/space-default.json
npm run benchmark:glow -- --profile=large-space-card-glow-v1 --output=artifacts/performance/space-glow.json
npm run benchmark:glow -- --profile=large-house-glow-overlay-v1 --variants=60 --samples=3 --warmups=1 --output=artifacts/performance-smoke/candidate.json
npm run benchmark:compare -- --absolute-only --budgets=demo/performance/budgets-glow-smoke.json --candidate=artifacts/performance-smoke/candidate.json --output=artifacts/performance-smoke/comparison.json
```
@@ -0,0 +1,26 @@
{
"schema": 1,
"profile": "large-space-card-default-v1",
"minimumSamples": 7,
"timings": {
"stateUpdate1Ms": { "stat": "median", "maxRegressionRatio": 0.5, "noiseAllowanceMs": 50, "hardMaxMs": 750 },
"stateUpdate10Ms": { "stat": "median", "maxRegressionRatio": 0.5, "noiseAllowanceMs": 75, "hardMaxMs": 1100 },
"stateUpdate30Ms": { "stat": "median", "maxRegressionRatio": 0.5, "noiseAllowanceMs": 100, "hardMaxMs": 1500 },
"stateUpdate60Ms": { "stat": "median", "maxRegressionRatio": 0.5, "noiseAllowanceMs": 150, "hardMaxMs": 2200 },
"screenshotCaptureMs": { "stat": "median", "maxRegressionRatio": 0.5, "noiseAllowanceMs": 250, "hardMaxMs": 3200 }
},
"longTasks": {
"maxSingleMs": 2700,
"maxSingleRegressionRatio": 0.5,
"maxSingleNoiseAllowanceMs": 200,
"maxCountP95": 12,
"maxCountRegressionRatio": 0.5,
"countNoiseAllowance": 2,
"maxTotalP95Ms": 5500,
"maxTotalRegressionRatio": 0.5,
"noiseAllowanceMs": 250
},
"heap": { "required": true, "hardMaxGrowthBytes": 67108864, "maxRegressionRatio": 0.75, "noiseAllowanceBytes": 16777216 },
"cacheEntries": { "glowClip": 0 },
"cacheGrowth": { "glowClip": 0 }
}
@@ -0,0 +1,26 @@
{
"schema": 1,
"profile": "large-space-card-glow-v1",
"minimumSamples": 7,
"timings": {
"stateUpdate1Ms": { "stat": "median", "maxRegressionRatio": 0.5, "noiseAllowanceMs": 50, "hardMaxMs": 750 },
"stateUpdate10Ms": { "stat": "median", "maxRegressionRatio": 0.5, "noiseAllowanceMs": 75, "hardMaxMs": 1100 },
"stateUpdate30Ms": { "stat": "median", "maxRegressionRatio": 0.5, "noiseAllowanceMs": 100, "hardMaxMs": 1500 },
"stateUpdate60Ms": { "stat": "median", "maxRegressionRatio": 0.5, "noiseAllowanceMs": 150, "hardMaxMs": 2200 },
"screenshotCaptureMs": { "stat": "median", "maxRegressionRatio": 0.5, "noiseAllowanceMs": 250, "hardMaxMs": 3200 }
},
"longTasks": {
"maxSingleMs": 2700,
"maxSingleRegressionRatio": 0.5,
"maxSingleNoiseAllowanceMs": 200,
"maxCountP95": 12,
"maxCountRegressionRatio": 0.5,
"countNoiseAllowance": 2,
"maxTotalP95Ms": 5500,
"maxTotalRegressionRatio": 0.5,
"noiseAllowanceMs": 250
},
"heap": { "required": true, "hardMaxGrowthBytes": 67108864, "maxRegressionRatio": 0.75, "noiseAllowanceBytes": 16777216 },
"cacheEntries": { "glowClip": 128 },
"cacheGrowth": { "glowClip": 0 }
}
@@ -0,0 +1,17 @@
{
"schema": 1,
"profile": "large-space-card-glow-v1",
"minimumSamples": 3,
"timings": {
"stateUpdate60Ms": { "stat": "median", "hardMaxMs": 2200 },
"screenshotCaptureMs": { "stat": "median", "hardMaxMs": 3200 }
},
"longTasks": {
"maxSingleMs": 2700,
"maxCountP95": 12,
"maxTotalP95Ms": 5500
},
"heap": { "required": true, "hardMaxGrowthBytes": 67108864 },
"cacheEntries": { "glowClip": 128 },
"cacheGrowth": { "glowClip": 0 }
}
+13
View File
@@ -89,6 +89,19 @@ export const GLOW_CARD_CONTRACT = Object.freeze({
}),
});
export const SPACE_GLOW_CARD_CONTRACT = Object.freeze({
label: 'Static-card Glow performance profiles',
methods: Object.freeze([]),
fields: Object.freeze(['_devices', '_loading', '_snap']),
optionalFields: Object.freeze(['_glowRuntimeState', '_glowScreenBlend']),
fieldTypes: Object.freeze({
_devices: 'array',
_loading: 'boolean',
_glowRuntimeState: 'object',
_glowScreenBlend: 'boolean',
}),
});
/** Single fail-fast implementation injected into both browser runners. Keep
* this function self-contained: runners serialize it with `toString()`. */
export function assertCardContract(card, contract) {
+52 -10
View File
@@ -87,20 +87,53 @@ try {
.filter((pool) => pool.hasAttribute('clip-path')).length;
await customElements.whenDefined('houseplan-space-card');
const staticOffCard = document.createElement('houseplan-space-card');
staticOffCard.setConfig({
type: 'custom:houseplan-space-card', space: fixture.config.spaces[0].id,
show_button: false,
});
staticOffCard.hass = hass;
document.body.appendChild(staticOffCard);
await until(() => staticOffCard.renderRoot?.querySelector('.hp-static-stage'));
await staticOffCard.updateComplete;
const staticBase = staticOffCard.renderRoot.querySelector('.glow-base-layer .glow-base');
const staticOffPools = staticOffCard.renderRoot.querySelectorAll('.glow-pool, .glowlayer').length;
const baseParity = !!fullBase && !!staticBase
&& fullBase.getAttribute('fill') === staticBase.getAttribute('fill')
&& fullBase.getAttribute('fill-opacity') === staticBase.getAttribute('fill-opacity');
const staticCard = document.createElement('houseplan-space-card');
staticCard.setConfig({
type: 'custom:houseplan-space-card', space: fixture.config.spaces[0].id,
show_button: false,
show_button: false, light_pools: true,
});
staticCard.hass = hass;
document.body.appendChild(staticCard);
await until(() => staticCard.renderRoot?.querySelector('.hp-static-stage'));
await until(() => staticCard._glowScreenBlend === true, 3000, 'static screen probe');
await until(() => staticCard.renderRoot.querySelectorAll('.glow-pool').length === 60,
10000, 'static Glow pools');
await staticCard.updateComplete;
const staticBase = staticCard.renderRoot.querySelector('.glow-base-layer .glow-base');
const staticPools = staticCard.renderRoot.querySelectorAll('.glow-pool, .glowlayer').length;
const baseParity = !!fullBase && !!staticBase
&& fullBase.getAttribute('fill') === staticBase.getAttribute('fill')
&& fullBase.getAttribute('fill-opacity') === staticBase.getAttribute('fill-opacity');
await frame();
const staticPoolGroup = staticCard.renderRoot.querySelector('.glow-pools');
const staticPoolNodes = [...staticCard.renderRoot.querySelectorAll('.glow-pool')];
const staticClippedPools = staticPoolNodes.filter((pool) => pool.hasAttribute('clip-path')).length;
const poolSnapshot = (root, pool) => {
const clipId = pool?.getAttribute('clip-path')?.match(/^url\(#(.+)\)$/)?.[1] || '';
const clip = [...root.querySelectorAll('clipPath')].find((node) => node.id === clipId);
return {
source: pool?.parentElement?.getAttribute('data-glow-source') || '',
cx: pool?.getAttribute('cx') || '', cy: pool?.getAttribute('cy') || '',
r: pool?.getAttribute('r') || '', parts: pool?.getAttribute('data-lit-parts') || '',
lit: [...(clip?.querySelectorAll('.glow-lit') || [])].map((path) => path.getAttribute('d') || ''),
};
};
const fullFirst = poolSnapshot(card.renderRoot, card.renderRoot.querySelector('.glow-pool'));
const staticFirst = poolSnapshot(staticCard.renderRoot, staticPoolNodes[0]);
const staticPointerEvents = getComputedStyle(
staticCard.renderRoot.querySelector('.hp-static-stage'),
).pointerEvents;
staticOffCard.remove();
staticCard.remove();
// The fixture deliberately has Temperature mode without temperature
@@ -129,7 +162,10 @@ try {
baseLayerOrder: follows(dataTunnel, fullBase)
&& follows(fullBase, baseTunnel) && follows(baseTunnel, poolFrame),
fullBase: !!fullBase, baseTunnel: !!baseTunnel,
staticBase: !!staticBase, staticPools, baseParity,
staticBase: !!staticBase, staticOffPools, baseParity,
staticPools: staticPoolNodes.length, staticClippedPools,
staticBlend: staticPoolGroup?.getAttribute('data-blend'),
staticPointerEvents, fullFirst, staticFirst,
customFill: customRoom?.style.getPropertyValue('--room-fill'),
customOpacity: customRoom?.style.getPropertyValue('--room-fill-op'),
customBase: !!customBase, customBaseTunnel: !!customBaseTunnel,
@@ -170,13 +206,19 @@ try {
if (!close(pixel(out.forward, 3), [106, 123, 140, 255])) throw new Error('non-pool sector/background changed');
if (out.clippedPools !== out.pools) throw new Error(`lost per-source clips: ${out.clippedPools}/${out.pools}`);
if (!out.baseLayerOrder) throw new Error('data/base/tunnel/pool layer order changed');
if (!out.fullBase || !out.baseTunnel || !out.staticBase || out.staticPools !== 0 || !out.baseParity)
throw new Error(`missing-data Glow fallback/parity failed: full=${out.fullBase}, tunnel=${out.baseTunnel}, static=${out.staticBase}, parity=${out.baseParity}`);
if (!out.fullBase || !out.baseTunnel || !out.staticBase || out.staticOffPools !== 0 || !out.baseParity)
throw new Error(`missing-data Glow fallback/parity failed: full=${out.fullBase}, tunnel=${out.baseTunnel}, static=${out.staticBase}, offPools=${out.staticOffPools}, parity=${out.baseParity}`);
if (out.staticPools !== out.pools || out.staticClippedPools !== out.staticPools)
throw new Error(`static pools/clips differ: pools=${out.staticPools}/${out.pools}, clips=${out.staticClippedPools}`);
if (out.staticBlend !== 'screen' || out.staticPointerEvents !== 'none')
throw new Error(`static Glow composition changed: blend=${out.staticBlend}, pointer=${out.staticPointerEvents}`);
if (JSON.stringify(out.staticFirst) !== JSON.stringify(out.fullFirst))
throw new Error(`full/static source geometry differs: ${JSON.stringify(out.fullFirst)} vs ${JSON.stringify(out.staticFirst)}`);
if (out.customFill !== '#486a8f' || Number(out.customOpacity) !== 0.42)
throw new Error(`custom data fill changed: ${out.customFill}/${out.customOpacity}`);
if (out.customBase || out.customBaseTunnel || !out.customLayerOrder)
throw new Error(`data fill was tinted/reordered by Glow base: full=${out.customBase}, tunnel=${out.customBaseTunnel}, order=${out.customLayerOrder}`);
console.log(JSON.stringify({ ok: true, blend: out.blend, pools: out.pools, staticParity: true }));
console.log(JSON.stringify({ ok: true, blend: out.blend, pools: out.pools, staticParity: true, staticPools: out.staticPools }));
} finally {
await browser.close();
}
+57 -57
View File
@@ -1,125 +1,125 @@
{
"schema": 1,
"fingerprint": "0f7edde68a64b82e24b51acbb2ba3050dbe2684c43fd3cb3428ee481b9de3d0f",
"fingerprint": "b4ba5baf06ec1abf83649f9b5611b92b935e58d54158c6eb4476d412e71f242b",
"entry": "houseplan-card.js",
"initialViewFiles": [
"houseplan-assets/houseplan-card-DVfaouS8.js",
"houseplan-assets/houseplan-card-BGHWJhG-.js",
"houseplan-card.js"
],
"initialViewGzipBytes": 273927,
"initialViewGzipBytes": 275795,
"lazyFiles": [
"houseplan-assets/backdrop-pick-DIYZEnDX.js",
"houseplan-assets/de-BNdN-RRm.js",
"houseplan-assets/editor-D4sIJVSN.js",
"houseplan-assets/fr-Ct3lI4wy.js",
"houseplan-assets/houseplan-editor-runtime-CdrLJACD.js",
"houseplan-assets/houseplan-onboarding-runtime-l_2tbTu9.js"
"houseplan-assets/backdrop-pick-YagMeews.js",
"houseplan-assets/de-BgnDBk9_.js",
"houseplan-assets/editor-B-i7d_fa.js",
"houseplan-assets/fr-CHRDAlq_.js",
"houseplan-assets/houseplan-editor-runtime-CiHb9hUW.js",
"houseplan-assets/houseplan-onboarding-runtime-asE73Ooz.js"
],
"lazyGzipBytes": 191420,
"lazyGzipBytes": 191494,
"lazyEditorFiles": [
"houseplan-assets/backdrop-pick-DIYZEnDX.js",
"houseplan-assets/editor-D4sIJVSN.js",
"houseplan-assets/houseplan-editor-runtime-CdrLJACD.js"
"houseplan-assets/backdrop-pick-YagMeews.js",
"houseplan-assets/editor-B-i7d_fa.js",
"houseplan-assets/houseplan-editor-runtime-CiHb9hUW.js"
],
"lazyEditorGzipBytes": 139724,
"lazyEditorGzipBytes": 139736,
"lazyOnboardingFiles": [
"houseplan-assets/backdrop-pick-DIYZEnDX.js",
"houseplan-assets/houseplan-onboarding-runtime-l_2tbTu9.js"
"houseplan-assets/backdrop-pick-YagMeews.js",
"houseplan-assets/houseplan-onboarding-runtime-asE73Ooz.js"
],
"lazyOnboardingGzipBytes": 9450,
"lazyOnboardingGzipBytes": 9454,
"lazyLocaleFiles": [
"houseplan-assets/de-BNdN-RRm.js",
"houseplan-assets/fr-Ct3lI4wy.js"
"houseplan-assets/de-BgnDBk9_.js",
"houseplan-assets/fr-CHRDAlq_.js"
],
"lazyLocaleGzipBytes": 45200,
"lazyLocaleGzipBytes": 45262,
"files": [
{
"path": "houseplan-assets/backdrop-pick-DIYZEnDX.js",
"sha256": "36d8b3a260416b62b4374b6919a0351609f305a2a520bdd66730d30b9771b06a",
"path": "houseplan-assets/backdrop-pick-YagMeews.js",
"sha256": "c8b2ce1daed814c2eeb1decbe0829e124e9ab4b7d996836925f85dbf956b319e",
"rawBytes": 6967,
"gzipBytes": 2954,
"gzipBytes": 2958,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-DVfaouS8.js"
"houseplan-assets/houseplan-card-BGHWJhG-.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/de-BNdN-RRm.js",
"sha256": "80b714e7674f479be1ab8f85ec17e5837c5fbb4b443782ceaf09d75dfb0aeff8",
"rawBytes": 74912,
"gzipBytes": 22810,
"path": "houseplan-assets/de-BgnDBk9_.js",
"sha256": "9e3c38ccabd0d892e54e9f67844e2a8aa8258449e58e94aba2dd8eaa3be4a00a",
"rawBytes": 74963,
"gzipBytes": 22841,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/editor-D4sIJVSN.js",
"sha256": "4fae099689ad8b84e00f316674ef4ffa7453b05724c3cc5e4bfd0c93a393aad6",
"path": "houseplan-assets/editor-B-i7d_fa.js",
"sha256": "d97b29999b0e6f071b432c223fe3032027525b64612e901e44644fbd8a3a7af6",
"rawBytes": 3831,
"gzipBytes": 1581,
"gzipBytes": 1586,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-DVfaouS8.js"
"houseplan-assets/houseplan-card-BGHWJhG-.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/fr-Ct3lI4wy.js",
"sha256": "27ce761a57309ee4a21cfed2e0c8dd4923268e397024b1e654313ae88b5a0c94",
"rawBytes": 77034,
"gzipBytes": 22390,
"path": "houseplan-assets/fr-CHRDAlq_.js",
"sha256": "e07dadedfbe7d76eac1b6dccb17b9ab9ae65e7c234f9754c6bd9b36bd6e896c0",
"rawBytes": 77091,
"gzipBytes": 22421,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-card-DVfaouS8.js",
"sha256": "358f2f5044dce46a9ba33638245d94f156b5d5a713149c0af18cd81bf75e5745",
"rawBytes": 964793,
"gzipBytes": 273134,
"path": "houseplan-assets/houseplan-card-BGHWJhG-.js",
"sha256": "141037436a8135e2464bd1146e5eb75111eff7bcaa68fb7e24520eebe4d0c305",
"rawBytes": 971365,
"gzipBytes": 274998,
"isEntry": false,
"imports": [],
"dynamicImports": [
"houseplan-assets/de-BNdN-RRm.js",
"houseplan-assets/editor-D4sIJVSN.js",
"houseplan-assets/fr-Ct3lI4wy.js",
"houseplan-assets/houseplan-editor-runtime-CdrLJACD.js",
"houseplan-assets/houseplan-onboarding-runtime-l_2tbTu9.js"
"houseplan-assets/de-BgnDBk9_.js",
"houseplan-assets/editor-B-i7d_fa.js",
"houseplan-assets/fr-CHRDAlq_.js",
"houseplan-assets/houseplan-editor-runtime-CiHb9hUW.js",
"houseplan-assets/houseplan-onboarding-runtime-asE73Ooz.js"
]
},
{
"path": "houseplan-assets/houseplan-editor-runtime-CdrLJACD.js",
"sha256": "c2da6c50e1d8878574d7c5b9a6e46ee2d3db32dca6ae1c9a0505f873028dec93",
"path": "houseplan-assets/houseplan-editor-runtime-CiHb9hUW.js",
"sha256": "f45742e37f3eaa8176b9001ba33fb601e27eee299ccf3d0f539253a181e578ab",
"rawBytes": 523581,
"gzipBytes": 135189,
"gzipBytes": 135192,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-DIYZEnDX.js",
"houseplan-assets/houseplan-card-DVfaouS8.js"
"houseplan-assets/backdrop-pick-YagMeews.js",
"houseplan-assets/houseplan-card-BGHWJhG-.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-onboarding-runtime-l_2tbTu9.js",
"sha256": "044745db6ea2019526757e130a79a6dbb8003f434d42f99eedffc2a2dd0dcefa",
"path": "houseplan-assets/houseplan-onboarding-runtime-asE73Ooz.js",
"sha256": "536304d3b2877ed85e8afc4518aaf896691bcfe6e8dad5d9fde245f3c3f81f5e",
"rawBytes": 26195,
"gzipBytes": 6496,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-DIYZEnDX.js",
"houseplan-assets/houseplan-card-DVfaouS8.js"
"houseplan-assets/backdrop-pick-YagMeews.js",
"houseplan-assets/houseplan-card-BGHWJhG-.js"
],
"dynamicImports": []
},
{
"path": "houseplan-card.js",
"sha256": "d36cadb50f439e2fe8cd08daf0357a0cc48dd9c2f221f081a7705b7bc6de4384",
"sha256": "4faca52c747736e4e54f85f95acc75af508b63e3ab1820ee4c09fc0ba88f208f",
"rawBytes": 1183,
"gzipBytes": 793,
"gzipBytes": 797,
"isEntry": true,
"imports": [
"houseplan-assets/houseplan-card-DVfaouS8.js"
"houseplan-assets/houseplan-card-BGHWJhG-.js"
],
"dynamicImports": []
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="0f7edde68a64b82e24b51acbb2ba3050dbe2684c43fd3cb3428ee481b9de3d0f";import{c as e,l as o,t,cZ as s,A as a,c_ as i,c$ as l,d0 as n,E as r,b as c}from"./houseplan-card-DVfaouS8.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__="b4ba5baf06ec1abf83649f9b5611b92b935e58d54158c6eb4476d412e71f242b";import{c as e,l as o,t,cZ as s,A as a,c_ as i,c$ as l,d0 as n,E as r,b as c}from"./houseplan-card-BGHWJhG-.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}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="0f7edde68a64b82e24b51acbb2ba3050dbe2684c43fd3cb3428ee481b9de3d0f";import{bT as t,bU as s,bV as e,bW as o,aq as a,bX as l,c0 as i,bY as h,bZ as c,b_ as n,b$ as r,A as p,b as _,cR as d,ap as u,ar as g,cS as m}from"./houseplan-card-DVfaouS8.js";import{i as b,c as $,e as v,r as f,a as y,b as w,s as D,t as S}from"./backdrop-pick-DIYZEnDX.js";const k=1e3,C=t=>{if(!t.trim())return null;const s=Number(t.replace(",","."));return Number.isFinite(s)?s:null},M="0f7edde68a64b82e24b51acbb2ba3050dbe2684c43fd3cb3428ee481b9de3d0f";class F{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)}))}}}}_openSpaceDialog(p,_){if(!this.host._serverStorage||!this.host._serverCfg)return void this.host._showToast(this.host._t("toast.integration_missing"));if("edit"===p){const i=this.host._serverCfg.spaces.find(t=>t.id===_);if(!i)return;const h=t(i),c=i.settings?.custom_fill&&"object"==typeof i.settings.custom_fill?s(i.settings.custom_fill):null,n="none"===h.fill?{...c||e,a:0}:c;return void(this.host._spaceDialog={mode:p,spaceId:_,title:i.title,planUrl:i.plan_url||null,planFile:null,source:i.plan_url?"file":"draw",showBorders:h.showBorders,showNames:h.showNames,zeroWallStyle:l(i),displayTouched:!0,hideDecor:h.hideDecor,hideOpenings:h.hideOpenings,roomColor:h.color,roomOpacity:h.opacity,fillMode:"none"===h.fill?"custom":h.fill,customFill:n,glowEnabled:h.glow,bgColor:h.bgColor,bgMode:"static"===i.settings?.bg_mode||"daynight"===i.settings?.bg_mode?i.settings.bg_mode:null,northDeg:a({},i.settings),sunRays:"boolean"==typeof i.settings?.sun_rays?i.settings.sun_rays:null,tempMin:h.tempMin,tempMax:h.tempMax,showLqi:h.showLqi??this.host._config?.show_signal??!0,cardFontScale:h.cardFontScale,labelTemp:h.labelTemp,labelHum:h.labelHum,labelLqi:h.labelLqi,labelLight:h.labelLight,cellCm:Number(i.cell_cm)>0?Number(i.cell_cm):5,cellCmInput:o(Number(i.cell_cm)>0?Number(i.cell_cm):5,this.host._imperial),cellCmTouched:!1,busy:!1})}const d=i(this.host._imperial);this.host._spaceDialog={mode:p,title:"",planUrl:null,planFile:null,...b(),hideDecor:!1,hideOpenings:!1,zeroWallStyle:"dashed",roomColor:r,roomOpacity:n,fillMode:"custom",customFill:{...e,a:0},glowEnabled:!0,bgColor:null,bgMode:"daynight",northDeg:null,sunRays:null,tempMin:c,tempMax:h,showLqi:this.host._config?.show_signal??!0,cardFontScale:1,labelTemp:!1,labelHum:!1,labelLqi:!1,labelLight:!1,cellCm:d,cellCmInput:o(d,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 $(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 v(e,o.ext,e.name);this.host._spaceDialog&&(this.host._spaceDialog={...this.host._spaceDialog,planFile:a})}_renderBackdropGuard(){return f(this.host,t=>{this.host._spaceDialog&&(this.host._spaceDialog={...this.host._spaceDialog,planFile:t})},()=>{this.host._backdropGuard=null},this.host.hass)??p}_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 _`<div class="savedplans muted">${this.host._t("space.loading")}</div>`;const s=t.saved||[];if(!s.length)return _`<div class="savedplans muted">${this.host._t("space.no_saved")}</div>`;return _`<div class="savedplans">
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="b4ba5baf06ec1abf83649f9b5611b92b935e58d54158c6eb4476d412e71f242b";import{bT as t,bU as s,bV as e,bW as o,aq as a,bX as l,c0 as i,bY as h,bZ as c,b_ as n,b$ as r,A as p,b as _,cR as d,ap as u,ar as g,cS as m}from"./houseplan-card-BGHWJhG-.js";import{i as b,c as $,e as v,r as f,a as y,b as w,s as D,t as S}from"./backdrop-pick-YagMeews.js";const k=1e3,C=t=>{if(!t.trim())return null;const s=Number(t.replace(",","."));return Number.isFinite(s)?s:null},M="b4ba5baf06ec1abf83649f9b5611b92b935e58d54158c6eb4476d412e71f242b";class F{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)}))}}}}_openSpaceDialog(p,_){if(!this.host._serverStorage||!this.host._serverCfg)return void this.host._showToast(this.host._t("toast.integration_missing"));if("edit"===p){const i=this.host._serverCfg.spaces.find(t=>t.id===_);if(!i)return;const h=t(i),c=i.settings?.custom_fill&&"object"==typeof i.settings.custom_fill?s(i.settings.custom_fill):null,n="none"===h.fill?{...c||e,a:0}:c;return void(this.host._spaceDialog={mode:p,spaceId:_,title:i.title,planUrl:i.plan_url||null,planFile:null,source:i.plan_url?"file":"draw",showBorders:h.showBorders,showNames:h.showNames,zeroWallStyle:l(i),displayTouched:!0,hideDecor:h.hideDecor,hideOpenings:h.hideOpenings,roomColor:h.color,roomOpacity:h.opacity,fillMode:"none"===h.fill?"custom":h.fill,customFill:n,glowEnabled:h.glow,bgColor:h.bgColor,bgMode:"static"===i.settings?.bg_mode||"daynight"===i.settings?.bg_mode?i.settings.bg_mode:null,northDeg:a({},i.settings),sunRays:"boolean"==typeof i.settings?.sun_rays?i.settings.sun_rays:null,tempMin:h.tempMin,tempMax:h.tempMax,showLqi:h.showLqi??this.host._config?.show_signal??!0,cardFontScale:h.cardFontScale,labelTemp:h.labelTemp,labelHum:h.labelHum,labelLqi:h.labelLqi,labelLight:h.labelLight,cellCm:Number(i.cell_cm)>0?Number(i.cell_cm):5,cellCmInput:o(Number(i.cell_cm)>0?Number(i.cell_cm):5,this.host._imperial),cellCmTouched:!1,busy:!1})}const d=i(this.host._imperial);this.host._spaceDialog={mode:p,title:"",planUrl:null,planFile:null,...b(),hideDecor:!1,hideOpenings:!1,zeroWallStyle:"dashed",roomColor:r,roomOpacity:n,fillMode:"custom",customFill:{...e,a:0},glowEnabled:!0,bgColor:null,bgMode:"daynight",northDeg:null,sunRays:null,tempMin:c,tempMax:h,showLqi:this.host._config?.show_signal??!0,cardFontScale:1,labelTemp:!1,labelHum:!1,labelLqi:!1,labelLight:!1,cellCm:d,cellCmInput:o(d,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 $(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 v(e,o.ext,e.name);this.host._spaceDialog&&(this.host._spaceDialog={...this.host._spaceDialog,planFile:a})}_renderBackdropGuard(){return f(this.host,t=>{this.host._spaceDialog&&(this.host._spaceDialog={...this.host._spaceDialog,planFile:t})},()=>{this.host._backdropGuard=null},this.host.hass)??p}_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 _`<div class="savedplans muted">${this.host._t("space.loading")}</div>`;const s=t.saved||[];if(!s.length)return _`<div class="savedplans muted">${this.host._t("space.no_saved")}</div>`;return _`<div class="savedplans">
${s.map(s=>{return _`
<div class="savedplan ${s.url===t.planUrl?"cur":""}">
<img src=${this.host._display(s.url)} alt="" loading="lazy" decoding="async" />
+1 -1
View File
@@ -1 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="0f7edde68a64b82e24b51acbb2ba3050dbe2684c43fd3cb3428ee481b9de3d0f";try{await import("./houseplan-assets/houseplan-card-DVfaouS8.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__="b4ba5baf06ec1abf83649f9b5611b92b935e58d54158c6eb4476d412e71f242b";try{await import("./houseplan-assets/houseplan-card-BGHWJhG-.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)}
+9 -4
View File
@@ -1198,6 +1198,11 @@ Shared, framework-light modules keep the two views from diverging:
use the shared `ResolvedDevicePresentation` and `renderDeviceFace`; optional card settings
can disable ordinary live dressing, temperature or signal without creating another
semantic implementation.
- `src/glow-scene.ts` — one canonical Glow transport/runtime/SVG implementation
shared by the full renderer and the opt-in static adapter. The static card's
public `light_pools` flag defaults to false and gates barrier/visibility work
before it starts; each mounted card owns and disposes its bounded clip cache,
source transitions and timers.
- `src/config-store.ts` — module-level `{config, rev, configFingerprint, layout,
layoutRev, layoutFingerprint}` cache shared by all embedded
cards (dedupes `houseplan/config/get`), seeded synchronously from the full card's
@@ -1244,10 +1249,10 @@ its configured space.
pools → sun/interactive layers. A resolved data/static fill (`lqi`, `light`,
`temp`, `custom`) never receives the dark base, so its exact color and alpha
remain visible; a dynamic mode without usable data or a custom fill with
zero opacity receives the base instead of exposing bright paper. Radial pools stay
independent and continue to render. The static
room card uses the same data/base projection, omits empty base groups, and
deliberately has no live pools.
zero opacity receives the base instead of exposing bright paper. Radial pools
stay independent and continue to render. The static room card uses the same
data/base projection, omits empty base groups, and renders the same live pools
only when its default-off `light_pools` option is enabled.
- **Custom room fill** (#56): `space.settings.custom_fill` is the space color
and `room.settings.custom_fill` is an optional explicit override. The pure
projection is room → space → `{c:'#607d8b',a:.18}` and every read crosses
+6
View File
@@ -2,6 +2,12 @@
## Unreleased
- The read-only `houseplan-space-card` can now opt into the full plan's radial
Glow and wall shadows with `light_pools: true`. It uses the same source
colour, brightness, radius, openings and physical occluders while remaining
fully non-interactive; the option defaults to `false` to preserve the cheap
existing-card path ([#374](https://github.com/Matysh/houseplan-card/issues/374)).
- `houseplan-space-card` with an explicit `title: ""` now removes the empty
top framing strip together with the header, while preserving its side and
bottom spacing ([#372](https://github.com/Matysh/houseplan-card/issues/372)).
+7
View File
@@ -8,6 +8,13 @@
## Не выпущено
- В read-only `houseplan-space-card` теперь можно включить радиальный Glow и
тени от стен как на полном плане: `light_pools: true`. Цвет, яркость, радиус,
проёмы и физические преграды общие с полным рендерером, а схема остаётся
полностью неинтерактивной. По умолчанию опция выключена, чтобы существующие
карточки сохранили дешёвый путь отрисовки
([#374](https://github.com/Matysh/houseplan-card/issues/374)).
- `houseplan-space-card` с явно заданным `title: ""` теперь убирает вместе с
заголовком пустую полосу кадрирования сверху, сохраняя боковые и нижнее поля
([#372](https://github.com/Matysh/houseplan-card/issues/372)).
+11 -13
View File
@@ -248,20 +248,18 @@ The complete UI and runtime truth table lives in
## Which surfaces render pools
Pools — and therefore wall shadows — exist on the **full plan only**:
`houseplan-card` and the geometry editors. `visibilityPolygon()`
(`src/light-visibility.ts`) is imported by those surfaces and by nothing else.
The full `houseplan-card` always renders pools for rooms where Glow is enabled.
`houseplan-space-card` uses the same canonical source projection, barrier scene,
visibility clip and SVG pool renderer when its public `light_pools: true` option
is enabled. It remains off by default: in that path the static card builds no
barrier/visibility geometry, starts no Glow transition timers and paints only
the shared room/data/base projection.
`houseplan-space-card` takes the static path (`renderSpaceStatic()` in
`src/space-render.ts`) and deliberately has no live radial pools; it shares the
room fills and the independent data/base projection with the full plan, so a lit
room is painted flat. Without a pool there is no shape to occlude, so the compact
card shows no wall shadows. This is a cost decision, not an omission: the pool
layer is the heaviest thing the product renders — it owns its own performance
workflow, budgets and a per-run smoke (`large-house-glow-overlay-v1`).
Reported from the field as a bug (#370), because the user guide promised that
markers share everything with the full plan and said nothing about light.
The opt-in remains fully static — `.hp-static-stage` and the Glow layer have
`pointer-events:none`. It is independent from the card's `live_states` marker
dressing. Separate `large-space-card-default-v1` and
`large-space-card-glow-v1` profiles protect the cheap default and the bounded
opt-in path alongside the full-plan profiles.
## Performance
+7
View File
@@ -2141,6 +2141,13 @@ require hands on real hardware — they remain for the human pass.
- [ ] Unknown `space` → tidy error card [manual]
- [ ] `show_button: false` hides the footer
- [ ] Full card honours `#space=<id>` on load and on hashchange; invalid id ignored [manual]
- [ ] Static Glow (#374): omitted/false `light_pools` creates no pool layer,
visibility geometry, cache entries or transition timers; `true` renders
the same source coordinates, radius, colour, brightness, per-source floor
clip, opening transmission and masonry shadows as the full card. The
stage remains `pointer-events:none`, and `live_states:false` does not
disable pools [unit: glow-scene; auto: smoke_glow_blending; performance:
large-space-card-default-v1 + large-space-card-glow-v1].
## Unified device status and pulse activity (#98)
+8 -7
View File
@@ -763,17 +763,18 @@ text sizing. `cycle` enables automatic space changes; interaction pauses it for
hover, drag, more-info or actions. A footer button opens the full plan. Use it
for compact dashboard navigation, not for home control.
**Light is deliberately different here.** The compact card paints the flat fill
of a lit room and the data-mode fills, but it builds no radial light pools from
the lamps. Consequently it draws no wall shadows either: a shadow exists only as
the shape of a pool, and with no pools there is nothing to occlude. The full plan
computes the pools and how walls, openings and columns block them
(`docs/LIGHT.md`); that is the most expensive part of the render, and it is
exactly what the compact card trades away for being cheap.
The compact card keeps radial light pools and wall shadows off by default, so
existing dashboards retain the cheapest render path. Set `light_pools: true`
to show the full plan's Glow: the same source colour, brightness and radius,
additive overlap, passages and shadows from walls, partitions and columns.
This is independent from `live_states`; disabling ordinary live marker dressing
does not disable light transport. The option is intentionally heavier than the
default because every visible source needs clipped floor visibility geometry.
```yaml
type: custom:houseplan-space-card
space: ground
light_pools: true
```
By default the card uses the configured space name as its header. A non-empty
+10 -6
View File
@@ -1484,12 +1484,14 @@ House Plan скрывает только собственную шапку. Ша
план, но не открывают подсказки или more-info. Единственный интерактивный
элемент — нижняя кнопка перехода.
**Свет здесь другой, и это намеренно.** Компактная карточка рисует ровную
заливку освещённой комнаты и заливки data-режимов, но не строит радиальные пулы
света от ламп. Значит, на ней нет и теней от стен: тень существует только как
форма пула, и без пулов затенять нечего. Полный план считает пулы и перекрытие
светом стен, проёмов и колонн (`docs/LIGHT.md`) — это самая дорогая часть
отрисовки, и компактная карточка платит за свою дешевизну именно ею.
Радиальные пулы света и тени от стен по умолчанию выключены, поэтому уже
созданные компактные карточки сохраняют самый дешёвый путь отрисовки. Включите
`light_pools: true`, чтобы получить полный Glow: те же цвет, яркость и радиус
источника, сложение пересекающихся пятен, прохождение через проёмы и тени от
стен, перегородок и колонн. Опция не зависит от `live_states`: отключение
обычного живого оформления маркеров не выключает свет. Включённый режим тяжелее
default-варианта, потому что для каждого видимого источника строится геометрия
доступного ему пола.
```yaml
type: custom:houseplan-space-card
@@ -1502,6 +1504,7 @@ icon_size: 2.5
live_states: true
show_temperature: true
show_signal: true
light_pools: true
```
| Поле | По умолчанию | Назначение |
@@ -1515,6 +1518,7 @@ show_signal: true
| `live_states` | `true` | Рабочие/открытые/недоступные состояния, смена иконок и активность; тревоги видны всегда |
| `show_temperature` | `true` | Компактные температура и влажность у подходящих устройств |
| `show_signal` | `true` | LQI у маркеров, если он доступен |
| `light_pools` | `false` | Радиальные пулы Glow и тени от стен как на полном плане; более тяжёлая отрисовка |
Если название уже показано секцией dashboard, задайте `title: ""` явно. В
этом случае карточка уберёт заголовок и верхнее пустое поле сцены, но сохранит
+34 -23
View File
@@ -1921,13 +1921,13 @@ const MUTANT_DEFINITIONS = [
because: 'a residual per-room geometry fallback must leave one redacted, deduplicated '
+ 'space/room diagnostic instead of silently looking like a broken Glow setting',
patches: [{
file: 'src/houseplan-card.ts',
find: ' console.warn(\n'
+ ' `HOUSEPLAN GLOW GEOMETRY FALLBACK: #218, space ${spaceId}, room ${roomId}, phase ${phase}`,\n'
+ ' );',
replace: ' if (false) console.warn(\n'
+ ' `HOUSEPLAN GLOW GEOMETRY FALLBACK: #218, space ${spaceId}, room ${roomId}, phase ${phase}`,\n'
+ ' );',
file: 'src/glow-scene.ts',
find: ' console.warn(\n'
+ ' `HOUSEPLAN GLOW GEOMETRY FALLBACK: #218, space ${spaceId}, room ${roomId}, phase ${phase}`,\n'
+ ' );',
replace: ' if (false) console.warn(\n'
+ ' `HOUSEPLAN GLOW GEOMETRY FALLBACK: #218, space ${spaceId}, room ${roomId}, phase ${phase}`,\n'
+ ' );',
}],
},
{
@@ -1936,9 +1936,13 @@ const MUTANT_DEFINITIONS = [
because: 'resilient floor clipping must not revive a source embedded in opaque masonry; '
+ 'the existing source guard remains a release-blocking fail-dark boundary',
patches: [{
file: 'src/houseplan-card.ts',
find: ' if (pointInOpaquePlanBody(sourcePoint, masonryGeometry, opaqueBodies)) {',
replace: ' if (false && pointInOpaquePlanBody(sourcePoint, masonryGeometry, opaqueBodies)) {',
file: 'src/glow-scene.ts',
find: ' return pointInOpaquePlanBody(\n'
+ ' [source.x, source.y], scene.masonryGeometry, scene.opaqueBodies,\n'
+ ' );',
replace: ' return false && pointInOpaquePlanBody(\n'
+ ' [source.x, source.y], scene.masonryGeometry, scene.opaqueBodies,\n'
+ ' );',
}],
},
{
@@ -2173,9 +2177,15 @@ const MUTANT_DEFINITIONS = [
because: 'проём, выродившийся в точку, остаётся кладкой — свет перестаёт '
+ 'проходить через дверь; смок обязан это увидеть по освещённому полу за проёмом',
patches: [{
file: 'src/houseplan-card.ts',
find: 'cuts.push([opening.x - dx, opening.y - dy, opening.x + dx, opening.y + dy]);',
replace: 'cuts.push([opening.x, opening.y, opening.x, opening.y]);',
file: 'src/glow-scene.ts',
find: ' outlineCuts.push([\n'
+ ' opening.x - dx, opening.y - dy,\n'
+ ' opening.x + dx, opening.y + dy,\n'
+ ' ]);',
replace: ' outlineCuts.push([\n'
+ ' opening.x, opening.y,\n'
+ ' opening.x, opening.y,\n'
+ ' ]);',
}],
},
{
@@ -2196,11 +2206,11 @@ const MUTANT_DEFINITIONS = [
because: 'физические тела выпали из окклюдеров — колонна перестаёт отбрасывать '
+ 'тень; исторически смок теней был зелёным, пока тени физически не рисовались',
patches: [{
file: 'src/houseplan-card.ts',
// Physical bodies now enter the canonical masonry union. Mutating only
// its legacy fallback is a no-op on every valid thick-wall fixture.
find: 'this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W, lightPhysical,',
replace: 'this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W, [],',
file: 'src/glow-scene.ts',
// Physical bodies now enter the one canonical scene shared by both
// cards. Removing them here must drop column shadows in the smoke.
find: ' const opaqueBodies = input.physicalBodies(partitionCuts, cacheKey);',
replace: ' const opaqueBodies: number[][][] = [];',
}],
},
{
@@ -2233,9 +2243,9 @@ const MUTANT_DEFINITIONS = [
because: 'растушёвка 20 px вместо 2 размывает границу света на полкомнаты; '
+ 'смок читает data-feather-px и обязан отвергнуть значение больше 3',
patches: [{
file: 'src/houseplan-card.ts',
find: 'const GLOW_EDGE_FEATHER_PX = 2;',
replace: 'const GLOW_EDGE_FEATHER_PX = 20;',
file: 'src/glow-scene.ts',
find: 'export const GLOW_EDGE_FEATHER_PX = 2;',
replace: 'export const GLOW_EDGE_FEATHER_PX = 20;',
}],
},
{
@@ -2246,7 +2256,7 @@ const MUTANT_DEFINITIONS = [
+ 'стену в сплошную и обязан увидеть смену освещённости соседней комнаты',
patches: [{
file: 'src/houseplan-card.ts',
find: ' const cacheKey = `${space.id}|${fingerprint}`;',
find: ' const cacheKey = `${space.id}|${revision.fingerprint}`;',
replace: ' const cacheKey = space.id; // mutant: ignore changed geometry',
}],
},
@@ -2565,13 +2575,14 @@ const MUTANT_DEFINITIONS = [
replace: ' spaceModels, roomCenter, defaultPositions, markerPos, labelPos, spaceFrame, iconCqw, NORM_W,',
}, {
file: 'src/space-render.ts',
find: ' ${passageGlowTunnels}\n ${wallUnion',
find: ' ${passageGlowTunnels}\n ${glowPools}\n ${wallUnion',
replace: ' ${passageGlowTunnels}\n'
+ ' ${!space.bg && !disp.showNames ? svg`<g class="room-svg-labels" pointer-events="none">${space.rooms.map((room) => {\n'
+ ' const center = roomCenter(room);\n'
+ ' return svg`<text class="rlabel" data-hp="room-label" data-id=${room.id || nothing}\n'
+ ' data-area=${room.area || nothing} x=${center[0]} y=${center[1]}>${room.name}</text>`;\n'
+ ' })}</g>` : nothing}\n'
+ ' ${glowPools}\n'
+ ' ${wallUnion',
}],
},
+618
View File
@@ -0,0 +1,618 @@
/**
* Canonical Glow transport, source projection and SVG field shared by the
* full plan and the opt-in static space card (#374).
*
* This module deliberately knows neither card class. Callers own configuration
* snapshots and structural caches; the algorithms below own the meaning of a
* light opening, one spatial source and one painted pool.
*/
import { nothing, svg, type TemplateResult } from 'lit';
import { repeat } from 'lit/directives/repeat.js';
import {
resolvedLightSources, selectSpatialGlowSource,
} from './devices';
import {
glowAlpha, isInteriorLightOpeningType, openingLightApertureLength,
openingLightStateSignature, outlineWithout, pointInPolygon,
quantizeOpeningLightAmount, resolveGlowAppearance, roomPoly,
} from './logic';
import {
geometryAllRings, intersectionPaths, pointInOpaquePlanBody,
scalePartitionOpeningCut, type PartitionOpeningCut,
} from './physical-geometry';
import { partitionOpeningCut } from './partition-openings';
import {
geometryRoomOpeningInputs, type GeometryOpeningProjection,
} from './plan-geometry-preflight';
import {
type LightSegment, polygonSegments, splitAtIntersections, visibilityPolygon,
} from './light-visibility';
import {
recutWallBodiesGeometry, wallBodiesGeometry, type WallEntry,
} from './wall-thickness';
import type { DevItem, RoomCfg, SpaceModel } from './types';
import type { VirtualLightSnapshot } from './virtual-light-state';
import { contentFingerprint } from './visual-continuity';
/** 96 steps keep an unobstructed arc below a tenth of a tablet pixel. */
export const GLOW_ARC_STEPS = 96;
/** The visible penumbra is one screen-space hair, not plan-space masonry. */
export const GLOW_EDGE_FEATHER_PX = 2;
/** One calibrated monotonic field per source. */
export const GLOW_FALLOFF: readonly (readonly [number, number])[] = [
[0, 1], [45, 0.88], [70, 0.62], [86, 0.32], [100, 0],
];
export const GLOW_FADE_MS = 500;
export type GlowClipGeometry = { lit: string[] };
export interface GlowCandidate {
key: string;
sourceEid: string;
pos: { x: number; y: number };
radius: number;
appearance: { c: string; alpha: number } | null;
}
export interface GlowSpot {
key: string;
sourceEid: string;
domId: number;
entering: boolean;
leaving: boolean;
pos: { x: number; y: number };
c: string;
alpha: number;
geometry: GlowClipGeometry | null;
r: number;
}
export interface LightRoomPolygon {
room: RoomCfg;
poly: number[][];
}
export interface LightBarrierRevision {
geometryFingerprint: string;
fingerprint: string;
polygons: LightRoomPolygon[];
passageStates: Array<{ opening: GeometryOpeningProjection; amount: number }>;
}
export interface LightBarrierScene {
occluders: LightSegment[];
floor: number[][][];
fingerprint: string;
masonryGeometry: any;
opaqueBodies: number[][][];
}
export interface LightZeroWalls {
contour: number[][];
barriers: number[][];
transmissive: number[][];
}
export interface GlowRuntimeHost {
window: () => Window;
isConnected: () => boolean;
requestUpdate: () => void;
reducedMotion: () => boolean;
}
export interface GlowRuntimeState {
clipCache: Map<string, GlowClipGeometry | null>;
geometryWarnings: Set<string>;
featherUnits: number | null;
renderedSources: Map<string, number>;
lastAppearance: Map<string, { c: string; alpha: number }>;
enteringSources: Set<string>;
enterRafs: Map<string, number>;
fadeTimers: Map<string, number>;
featherSuspendUntil: number;
featherResumeTimer: number;
sourceSeq: number;
}
export function createGlowRuntimeState(): GlowRuntimeState {
return {
clipCache: new Map(),
geometryWarnings: new Set(),
featherUnits: null,
renderedSources: new Map(),
lastAppearance: new Map(),
enteringSources: new Set(),
enterRafs: new Map(),
fadeTimers: new Map(),
featherSuspendUntil: 0,
featherResumeTimer: 0,
sourceSeq: 0,
};
}
export function readGlowClip(
state: GlowRuntimeState, key: string,
): { hit: boolean; value: GlowClipGeometry | null } {
if (!state.clipCache.has(key)) return { hit: false, value: null };
const value = state.clipCache.get(key) ?? null;
state.clipCache.delete(key);
state.clipCache.set(key, value);
return { hit: true, value };
}
export function writeGlowClip(
state: GlowRuntimeState, key: string, value: GlowClipGeometry | null,
limit = 256,
): void {
state.clipCache.delete(key);
state.clipCache.set(key, value);
while (state.clipCache.size > limit) {
const oldest = state.clipCache.keys().next().value;
if (oldest === undefined) break;
state.clipCache.delete(oldest);
}
}
/** The exact spatial-source decision shared by both public cards. */
export function resolveGlowCandidates(input: {
hass: any;
devices: readonly DevItem[];
virtualLights?: VirtualLightSnapshot | null;
spaceId: string;
defaultColor: string;
paletteAlpha: number;
defaultRadiusUnits: number;
cellCm: number;
gridPitch: number;
position: (device: DevItem) => { x: number; y: number };
}): GlowCandidate[] {
const sources = resolvedLightSources(
input.hass, [...input.devices], null, input.virtualLights,
).filter((source) => source.device.space === input.spaceId);
const byDevice = new Map<string, typeof sources>();
for (const source of sources) {
if (!source.device.id) continue;
const current = byDevice.get(source.device.id) || [];
current.push(source);
byDevice.set(source.device.id, current);
}
const result: GlowCandidate[] = [];
for (const device of input.devices) {
if (!device.id || device.space !== input.spaceId) continue;
const source = selectSpatialGlowSource(byDevice.get(device.id) || []);
if (!source) continue;
const appearance = resolveGlowAppearance(
source.passive
? { state: source.on ? 'on' : 'off', attributes: {} }
: input.hass.states[source.eid],
device.marker?.glow_color,
input.defaultColor,
);
const ownCm = Number(device.marker?.glow_radius_cm);
const radius = Number.isFinite(ownCm) && ownCm > 0
? (ownCm / input.cellCm) * input.gridPitch
: input.defaultRadiusUnits;
result.push({
key: `${input.spaceId}|${device.id}`,
sourceEid: source.eid,
pos: input.position(device),
radius,
appearance: appearance
? { c: appearance.c, alpha: glowAlpha(appearance.bri, input.paletteAlpha) }
: null,
});
}
return result;
}
/**
* Resolve the part of a barrier revision that changes with an architectural
* opening. The fingerprint is intentionally available before structural work.
*/
export function resolveLightBarrierRevision(input: {
rawSpaceConfig: any;
space: SpaceModel;
openings: readonly GeometryOpeningProjection[];
cellCm: number;
gridPitch: number;
openingAmount: (opening: GeometryOpeningProjection) => number;
}): LightBarrierRevision {
const geometryFingerprint = contentFingerprint([
input.rawSpaceConfig, input.cellCm, input.gridPitch,
]);
const polygons = input.space.rooms.flatMap((room) => {
const poly = roomPoly(room);
return poly ? [{ room, poly }] : [];
});
const probe = Math.max(
(10 / input.cellCm) * input.gridPitch,
input.gridPitch * 0.5,
);
const onFloor = (point: number[]): boolean =>
polygons.some(({ poly }) => pointInPolygon(point, poly));
const passageStates = input.openings.flatMap((opening) => {
if (!isInteriorLightOpeningType(String(opening.type))) return [];
const rad = (opening.angle * Math.PI) / 180;
const nx = -Math.sin(rad);
const ny = Math.cos(rad);
const interior = onFloor([opening.rx + nx * probe, opening.ry + ny * probe])
&& onFloor([opening.rx - nx * probe, opening.ry - ny * probe]);
return interior ? [{
opening,
amount: quantizeOpeningLightAmount(input.openingAmount(opening)),
}] : [];
});
const openingStateSignature = openingLightStateSignature(
passageStates.map(({ opening, amount }) => ({
id: opening.id,
type: opening.type,
contact: opening.contact,
amount,
})),
);
return {
geometryFingerprint,
fingerprint: contentFingerprint([geometryFingerprint, openingStateSignature]),
polygons,
passageStates,
};
}
/**
* Build the one canonical light transport scene after a caller-owned cache
* miss. Full and static cards may supply different cache owners, never a
* different barrier algorithm.
*/
export function buildLightBarrierScene(input: {
rawSpaceConfig: any;
space: SpaceModel;
revision: LightBarrierRevision;
walls: readonly WallEntry[];
zeroWalls: LightZeroWalls;
wallKeyPitch: number;
cellCm: number;
gridPitch: number;
coordScale: number;
sharedWallGeometry?: any;
physicalBodies: (partitionCuts: PartitionOpeningCut[], cacheKey: string) => number[][][];
}): LightBarrierScene {
const { revision } = input;
const cacheKey = `${input.space.id}|${revision.fingerprint}`;
const outlineCuts: number[][] = [...input.zeroWalls.transmissive];
const passages = revision.passageStates
.filter(({ amount }) => amount > 0)
.map(({ opening, amount }) => ({
...opening,
rlen: openingLightApertureLength(opening.rlen, amount),
}));
const roomPassages = geometryRoomOpeningInputs(
passages,
input.space,
[...input.walls],
input.zeroWalls.contour,
input.wallKeyPitch,
input.cellCm,
input.gridPitch,
input.coordScale,
);
for (const opening of roomPassages) {
const rad = (opening.angle * Math.PI) / 180;
const dx = (Math.cos(rad) * opening.length) / 2;
const dy = (Math.sin(rad) * opening.length) / 2;
outlineCuts.push([
opening.x - dx, opening.y - dy,
opening.x + dx, opening.y + dy,
]);
}
const partitionCuts = revision.passageStates.flatMap(({ opening, amount }) =>
amount > 0 && opening.partitionHost
? [scalePartitionOpeningCut(partitionOpeningCut(opening.partitionHost), amount)]
: []);
const opaqueBodies = input.physicalBodies(partitionCuts, cacheKey);
const occluders: LightSegment[] = [];
const sharedFingerprint = (input.sharedWallGeometry as {
sourceFingerprint?: string;
} | null | undefined)?.sourceFingerprint;
const recut = input.sharedWallGeometry
&& sharedFingerprint === revision.geometryFingerprint
? recutWallBodiesGeometry(input.sharedWallGeometry, roomPassages, opaqueBodies)
: null;
const masonry = recut || (input.walls.length || opaqueBodies.length
? wallBodiesGeometry(
input.space.rooms,
[...input.walls],
input.zeroWalls.contour,
roomPassages,
input.wallKeyPitch,
input.cellCm,
input.gridPitch,
input.coordScale,
opaqueBodies,
)
: null);
if (masonry && (masonry.status === 'ok' || masonry.status === 'degraded-extra')) {
for (const component of masonry.components) {
for (const ring of geometryAllRings(component.geom)) {
occluders.push(...polygonSegments(ring));
}
}
} else {
for (const body of opaqueBodies) occluders.push(...polygonSegments(body));
}
const eps = input.gridPitch * 0.02;
for (const { poly } of revision.polygons) {
const segments = outlineCuts.length
? outlineWithout(poly, outlineCuts, eps)
: polygonSegments(poly);
for (const segment of segments) occluders.push(segment as LightSegment);
}
for (const barrier of input.zeroWalls.barriers) {
occluders.push(barrier as LightSegment);
}
return {
occluders: splitAtIntersections(occluders),
floor: revision.polygons.map(({ poly }) => poly),
fingerprint: revision.fingerprint,
masonryGeometry: masonry && (masonry.status === 'ok' || masonry.status === 'degraded-extra')
? masonry.components.flatMap((component) => component.geom)
: [],
opaqueBodies,
};
}
export function buildGlowClipGeometry(input: {
spaceId: string;
source: { x: number; y: number };
radius: number;
scene: LightBarrierScene;
polygons: readonly LightRoomPolygon[];
onBoundsFailure?: (roomId: string, phase: string) => void;
}): GlowClipGeometry {
const seen = visibilityPolygon(
[input.source.x, input.source.y], input.radius,
input.scene.occluders, GLOW_ARC_STEPS,
);
return {
lit: seen.length >= 3
? intersectionPaths([seen], input.scene.floor, {
onBoundsFailure: ({ boundIndex, phase }) => {
const room = input.polygons[boundIndex]?.room;
input.onBoundsFailure?.(room?.id || `#${boundIndex}`, phase);
},
})
: [],
};
}
export function glowSourceInOpaqueBody(
source: { x: number; y: number }, scene: LightBarrierScene,
): boolean {
return pointInOpaquePlanBody(
[source.x, source.y], scene.masonryGeometry, scene.opaqueBodies,
);
}
function suspendGlowFeather(
state: GlowRuntimeState, host: GlowRuntimeHost,
): void {
if (host.reducedMotion()) return;
const win = host.window();
state.featherSuspendUntil = Math.max(
state.featherSuspendUntil, Date.now() + GLOW_FADE_MS,
);
win.clearTimeout(state.featherResumeTimer);
const resume = () => {
state.featherResumeTimer = 0;
if (Date.now() < state.featherSuspendUntil) {
state.featherResumeTimer = win.setTimeout(
resume, state.featherSuspendUntil - Date.now() + 17,
);
return;
}
state.featherSuspendUntil = 0;
if (host.isConnected()) host.requestUpdate();
};
const delay = Math.max(0, state.featherSuspendUntil - Date.now()) + 17;
state.featherResumeTimer = win.setTimeout(resume, delay);
}
export function transitionGlowSource(
state: GlowRuntimeState,
host: GlowRuntimeHost,
key: string,
active: boolean,
): { domId: number; entering: boolean; leaving: boolean } | null {
const win = host.window();
let domId = state.renderedSources.get(key);
if (active) {
const timer = state.fadeTimers.get(key);
if (timer != null) {
win.clearTimeout(timer);
state.fadeTimers.delete(key);
}
if (domId == null) {
suspendGlowFeather(state, host);
domId = ++state.sourceSeq;
state.renderedSources.set(key, domId);
state.enteringSources.add(key);
const raf = win.requestAnimationFrame(() => {
if (state.enterRafs.get(key) !== raf) return;
state.enterRafs.delete(key);
state.enteringSources.delete(key);
if (host.isConnected()) host.requestUpdate();
});
state.enterRafs.set(key, raf);
}
return { domId, entering: state.enteringSources.has(key), leaving: false };
}
if (domId == null) return null;
const enterRaf = state.enterRafs.get(key);
if (enterRaf != null) win.cancelAnimationFrame(enterRaf);
state.enterRafs.delete(key);
state.enteringSources.delete(key);
if (!state.fadeTimers.has(key)) {
suspendGlowFeather(state, host);
const timer = win.setTimeout(() => {
if (state.fadeTimers.get(key) !== timer) return;
state.fadeTimers.delete(key);
state.renderedSources.delete(key);
state.lastAppearance.delete(key);
if (host.isConnected()) host.requestUpdate();
}, GLOW_FADE_MS + 34);
state.fadeTimers.set(key, timer);
}
return { domId, entering: false, leaving: true };
}
export function forgetGlowSource(
state: GlowRuntimeState, host: GlowRuntimeHost, key: string,
): void {
const win = host.window();
const timer = state.fadeTimers.get(key);
const raf = state.enterRafs.get(key);
if (timer != null) win.clearTimeout(timer);
if (raf != null) win.cancelAnimationFrame(raf);
state.fadeTimers.delete(key);
state.enterRafs.delete(key);
state.enteringSources.delete(key);
state.renderedSources.delete(key);
state.lastAppearance.delete(key);
}
export function forgetGlowSpace(
state: GlowRuntimeState, host: GlowRuntimeHost, spaceId: string,
): void {
const prefix = `${spaceId}|`;
for (const key of [...state.renderedSources.keys()]) {
if (key.startsWith(prefix)) forgetGlowSource(state, host, key);
}
}
export function pruneGlowSources(
state: GlowRuntimeState,
host: GlowRuntimeHost,
spaceId: string,
seen: ReadonlySet<string>,
): void {
const prefix = `${spaceId}|`;
for (const key of [...state.renderedSources.keys()]) {
if (key.startsWith(prefix) && !seen.has(key)) forgetGlowSource(state, host, key);
}
}
export function disposeGlowRuntime(
state: GlowRuntimeState, host: GlowRuntimeHost,
): void {
const win = host.window();
for (const timer of state.fadeTimers.values()) win.clearTimeout(timer);
for (const raf of state.enterRafs.values()) win.cancelAnimationFrame(raf);
win.clearTimeout(state.featherResumeTimer);
state.clipCache.clear();
state.geometryWarnings.clear();
state.fadeTimers.clear();
state.enterRafs.clear();
state.enteringSources.clear();
state.renderedSources.clear();
state.lastAppearance.clear();
state.featherUnits = null;
state.featherSuspendUntil = 0;
state.featherResumeTimer = 0;
state.sourceSeq = 0;
}
export function warnGlowGeometryFallback(
state: GlowRuntimeState,
spaceId: string,
fingerprint: string,
roomId: string,
phase: string,
): void {
const key = `${spaceId}|${fingerprint}|${roomId}`;
if (state.geometryWarnings.has(key)) return;
if (state.geometryWarnings.size >= 128) {
const oldest = state.geometryWarnings.values().next().value;
if (oldest) state.geometryWarnings.delete(oldest);
}
state.geometryWarnings.add(key);
console.warn(
`HOUSEPLAN GLOW GEOMETRY FALLBACK: #218, space ${spaceId}, room ${roomId}, phase ${phase}`,
);
}
export function resolveGlowFeather(
state: GlowRuntimeState,
perUnit: number,
allowRefresh: boolean,
): { feather: number; enabled: boolean } {
const next = GLOW_EDGE_FEATHER_PX / 2 / (perUnit > 0 ? perUnit : 1);
const enabled = allowRefresh && Date.now() >= state.featherSuspendUntil;
if (state.featherUnits == null || enabled) state.featherUnits = next;
return { feather: state.featherUnits ?? next, enabled };
}
/** One shared SVG field: one circle and one clip per source, one layer blur. */
export function renderGlowPools(input: {
spots: readonly GlowSpot[];
enabledClip?: readonly string[] | null;
feather: number;
featherEnabled: boolean;
screenBlend: boolean;
}): TemplateResult {
if (!input.spots.length) return svg`` as unknown as TemplateResult;
const pad = input.feather * 4;
const featherBox = input.spots.reduce((box, spot) => ({
x: Math.min(box.x, spot.pos.x - spot.r - pad),
y: Math.min(box.y, spot.pos.y - spot.r - pad),
maxX: Math.max(box.maxX, spot.pos.x + spot.r + pad),
maxY: Math.max(box.maxY, spot.pos.y + spot.r + pad),
w: 0,
h: 0,
}), { x: Infinity, y: Infinity, maxX: -Infinity, maxY: -Infinity, w: 0, h: 0 });
featherBox.w = featherBox.maxX - featherBox.x;
featherBox.h = featherBox.maxY - featherBox.y;
const enabledClip = input.enabledClip?.length ? input.enabledClip : null;
return svg`<defs>
${repeat(input.spots, (spot) => spot.key, (spot) => {
const id = spot.domId;
return svg`
<radialGradient id="hp-glow-${id}" gradientUnits="userSpaceOnUse"
cx="${spot.pos.x}" cy="${spot.pos.y}" r="${spot.r}">
${GLOW_FALLOFF.map(([offset, scale]) => svg`
<stop offset="${offset}%" stop-color="${spot.c}"
stop-opacity="${(spot.alpha * scale).toFixed(4)}"></stop>`)}
</radialGradient>
${spot.geometry ? svg`
<clipPath id="hp-glowclip-${id}">
<path class="glow-lit" d="${spot.geometry.lit.join(' ')}"
clip-rule="evenodd" fill-rule="evenodd"></path>
</clipPath>` : nothing}`;
})}
${enabledClip ? svg`<clipPath id="hp-glow-enabled">${enabledClip.map((d) => svg`
<path d=${d} clip-rule="evenodd" fill-rule="evenodd"></path>`)}
</clipPath>` : nothing}
<filter id="hp-glowfeather" filterUnits="userSpaceOnUse"
x="${featherBox.x}" y="${featherBox.y}"
width="${featherBox.w}" height="${featherBox.h}"
color-interpolation-filters="sRGB">
<feGaussianBlur stdDeviation="${input.feather.toFixed(4)}" edgeMode="none"></feGaussianBlur>
</filter>
</defs>
<g class="glowlayer glow-pools-frame" pointer-events="none" aria-hidden="true"
filter=${input.featherEnabled ? 'url(#hp-glowfeather)' : nothing}>
<g class="glow-pools ${input.screenBlend ? 'blend-screen' : 'blend-normal'}"
data-blend=${input.screenBlend ? 'screen' : 'normal'}
data-feather-px="${GLOW_EDGE_FEATHER_PX}"
clip-path=${enabledClip ? 'url(#hp-glow-enabled)' : nothing}>
${repeat(input.spots, (spot) => spot.key, (spot) => svg`
<g class="glow-spot ${spot.entering ? 'is-entering' : ''} ${spot.leaving ? 'is-leaving' : ''}"
data-glow-spot="${spot.domId}" data-glow-source="${spot.sourceEid}">
<circle class="glow-pool"
cx="${spot.pos.x}" cy="${spot.pos.y}" r="${spot.r}"
data-lit-parts="${spot.geometry?.lit.length || 0}"
data-feather-px="${GLOW_EDGE_FEATHER_PX}"
fill="url(#hp-glow-${spot.domId})"
clip-path=${spot.geometry ? `url(#hp-glowclip-${spot.domId})` : nothing}></circle>
</g>`)}
</g>
</g>` as unknown as TemplateResult;
}
+118 -429
View File
@@ -241,6 +241,14 @@ import { LANGUAGE_RUNTIME, subscribeLanguageLoadFailures } from './i18n/registry
import { languageLoadingTemplate, languageRenderGate } from './i18n/language-runtime';
import { CommandStack } from './command-stack';
import { resolvedSvgScreenBlend, svgScreenBlendSupported } from './glow-blend';
import {
buildGlowClipGeometry, buildLightBarrierScene, createGlowRuntimeState,
disposeGlowRuntime, forgetGlowSource, forgetGlowSpace, glowSourceInOpaqueBody,
pruneGlowSources, readGlowClip, renderGlowPools, resolveGlowCandidates, resolveGlowFeather,
resolveLightBarrierRevision, transitionGlowSource, warnGlowGeometryFallback,
writeGlowClip,
type GlowRuntimeHost, type GlowRuntimeState, type GlowSpot,
} from './glow-scene';
import {
CONTINUITY_LONG_HIDDEN_MS,
VisualContinuityController,
@@ -586,30 +594,6 @@ const POINTER_HOVER_TARGET_SELECTOR = 'hp-dialog, hp-help, hp-color-opacity, hp-
const NORM_W = 1000; // side of the render space — the canvas is square (v1.48.0)
/** Short semantic-event / direct-terminal-transition window. Event uses
three sequential 1.1 s waves; motion cool-down itself never animates. */
/**
* How finely the lit region is traced where nothing blocks the light. 96 steps
* put the chord error at 0.05% of the radius — under a tenth of a pixel on a
* wall tablet, and cheap because only unobstructed directions use them.
*/
const GLOW_ARC_STEPS = 96;
/**
* Width of the lit→unlit ramp along a shadow edge, in SCREEN pixels: the eye
* reads a perfectly geometric edge as a cut-out, and a real penumbra is never
* wider than a hair at this scale. Measured on screen on purpose, so zooming in
* does not turn a hairline into a smear.
*/
const GLOW_EDGE_FEATHER_PX = 2;
/**
* Radial profile of a pool, as [offset %, share of the calibrated alpha].
* Monotonic all the way out: a lamp is brightest under itself and dies at its
* radius. The centre keeps the full calibrated alpha, so nothing about the
* brightness maths (docs/specs/067) changes — only where that alpha is spent.
*/
const GLOW_FALLOFF: readonly (readonly [number, number])[] = [
[0, 1], [45, 0.88], [70, 0.62], [86, 0.32], [100, 0],
];
/** A source pool fades in/out without changing its final calibrated alpha. */
const GLOW_FADE_MS = 500;
/** Smallest rectangle holding both (docs/CANVAS.md §4). */
const unionRect = (a: Rect, b: Rect): Rect => {
@@ -653,12 +637,6 @@ type WallFaceBatch = {
activeCms: number[];
activeDraftId: string | null;
};
/**
* The floor a source can see, and nothing else. One region means one clip:
* a beam through a doorway, the room it lands in and the shadow of a column
* are all the same computation, so they can never disagree with each other.
*/
type GlowClipGeometry = { lit: string[] };
const MARKUP_TOOLS = new Set<MarkupTool>([
'select', 'draw', 'column', 'merge', 'split', 'resize',
'opening', 'wallthick', 'delroom',
@@ -1815,9 +1793,16 @@ export class HouseplanCard extends LitElement {
floor: number[][]; geom: any; path: string; area: number;
}>();
private _innerContourCache = new Map<string, number[][] | null>();
private _glowClipCache = new Map<string, GlowClipGeometry | null>();
/** Redacted, bounded dedupe for numerical floor fallbacks (#218). */
private _glowGeometryWarnings = new Set<string>();
private readonly _glowRuntimeState: GlowRuntimeState = createGlowRuntimeState();
private readonly _glowRuntimeHost: GlowRuntimeHost = {
window: () => this.ownerDocument.defaultView || window,
isConnected: () => this.isConnected,
requestUpdate: () => this.requestUpdate(),
reducedMotion: () => this._reducedMotion,
};
/** Compatibility aliases keep the performance/smoke diagnostic contract. */
private get _glowClipCache() { return this._glowRuntimeState.clipCache; }
private get _glowGeometryWarnings() { return this._glowRuntimeState.geometryWarnings; }
private _lightBarrierCache: {
key: string;
value: {
@@ -1830,16 +1815,20 @@ export class HouseplanCard extends LitElement {
private _lightBarrierPool = new Map<string, NonNullable<typeof this._lightBarrierCache>>();
/** Freeze the SVG blur while a pinch/pan emits animation frames, then adopt
* the final screen-space value after the gesture. */
private _glowFeatherUnits: number | null = null;
private get _glowFeatherUnits() { return this._glowRuntimeState.featherUnits; }
private set _glowFeatherUnits(value: number | null) { this._glowRuntimeState.featherUnits = value; }
/** Active pools survive an off transition until their 500 ms fade completes. */
private _glowRenderedSources = new Map<string, number>();
private _glowLastAppearance = new Map<string, { c: string; alpha: number }>();
private _glowEnteringSources = new Set<string>();
private _glowEnterRafs = new Map<string, number>();
private _glowFadeTimers = new Map<string, number>();
private _glowFeatherSuspendUntil = 0;
private _glowFeatherResumeTimer = 0;
private _glowSourceSeq = 0;
private get _glowRenderedSources() { return this._glowRuntimeState.renderedSources; }
private get _glowLastAppearance() { return this._glowRuntimeState.lastAppearance; }
private get _glowEnteringSources() { return this._glowRuntimeState.enteringSources; }
private get _glowEnterRafs() { return this._glowRuntimeState.enterRafs; }
private get _glowFadeTimers() { return this._glowRuntimeState.fadeTimers; }
private get _glowFeatherSuspendUntil() { return this._glowRuntimeState.featherSuspendUntil; }
private set _glowFeatherSuspendUntil(value: number) { this._glowRuntimeState.featherSuspendUntil = value; }
private get _glowFeatherResumeTimer() { return this._glowRuntimeState.featherResumeTimer; }
private set _glowFeatherResumeTimer(value: number) { this._glowRuntimeState.featherResumeTimer = value; }
private get _glowSourceSeq() { return this._glowRuntimeState.sourceSeq; }
private set _glowSourceSeq(value: number) { this._glowRuntimeState.sourceSeq = value; }
/** Pending/false uses the exact historical normal-layer fallback. */
private _glowScreenBlend = false;
private _duplicateColumnId: string | null = null;
@@ -2667,17 +2656,7 @@ export class HouseplanCard extends LitElement {
this._liveSyncConnection = null;
clearTimeout(this._layoutSyncTimer);
clearTimeout(this._duplicateColumnTimer);
for (const timer of this._glowFadeTimers.values()) clearTimeout(timer);
for (const raf of this._glowEnterRafs.values()) cancelAnimationFrame(raf);
clearTimeout(this._glowFeatherResumeTimer);
this._glowFeatherResumeTimer = 0;
this._glowFeatherSuspendUntil = 0;
this._glowFadeTimers.clear();
this._glowEnterRafs.clear();
this._glowEnteringSources.clear();
this._glowRenderedSources.clear();
this._glowLastAppearance.clear();
this._glowSourceSeq = 0;
disposeGlowRuntime(this._glowRuntimeState, this._glowRuntimeHost);
// DEV-B703-03: the last thing this instance was showing, then the
// tombstone that lets exactly one successor adopt the open dialog.
// AUD-159B1-02: the snapshot runs while `_warmRevivePending` is still
@@ -10233,105 +10212,24 @@ export class HouseplanCard extends LitElement {
key: string,
active: boolean,
): { domId: number; entering: boolean; leaving: boolean } | null {
let domId = this._glowRenderedSources.get(key);
if (active) {
const timer = this._glowFadeTimers.get(key);
if (timer != null) {
clearTimeout(timer);
this._glowFadeTimers.delete(key);
}
if (domId == null) {
this._suspendGlowFeatherForTransition();
domId = ++this._glowSourceSeq;
this._glowRenderedSources.set(key, domId);
this._glowEnteringSources.add(key);
const raf = requestAnimationFrame(() => {
if (this._glowEnterRafs.get(key) !== raf) return;
this._glowEnterRafs.delete(key);
this._glowEnteringSources.delete(key);
if (this.isConnected) this.requestUpdate();
});
this._glowEnterRafs.set(key, raf);
}
return { domId, entering: this._glowEnteringSources.has(key), leaving: false };
}
if (domId == null) return null;
const enterRaf = this._glowEnterRafs.get(key);
if (enterRaf != null) cancelAnimationFrame(enterRaf);
this._glowEnterRafs.delete(key);
this._glowEnteringSources.delete(key);
if (!this._glowFadeTimers.has(key)) {
this._suspendGlowFeatherForTransition();
const timer = window.setTimeout(() => {
if (this._glowFadeTimers.get(key) !== timer) return;
this._glowFadeTimers.delete(key);
this._glowRenderedSources.delete(key);
this._glowLastAppearance.delete(key);
if (this.isConnected) this.requestUpdate();
}, GLOW_FADE_MS + 34); // keep one frame of slack after the CSS transition
this._glowFadeTimers.set(key, timer);
}
return { domId, entering: false, leaving: true };
}
/** A whole-layer Gaussian blur is needlessly re-evaluated for every frame
* of a source opacity transition. The 1 px penumbra is imperceptible while
* the pool itself is moving between transparent and opaque, so bypass it
* for that bounded interval and restore it once at the settled frame. */
private _suspendGlowFeatherForTransition(): void {
if (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) return;
this._glowFeatherSuspendUntil = Math.max(
this._glowFeatherSuspendUntil, Date.now() + GLOW_FADE_MS,
return transitionGlowSource(
this._glowRuntimeState, this._glowRuntimeHost, key, active,
);
clearTimeout(this._glowFeatherResumeTimer);
const delay = Math.max(0, this._glowFeatherSuspendUntil - Date.now()) + 17;
const resume = () => {
this._glowFeatherResumeTimer = 0;
if (Date.now() < this._glowFeatherSuspendUntil) {
this._glowFeatherResumeTimer = window.setTimeout(
resume, this._glowFeatherSuspendUntil - Date.now() + 17,
);
return;
}
this._glowFeatherSuspendUntil = 0;
if (this.isConnected) this.requestUpdate();
};
this._glowFeatherResumeTimer = window.setTimeout(resume, delay);
}
private _forgetGlowSource(key: string): void {
const timer = this._glowFadeTimers.get(key);
if (timer != null) clearTimeout(timer);
const raf = this._glowEnterRafs.get(key);
if (raf != null) cancelAnimationFrame(raf);
this._glowFadeTimers.delete(key);
this._glowEnterRafs.delete(key);
this._glowEnteringSources.delete(key);
this._glowRenderedSources.delete(key);
this._glowLastAppearance.delete(key);
forgetGlowSource(this._glowRuntimeState, this._glowRuntimeHost, key);
}
private _forgetGlowSpace(spaceId: string): void {
const prefix = `${spaceId}|`;
for (const key of this._glowRenderedSources.keys()) {
if (key.startsWith(prefix)) this._forgetGlowSource(key);
}
forgetGlowSpace(this._glowRuntimeState, this._glowRuntimeHost, spaceId);
}
private _warnGlowGeometryFallback(
spaceId: string, fingerprint: string, roomId: string, phase: string,
): void {
// One diagnostic per geometry revision and room, even if that room happens
// to fail in more than one boolean phase while the card is re-rendering.
const key = `${spaceId}|${fingerprint}|${roomId}`;
if (this._glowGeometryWarnings.has(key)) return;
if (this._glowGeometryWarnings.size >= 128) {
const oldest = this._glowGeometryWarnings.values().next().value;
if (oldest) this._glowGeometryWarnings.delete(oldest);
}
this._glowGeometryWarnings.add(key);
console.warn(
`HOUSEPLAN GLOW GEOMETRY FALLBACK: #218, space ${spaceId}, room ${roomId}, phase ${phase}`,
warnGlowGeometryFallback(
this._glowRuntimeState, spaceId, fingerprint, roomId, phase,
);
}
@@ -10357,140 +10255,44 @@ export class HouseplanCard extends LitElement {
masonryGeometry: any; opaqueBodies: number[][][];
} {
const raw = this._curSpaceCfg;
// Exact content identity keeps the old in-place-mutation protection while
// moving the cache lookup ahead of passage classification, physical-body
// booleans and wall topology. `_cfgEpoch` alone is deliberately not used:
// old editor paths and third-party harnesses can still mutate a structure
// before advancing it.
const geometryFingerprint = contentFingerprint([raw, this._cellCm, this._gridPitch]);
// Gates are door-like: their different symbol must not change how light
// crosses the clear opening.
// An opening is transparent only where it leads from floor to floor. A
// front door has nothing behind it to light, so for light it is masonry
// like a window — otherwise its tunnel glows halfway, up to the centreline
// where the room polygon ends, and the plan shows a lit doorway to nowhere.
const probe = Math.max(this._cmToUnits(10), this._gridPitch * 0.5);
const onFloor = (point: number[]): boolean =>
polys.some((x) => this._pointInRoom(point, x.r));
const passageStates = this._openingsR.flatMap((opening) => {
if (!isInteriorLightOpeningType(String(opening.type))) return [];
const o = opening;
const rad = (o.angle * Math.PI) / 180;
const nx = -Math.sin(rad);
const ny = Math.cos(rad);
const interior = onFloor([o.rx + nx * probe, o.ry + ny * probe])
&& onFloor([o.rx - nx * probe, o.ry - ny * probe]);
// #366: the light pipeline consumes the quantised amount — the door
// LEAF animation stays smooth (_openingAmt elsewhere), only light steps.
return interior ? [{ opening: o, amount: quantizeOpeningLightAmount(this._openingAmt(o)) }] : [];
const revision = resolveLightBarrierRevision({
rawSpaceConfig: raw,
space,
openings: this._openingsR,
cellCm: this._cellCm,
gridPitch: this._gridPitch,
openingAmount: (opening) => this._openingAmt(opening),
});
const openingStateSignature = openingLightStateSignature(
passageStates.map(({ opening, amount }) => ({
id: opening.id, type: opening.type, contact: opening.contact, amount,
})),
);
const fingerprint = contentFingerprint([geometryFingerprint, openingStateSignature]);
const cacheKey = `${space.id}|${fingerprint}`;
const cacheKey = `${space.id}|${revision.fingerprint}`;
const pooled = lruRead(this._lightBarrierPool, cacheKey);
if (pooled.hit) {
this._lightBarrierCache = pooled.value;
return pooled.value.value;
}
const zeroWalls = this._zeroWalls();
// Physical masonry has no body at every zero wall, independent of style.
// Light transport differs: solid zero walls are added back below as exact
// centre-line barriers rather than inflated fake masonry.
const openCuts = this._openCuts();
// Only dashed zero axes are openings in the visibility contour. Solid
// zero axes stay in the room outline and are also registered below as
// exact standalone barriers for independent/outer-wall parity.
const cuts: number[][] = [...zeroWalls.transmissive];
const passages = passageStates
.filter(({ amount }) => amount > 0)
.map(({ opening, amount }) => ({
...opening,
rlen: openingLightApertureLength(opening.rlen, amount),
}));
const roomPassages = this._roomWallOpeningInputs(passages, space);
// Openings omitted from `passages` remain part of light's opaque masonry.
// Consequently a source centred in an exterior door/gate or any window is
// rejected below exactly like a source centred inside the wall itself.
for (const opening of roomPassages) {
const rad = (opening.angle * Math.PI) / 180;
const dx = (Math.cos(rad) * opening.length) / 2;
const dy = (Math.sin(rad) * opening.length) / 2;
cuts.push([opening.x - dx, opening.y - dy, opening.x + dx, opening.y + dy]);
}
const lightPartitionCuts = passageStates.flatMap(({ opening, amount }) =>
amount > 0 && opening.partitionHost
? [scalePartitionOpeningCut(partitionOpeningCut(opening.partitionHost), amount)]
: []);
const lightPhysicalKey = cacheKey;
if (this._lightPhysicalBodiesCache?.key !== lightPhysicalKey) {
this._lightPhysicalBodiesCache = {
key: lightPhysicalKey,
all: physicalBodyParts(
space, this._cellCm, this._gridPitch, this._gridPitch * 0.0002,
lightPartitionCuts,
).all,
};
}
const lightPhysical = this._lightPhysicalBodiesCache.all;
const walls = this._spaceWalls;
const eps = this._gridPitch * 0.02;
const occluders: LightSegment[] = [];
// The masonry the plan draws, cut by passages only — real thickness, real
// jamb faces. Treating a wall as its centreline let light bleed half a wall
// deep (a bright bar at every opening) and started every shadow half a wall
// away from the corner that casts it.
const sharedWallGeometry = this._wallUnionGeometry();
const sharedFingerprint = (sharedWallGeometry as unknown as {
sourceFingerprint?: string;
} | null)?.sourceFingerprint;
const recut = sharedWallGeometry && sharedFingerprint === geometryFingerprint
? recutWallBodiesGeometry(sharedWallGeometry, roomPassages, lightPhysical)
: null;
const masonry = recut || (walls.length || lightPhysical.length
? wallBodiesGeometry(
space.rooms, walls, openCuts,
roomPassages,
this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W, lightPhysical,
)
: null);
if (masonry && (masonry.status === 'ok' || masonry.status === 'degraded-extra')) {
for (const component of masonry.components) {
for (const ring of geometryAllRings(component.geom)) occluders.push(...polygonSegments(ring));
}
} else {
// Malformed legacy geometry must remain opaque even when the canonical
// boolean pass cannot produce a joined result.
for (const body of lightPhysical) occluders.push(...polygonSegments(body));
}
// Edges without any thickness are still walls; so is a room outline when
// the boolean pass above could not run at all.
for (const { poly } of polys) {
for (const seg of (cuts.length ? outlineWithout(poly, cuts, eps) : polygonSegments(poly))) {
occluders.push(seg as LightSegment);
}
}
// A solid zero wall has topology and opacity but deliberately no area.
// Add its axis directly to the visibility sweep; do not manufacture a
// narrow polygon, because that would alter floor area and doorway maths.
for (const barrier of zeroWalls.barriers) {
occluders.push(barrier as LightSegment);
}
const value = {
occluders: splitAtIntersections(occluders),
floor: polys.map((x) => x.poly),
fingerprint,
masonryGeometry: masonry && (masonry.status === 'ok' || masonry.status === 'degraded-extra')
? masonry.components.flatMap((component) => component.geom) : [],
// The source guard must fail dark against the same type/floor-filtered
// bodies used to build the light masonry. The ordinary render bodies are
// cut by every hosted opening, including opaque windows and exterior
// doors, and therefore cannot be used when the boolean geometry is empty.
opaqueBodies: lightPhysical,
};
const value = buildLightBarrierScene({
rawSpaceConfig: raw,
space,
revision,
walls: this._spaceWalls,
zeroWalls: this._zeroWalls(),
wallKeyPitch: this._wallKeyPitch,
cellCm: this._cellCm,
gridPitch: this._gridPitch,
coordScale: NORM_W,
sharedWallGeometry: this._wallUnionGeometry(),
physicalBodies: (partitionCuts, lightPhysicalKey) => {
if (this._lightPhysicalBodiesCache?.key !== lightPhysicalKey) {
this._lightPhysicalBodiesCache = {
key: lightPhysicalKey,
all: physicalBodyParts(
space, this._cellCm, this._gridPitch, this._gridPitch * 0.0002,
partitionCuts,
).all,
};
}
return this._lightPhysicalBodiesCache.all;
},
});
const entry = { key: cacheKey, value };
lruWrite(this._lightBarrierPool, cacheKey, entry, 8);
this._lightBarrierCache = entry;
@@ -10509,84 +10311,48 @@ export class HouseplanCard extends LitElement {
this._forgetGlowSpace(space.id);
return svg`` as unknown as TemplateResult;
}
const {
occluders, floor, fingerprint, masonryGeometry, opaqueBodies,
} = this._lightBarriers(space, polys);
// Resolve against the whole plan: a controller and its passive lamp may
// legitimately live in different spaces. Ownership is filtered afterwards.
const resolvedSources = resolvedLightSources(
this._renderPlanHass, this._renderDevices, null, this._virtualLights,
)
.filter((source) => source.device.space === space.id);
const sourcesByDevice = new Map<string, typeof resolvedSources>();
for (const source of resolvedSources) {
if (!source.device.id) continue;
const list = sourcesByDevice.get(source.device.id) || [];
list.push(source);
sourcesByDevice.set(source.device.id, list);
}
const spots: {
key: string;
sourceEid: string;
domId: number;
entering: boolean;
leaving: boolean;
pos: { x: number; y: number };
c: string;
alpha: number;
geometry: GlowClipGeometry | null;
r: number;
}[] = [];
const scene = this._lightBarriers(space, polys);
const candidates = resolveGlowCandidates({
hass: this._renderPlanHass,
devices: this._renderDevices,
virtualLights: this._virtualLights,
spaceId: space.id,
defaultColor: colors.glow_light.c,
paletteAlpha: colors.glow_light.a,
defaultRadiusUnits: defaultR,
cellCm: this._cellCm,
gridPitch: this._gridPitch,
position: (device) => this._pos(device),
});
const spots: GlowSpot[] = [];
const seenSourceKeys = new Set<string>();
for (const d of this._renderDevices) {
if (d.space !== space.id) continue;
const source = selectSpatialGlowSource(sourcesByDevice.get(d.id) || []);
if (!source) continue;
// One marker owns one spatial pool even when its resolved HA source
// changes; keeping the key marker-stable avoids a false fade/recreate.
const key = `${space.id}|${d.id}`;
for (const candidate of candidates) {
const { key, pos } = candidate;
seenSourceKeys.add(key);
const visibleGlow = resolveGlowAppearance(
source.passive
? { state: source.on ? 'on' : 'off', attributes: {} }
: this._renderPlanHass.states[source.eid],
d.marker?.glow_color,
colors.glow_light.c,
);
// per-source radius (owner's decision v1.36.2): marker override, else global
const ownCm = Number(d.marker?.glow_radius_cm);
const R = Number.isFinite(ownCm) && ownCm > 0 ? (ownCm / this._cellCm) * this._gridPitch : defaultR;
const pos = this._pos(d);
// Invalid placement inside any opaque body must remain dark. The
// masonry geometry already contains the exact passage cuts, so a valid
// interior doorway remains transparent while a source embedded in the
// surrounding wall cannot light one side of its tunnel. The visibility
// sweep separately rejects a source that lies exactly on an opaque edge.
const sourcePoint = [pos.x, pos.y];
// Exterior opening tunnels deliberately remain in `masonryGeometry`,
// so placing the source there suppresses the entire pool rather than
// lighting only the indoor half of the tunnel.
if (pointInOpaquePlanBody(sourcePoint, masonryGeometry, opaqueBodies)) {
if (glowSourceInOpaqueBody(pos, scene)) {
// This placement cannot produce a valid previous-frame fade: the old
// clip belongs to a different position. Remove its transition state as
// well, rather than leaving a timer for a DOM node no longer rendered.
this._forgetGlowSource(key);
continue;
}
const transition = this._glowTransition(key, !!visibleGlow);
const transition = this._glowTransition(key, !!candidate.appearance);
if (!transition) continue;
if (visibleGlow) {
this._glowLastAppearance.set(key, {
c: visibleGlow.c,
alpha: glowAlpha(visibleGlow.bri, colors.glow_light.a),
});
}
if (candidate.appearance) this._glowLastAppearance.set(key, candidate.appearance);
const appearance = this._glowLastAppearance.get(key);
if (!appearance) continue;
let geometry: GlowClipGeometry | null = null;
let geometry: GlowSpot['geometry'] = null;
const clipKey =
`${space.id}|${fingerprint}|${pos.x.toFixed(4)},${pos.y.toFixed(4)}|${R.toFixed(4)}`;
const cachedClip = lruRead(this._glowClipCache, clipKey);
`${space.id}|${scene.fingerprint}|${pos.x.toFixed(4)},${pos.y.toFixed(4)}|${candidate.radius.toFixed(4)}`;
const cachedClip = readGlowClip(this._glowRuntimeState, clipKey);
if (cachedClip.hit) {
geometry = cachedClip.value;
} else {
@@ -10596,24 +10362,21 @@ export class HouseplanCard extends LitElement {
// notion of a "spill", a "sector", a "tunnel" or an "open zone" — and
// a wall corner two rooms away casts its shadow for exactly the same
// reason a column does.
const seen = visibilityPolygon([pos.x, pos.y], R, occluders, GLOW_ARC_STEPS);
geometry = {
lit: seen.length >= 3
? intersectionPaths([seen], floor, {
onBoundsFailure: ({ boundIndex, phase }) => {
const room = polys[boundIndex]?.r;
this._warnGlowGeometryFallback(
space.id, fingerprint, room?.id || `#${boundIndex}`, phase,
);
},
})
: [],
};
lruWrite(this._glowClipCache, clipKey, geometry, 256);
geometry = buildGlowClipGeometry({
spaceId: space.id,
source: pos,
radius: candidate.radius,
scene,
polygons: polys.map(({ r, poly }) => ({ room: r, poly })),
onBoundsFailure: (roomId, phase) => this._warnGlowGeometryFallback(
space.id, scene.fingerprint, roomId, phase,
),
});
writeGlowClip(this._glowRuntimeState, clipKey, geometry);
}
spots.push({
key,
sourceEid: source.eid,
sourceEid: candidate.sourceEid,
domId: transition.domId,
entering: transition.entering,
leaving: transition.leaving,
@@ -10621,13 +10384,12 @@ export class HouseplanCard extends LitElement {
c: appearance.c,
alpha: appearance.alpha,
geometry,
r: R,
r: candidate.radius,
});
}
const sourcePrefix = `${space.id}|`;
for (const key of this._glowRenderedSources.keys()) {
if (key.startsWith(sourcePrefix) && !seenSourceKeys.has(key)) this._forgetGlowSource(key);
}
pruneGlowSources(
this._glowRuntimeState, this._glowRuntimeHost, space.id, seenSourceKeys,
);
if (!spots.length) return svg`` as unknown as TemplateResult;
// Per-room Glow overrides are visual clips only. The transport calculation
// above still crosses a disabled room, but no base/pool pixels are painted
@@ -10654,89 +10416,16 @@ export class HouseplanCard extends LitElement {
const perUnit = this._stageEl?.clientWidth && view.w
? this._stageEl.clientWidth / view.w
: 1;
const nextFeather = GLOW_EDGE_FEATHER_PX / 2 / (perUnit > 0 ? perUnit : 1);
const featherEnabled = !this._pinchStart && !this._panStart
&& Date.now() >= this._glowFeatherSuspendUntil;
if (this._glowFeatherUnits == null || featherEnabled)
this._glowFeatherUnits = nextFeather;
const feather = this._glowFeatherUnits ?? nextFeather;
const pad = feather * 4;
const featherBox = spots.reduce((box, sp) => ({
x: Math.min(box.x, sp.pos.x - sp.r - pad),
y: Math.min(box.y, sp.pos.y - sp.r - pad),
maxX: Math.max(box.maxX, sp.pos.x + sp.r + pad),
maxY: Math.max(box.maxY, sp.pos.y + sp.r + pad),
w: 0, h: 0,
}), { x: Infinity, y: Infinity, maxX: -Infinity, maxY: -Infinity, w: 0, h: 0 });
featherBox.w = featherBox.maxX - featherBox.x;
featherBox.h = featherBox.maxY - featherBox.y;
return svg`<defs>
${repeat(spots, (sp) => sp.key, (sp) => {
const i = sp.domId;
const geom = sp.geometry;
return svg`
${''/* One field per source, in plan coordinates. Attenuation is a
property of DISTANCE FROM THE LAMP and nothing else, so the
floor two rooms away is dimmer because it is far, not because
a second gradient was pasted at a doorway. The profile decays
over the whole radius: the old flat plateau out to 70% turned
every clipped shape into a slab of solid colour with a rim. */}
<radialGradient id="hp-glow-${i}" gradientUnits="userSpaceOnUse"
cx="${sp.pos.x}" cy="${sp.pos.y}" r="${sp.r}">
${GLOW_FALLOFF.map(([offset, scale]) => svg`
<stop offset="${offset}%" stop-color="${sp.c}"
stop-opacity="${(sp.alpha * scale).toFixed(4)}"></stop>`)}
</radialGradient>
${''/* The one and only shape of this light: the floor this lamp can
actually see. A shadow is simply floor that is not in here.
The single blur below is the penumbra of that shape — one
pass over one path, never a second layer of light. */}
${geom ? svg`
<clipPath id="hp-glowclip-${i}">
<path class="glow-lit" d="${geom.lit.join(' ')}"
clip-rule="evenodd" fill-rule="evenodd"></path>
</clipPath>`
: nothing}`;
})}
${enabledClip ? svg`<clipPath id="hp-glow-enabled">${enabledClip.map((d) => svg`<path d=${d} clip-rule="evenodd" fill-rule="evenodd"></path>`)}</clipPath>` : nothing}
${''/* Penumbra, once for the whole layer. Every region is cut
geometrically; blurring the composited result by a hair is what
keeps a shadow edge from reading as a cut-out. One pass costs a
fraction of one blurred mask per source, and the difference is
invisible — neighbouring pools are already smooth where they
meet. CSS `filter: blur()` is not usable here: Chromium applies
it to an SVG group in name only. */}
<filter id="hp-glowfeather" filterUnits="userSpaceOnUse"
x="${featherBox.x}" y="${featherBox.y}"
width="${featherBox.w}" height="${featherBox.h}"
color-interpolation-filters="sRGB">
<feGaussianBlur stdDeviation="${feather.toFixed(4)}" edgeMode="none"></feGaussianBlur>
</filter>
</defs>
${''/* Glow is presentation only. It is painted above room fills, but must
not become the pointer target: room hover and its tooltip still
belong to the room underneath the light pool. */}
<g class="glowlayer glow-pools-frame" pointer-events="none"
filter=${featherEnabled ? 'url(#hp-glowfeather)' : nothing}>
<g class="glow-pools ${this._glowScreenBlend ? 'blend-screen' : 'blend-normal'}"
data-blend=${this._glowScreenBlend ? 'screen' : 'normal'}
data-feather-px="${GLOW_EDGE_FEATHER_PX}"
clip-path=${enabledClip ? 'url(#hp-glow-enabled)' : nothing}>
${repeat(spots, (sp) => sp.key, (sp) => {
const i = sp.domId;
return svg`
<g class="glow-spot ${sp.entering ? 'is-entering' : ''} ${sp.leaving ? 'is-leaving' : ''}"
data-glow-spot="${i}" data-glow-source="${sp.sourceEid}">
<circle class="glow-pool"
cx="${sp.pos.x}" cy="${sp.pos.y}" r="${sp.r}"
data-lit-parts="${sp.geometry?.lit.length || 0}"
data-feather-px="${GLOW_EDGE_FEATHER_PX}"
fill="url(#hp-glow-${i})"
clip-path=${sp.geometry ? `url(#hp-glowclip-${i})` : nothing}></circle>
</g>`;
})}
</g>
</g>` as unknown as TemplateResult;
const feather = resolveGlowFeather(
this._glowRuntimeState, perUnit, !this._pinchStart && !this._panStart,
);
return renderGlowPools({
spots,
enabledClip,
feather: feather.feather,
featherEnabled: feather.enabled,
screenBlend: this._glowScreenBlend,
});
}
/**
+1
View File
@@ -351,6 +351,7 @@
"editor.icon_size": "Symbolgröße, % der Planbreite",
"editor.show_temperature": "Temperatur anzeigen",
"editor.live_states": "Live-Zustände (ein/aus, offen…)",
"editor.light_pools": "Lichtkegel und Wandschatten",
"editor.show_signal": "Zigbee-Signal anzeigen (LQI)",
"editor.language": "Schnittstellensprache",
"editor.lang_auto": "Automatisch (HA-Profil)",
+1
View File
@@ -351,6 +351,7 @@
"editor.icon_size": "Icon size, % of plan width",
"editor.show_temperature": "Show temperature",
"editor.live_states": "Live states (on/off, open…)",
"editor.light_pools": "Light pools and wall shadows",
"editor.show_signal": "Show zigbee signal (LQI)",
"editor.language": "Interface language",
"editor.lang_auto": "Auto (HA profile)",
+1
View File
@@ -351,6 +351,7 @@
"editor.icon_size": "Taille des icônes, % de la largeur du plan",
"editor.show_temperature": "Afficher la température",
"editor.live_states": "États en direct (on/off, ouvert…)",
"editor.light_pools": "Halos lumineux et ombres des murs",
"editor.show_signal": "Afficher le signal Zigbee (LQI)",
"editor.language": "Langue de l’interface",
"editor.lang_auto": "Auto (profil HA)",
+1
View File
@@ -351,6 +351,7 @@
"editor.icon_size": "Размер иконок, % ширины плана",
"editor.show_temperature": "Показывать температуру",
"editor.live_states": "Живые состояния (вкл/выкл, открыто…)",
"editor.light_pools": "Световые пулы и тени от стен",
"editor.show_signal": "Показывать сигнал zigbee (LQI)",
"editor.language": "Язык интерфейса",
"editor.lang_auto": "Авто (профиль HA)",
+42
View File
@@ -38,6 +38,11 @@ import {
bgModeOf, resolveDayCycle, dayCycleFingerprint, type DayCycleState,
} from './sun';
import { PointerModalityController } from './pointer-modality';
import { resolvedSvgScreenBlend, svgScreenBlendSupported } from './glow-blend';
import {
createGlowRuntimeState, disposeGlowRuntime,
type GlowRuntimeHost, type GlowRuntimeState,
} from './glow-scene';
import './space-editor';
const fireEvent = (node: EventTarget, type: string, detail?: unknown) => {
@@ -61,6 +66,7 @@ interface SpaceCardConfig {
show_temperature?: boolean;
live_states?: boolean;
show_signal?: boolean;
light_pools?: boolean;
language?: string;
}
@@ -103,6 +109,15 @@ class HouseplanSpaceCard extends LitElement {
private _capturedSnapshotActivity = '';
private _capturedSnapshotVirtual = '';
private _activityRuntime = new Map<string, FiniteActivityRuntime>();
private readonly _glowRuntimeState: GlowRuntimeState = createGlowRuntimeState();
private readonly _glowRuntimeHost: GlowRuntimeHost = {
window: () => this.ownerDocument.defaultView || window,
isConnected: () => this.isConnected,
requestUpdate: () => this.requestUpdate(),
reducedMotion: () => this._reducedMotion,
};
/** Pending/false is the exact historical normal-layer fallback. */
private _glowScreenBlend = false;
private _reducedMotion = false;
private readonly _pointerModality = new PointerModalityController(this);
private _motionMedia?: MediaQueryList;
@@ -258,6 +273,7 @@ class HouseplanSpaceCard extends LitElement {
return {
type: 'custom:houseplan-space-card', space: first, show_button: true,
live_states: true, show_temperature: true, show_signal: true,
light_pools: false,
};
}
@@ -268,8 +284,14 @@ class HouseplanSpaceCard extends LitElement {
this._config = {
show_button: true, button_target: '/plan-doma',
live_states: true, show_temperature: true, show_signal: true,
light_pools: false,
...config,
};
if (!this._config.light_pools) {
disposeGlowRuntime(this._glowRuntimeState, this._glowRuntimeHost);
} else if (this.isConnected) {
this._resolveGlowBlend();
}
// instant paint from the full card's localStorage snapshot, refresh in the background
this._snap = this._snap || cachedSnapshot();
}
@@ -285,6 +307,7 @@ class HouseplanSpaceCard extends LitElement {
this._motionMedia = window.matchMedia?.('(prefers-reduced-motion: reduce)');
this._reducedMotion = !!this._motionMedia?.matches;
this._motionMedia?.addEventListener?.('change', this._onMotionChange);
this._resolveGlowBlend();
if (this.hass) this._ensureHaRegistryAuthority();
this._continuityUnsub?.();
this._continuityUnsub = subscribePageVisibility(this.ownerDocument, this._pageVisibility);
@@ -325,12 +348,24 @@ class HouseplanSpaceCard extends LitElement {
this._connHooked = null;
for (const runtime of this._activityRuntime.values()) window.clearTimeout(runtime.timer);
this._activityRuntime.clear();
disposeGlowRuntime(this._glowRuntimeState, this._glowRuntimeHost);
this._continuityHistory = [...this._continuityHistory, ...this._continuity.trace].slice(-80);
this._continuity.dispose();
this._continuityDisposed = true;
super.disconnectedCallback();
}
private _resolveGlowBlend(): void {
if (!this._config?.light_pools) return;
const resolved = resolvedSvgScreenBlend(this.ownerDocument);
if (resolved !== undefined) this._glowScreenBlend = resolved;
void svgScreenBlendSupported(this.ownerDocument).then((supported) => {
if (supported === this._glowScreenBlend) return;
this._glowScreenBlend = supported;
if (this.isConnected) this.requestUpdate();
});
}
protected willUpdate(changed: PropertyValues): void {
if (changed.has('hass') && this.hass) {
this._hassSequence++;
@@ -525,6 +560,7 @@ class HouseplanSpaceCard extends LitElement {
snap?.layoutFingerprint || contentFingerprint(snap?.layout),
snap?.virtualLights ? `${snap.virtualLights.configRev}:${snap.virtualLights.rev}` : '',
this._config?.space || '',
this._config?.light_pools === true,
this._stageWidth,
this.hass?.themes?.darkMode ?? this.hass?.themes?.default_theme ?? '',
]);
@@ -803,6 +839,12 @@ class HouseplanSpaceCard extends LitElement {
activityRuntime: this._activityRuntime,
reducedMotion: this._reducedMotion,
virtualLights: this._snap?.virtualLights,
lightPools: this._config.light_pools === true,
glowRuntime: {
state: this._glowRuntimeState,
host: this._glowRuntimeHost,
screenBlend: this._glowScreenBlend,
},
liveStates: this._config.live_states !== false,
showTemperature: this._config.show_temperature !== false,
showSignal: this._config.show_signal !== false,
+2
View File
@@ -50,6 +50,7 @@ class HouseplanSpaceCardEditor extends LitElement {
{ name: 'icon_size', selector: { number: { min: 1, max: 6, step: 0.1, mode: 'box' } } },
{ name: 'show_temperature', selector: { boolean: {} } },
{ name: 'live_states', selector: { boolean: {} } },
{ name: 'light_pools', selector: { boolean: {} } },
{ name: 'show_signal', selector: { boolean: {} } },
];
}
@@ -72,6 +73,7 @@ class HouseplanSpaceCardEditor extends LitElement {
icon_size: t(L, 'editor.icon_size'),
show_temperature: t(L, 'editor.show_temperature'),
live_states: t(L, 'editor.live_states'),
light_pools: t(L, 'editor.light_pools'),
show_signal: t(L, 'editor.show_signal'),
};
return html`<ha-form
+185 -7
View File
@@ -14,12 +14,12 @@ import {
import {
spaceDisplayOf, fillColorsOf, roomFillModeOf, roomGlowOf,
roomCustomFillOf, resolveEffectiveRoomFill, stageBgOf, paperRoomShapes,
openingAmount, roomPoly, outlineWithout,
openingAmount, roomPoly, outlineWithout, islandsOf,
type ResolvedRoomFill,
} from './logic';
import {
openingTunnelGeometries, wallBodiesUnionPath, wallBodyNeedsSolid, wallHatchNeedsSolid,
wallHatchStepUnits, wallIntervals, HATCH_BASE_STEP_UNITS,
wallHatchStepUnits, wallIntervals, innerContourForRoom, HATCH_BASE_STEP_UNITS,
type WallEntry,
} from './wall-thickness';
import { DEFAULT_ICON_RULES, compileIconRules, EXCLUDED_DOMAINS } from './rules';
@@ -27,7 +27,7 @@ import { t, type Lang } from './i18n';
import { bgModeOf, resolveDayCycle } from './sun';
import { dayCycleStageVars, renderDayCycleEnvironment } from './day-cycle-render';
import type { DevItem, OpeningCfg, ServerConfig } from './types';
import { physicalBodyParts } from './physical-geometry';
import { floorMinusBodies, physicalBodyParts, polyclipPathD } from './physical-geometry';
import {
materializePartitionOpening, partitionOpeningCut,
partitionOpeningFace, partitionOpeningHasCompositeRoomWall, resolvePartitionOpeningCompat,
@@ -54,6 +54,14 @@ import {
type Layout, type ContentItem,
} from './space-geometry';
import { resolveZeroWalls } from './zero-walls';
import { geometryOpenings } from './plan-geometry-preflight';
import {
buildGlowClipGeometry, buildLightBarrierScene, forgetGlowSource, forgetGlowSpace,
glowSourceInOpaqueBody, pruneGlowSources, readGlowClip, renderGlowPools,
resolveGlowCandidates, resolveGlowFeather, resolveLightBarrierRevision,
transitionGlowSource, warnGlowGeometryFallback, writeGlowClip,
type GlowRuntimeHost, type GlowRuntimeState, type GlowSpot, type LightBarrierScene,
} from './glow-scene';
export { spaceModels } from './space-geometry';
@@ -62,6 +70,8 @@ type StaticWallGeometryEntry = { fingerprint: string; value: StaticWallGeometry
const staticWallGeometryCache = new WeakMap<object, Map<string, StaticWallGeometryEntry>>();
type StaticPhysicalBodiesEntry = { fingerprint: string; value: number[][][] };
const staticPhysicalBodiesCache = new WeakMap<object, Map<string, StaticPhysicalBodiesEntry>>();
type StaticLightBarrierEntry = { fingerprint: string; value: LightBarrierScene };
const staticLightBarrierCache = new WeakMap<object, Map<string, StaticLightBarrierEntry>>();
/** Static cards receive the same immutable server-config object on HA ticks. */
function cachedStaticWallGeometry(
@@ -100,6 +110,30 @@ function cachedStaticPhysicalBodies(
return value;
}
function cachedStaticLightBarriers(
cfg: ServerConfig,
spaceId: string,
fingerprint: string,
build: () => LightBarrierScene,
): LightBarrierScene {
let spaces = staticLightBarrierCache.get(cfg as object);
if (!spaces) {
spaces = new Map<string, StaticLightBarrierEntry>();
staticLightBarrierCache.set(cfg as object, spaces);
}
const cached = spaces.get(spaceId);
if (cached?.fingerprint === fingerprint) return cached.value;
const value = build();
spaces.set(spaceId, { fingerprint, value });
return value;
}
export interface StaticGlowRuntime {
state: GlowRuntimeState;
host: GlowRuntimeHost;
screenBlend: boolean;
}
export interface StaticRenderOpts {
hass: any;
registry?: HaRegistrySnapshot;
@@ -120,6 +154,9 @@ export interface StaticRenderOpts {
showTemperature?: boolean;
showSignal?: boolean;
reducedMotion?: boolean;
/** Full radial Glow is opt-in; omitted/false preserves the cheap static path. */
lightPools?: boolean;
glowRuntime?: StaticGlowRuntime;
/** Deterministic clock injection for unit/smoke fixtures; production omits it. */
dayCycleNow?: Date | number;
virtualLights?: VirtualLightSnapshot | null;
@@ -354,12 +391,13 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
return shape;
});
// The compact card intentionally has no live radial pools, but it shares the
// exact independent data/base projection with the full plan.
const glowBaseShapes = space.rooms
// Base projection is independent of radial pools: opt-in pools are painted
// above it through the same room-level Glow gates as the full plan.
const glowEnabledRooms = space.rooms.filter((room) => roomGlowOf(disp.glow, room));
const glowBaseShapes = glowEnabledRooms
.filter((room) => {
const fill = resolvedRoomFills.get(room);
return roomGlowOf(disp.glow, room) && (!fill || fill.opacity <= 0);
return !fill || fill.opacity <= 0;
})
.map((room) => room.poly
? svg`<polygon class="glow-base" aria-hidden="true" pointer-events="none"
@@ -510,6 +548,145 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
const solidWall = !!wallUnion && (wallBodyNeedsSolid(wallUnion.depthUnits, pxPerUnit)
|| wallHatchNeedsSolid(hatchStep, pxPerUnit));
const wallStroke = disp.color || '#607d8b';
let glowPools: TemplateResult | typeof nothing = nothing;
const glowRuntime = o.glowRuntime;
if (!o.lightPools || !glowRuntime || !glowEnabledRooms.length) {
if (glowRuntime) forgetGlowSpace(glowRuntime.state, glowRuntime.host, space.id);
} else {
const lightOpenings = geometryOpenings(spCfg, space, cellCm, GRID_PITCH, NORM_W);
const revision = resolveLightBarrierRevision({
rawSpaceConfig: spCfg,
space,
openings: lightOpenings,
cellCm,
gridPitch: GRID_PITCH,
openingAmount: (opening) => {
const entity = opening.contact ? planHass.states?.[opening.contact] : null;
return openingAmount(
opening.type, entity?.state, !!opening.invert,
entity?.attributes?.current_position,
);
},
});
const scene = cachedStaticLightBarriers(
o.cfg,
space.id,
revision.fingerprint,
() => buildLightBarrierScene({
rawSpaceConfig: spCfg,
space,
revision,
walls,
zeroWalls,
wallKeyPitch: GRID_STEP_N,
cellCm,
gridPitch: GRID_PITCH,
coordScale: NORM_W,
sharedWallGeometry: canonicalWallGeometry,
physicalBodies: (partitionCuts) => physicalBodyParts(
space, cellCm, GRID_PITCH, GRID_PITCH * 0.0002, partitionCuts,
).all,
}),
);
const configuredRadius = Number((o.cfg.settings as any)?.glow_radius_cm);
const defaultRadiusCm = Number.isFinite(configuredRadius) && configuredRadius > 0
? configuredRadius : 300;
const candidates = resolveGlowCandidates({
hass: planHass,
devices: devs,
virtualLights: o.virtualLights,
spaceId: space.id,
defaultColor: colors.glow_light.c,
paletteAlpha: colors.glow_light.a,
defaultRadiusUnits: (defaultRadiusCm / cellCm) * GRID_PITCH,
cellCm,
gridPitch: GRID_PITCH,
position: (device) => markerPos(device, o.layout, o.cfg, defPos, space),
});
const seen = new Set<string>();
const spots: GlowSpot[] = [];
for (const candidate of candidates) {
seen.add(candidate.key);
if (glowSourceInOpaqueBody(candidate.pos, scene)) {
forgetGlowSource(glowRuntime.state, glowRuntime.host, candidate.key);
continue;
}
const transition = transitionGlowSource(
glowRuntime.state, glowRuntime.host,
candidate.key, !!candidate.appearance,
);
if (!transition) continue;
if (candidate.appearance) {
glowRuntime.state.lastAppearance.set(candidate.key, candidate.appearance);
}
const appearance = glowRuntime.state.lastAppearance.get(candidate.key);
if (!appearance) continue;
const clipKey = `${space.id}|${scene.fingerprint}|${candidate.pos.x.toFixed(4)},${candidate.pos.y.toFixed(4)}|${candidate.radius.toFixed(4)}`;
const cachedClip = readGlowClip(glowRuntime.state, clipKey);
const geometry = cachedClip.hit ? cachedClip.value : buildGlowClipGeometry({
spaceId: space.id,
source: candidate.pos,
radius: candidate.radius,
scene,
polygons: revision.polygons,
onBoundsFailure: (roomId, phase) => warnGlowGeometryFallback(
glowRuntime.state, space.id, scene.fingerprint, roomId, phase,
),
});
if (!cachedClip.hit) writeGlowClip(glowRuntime.state, clipKey, geometry);
spots.push({
key: candidate.key,
sourceEid: candidate.sourceEid,
domId: transition.domId,
entering: transition.entering,
leaving: transition.leaving,
pos: candidate.pos,
c: appearance.c,
alpha: appearance.alpha,
geometry,
r: candidate.radius,
});
}
pruneGlowSources(glowRuntime.state, glowRuntime.host, space.id, seen);
if (spots.length) {
const allEnabled = glowEnabledRooms.length === revision.polygons.length;
const enabledClip = allEnabled ? null : glowEnabledRooms.flatMap((room) => {
const poly = roomPoly(room);
if (!poly) return [];
const floorPoly = walls.length && room.id
? (innerContourForRoom(
space.rooms, room.id, walls, zeroWalls.contour,
GRID_STEP_N, cellCm, GRID_PITCH, NORM_W,
canonicalWallGeometry?.roomGeom,
canonicalWallGeometry?.multiWallNodes,
) || poly)
: poly;
const cleanGeometry = extras.length ? floorMinusBodies(floorPoly, extras) : null;
const clean = cleanGeometry ? polyclipPathD(cleanGeometry) : '';
const holes = islandsOf(
floorPoly,
revision.polygons
.filter(({ room: other }) => other !== room)
.map(({ poly: other }) => other),
);
const path = (points: number[][]) =>
`M ${points.map((point) => `${point[0]} ${point[1]}`).join(' L ')} Z`;
return [[clean || path(floorPoly), ...holes.map(path)].join(' ')];
});
const feather = resolveGlowFeather(
glowRuntime.state,
o.stageWidth && vb[2] ? o.stageWidth / vb[2] : 1,
true,
);
glowPools = renderGlowPools({
spots,
enabledClip,
feather: feather.feather,
featherEnabled: feather.enabled,
screenBlend: glowRuntime.screenBlend,
});
}
}
const hostedOpeningSymbols = disp.hideOpenings ? [] : resolvedHosted.map((resolved) => {
const opening = resolved.opening;
const entity = opening.type === 'passage' || !opening.contact
@@ -586,6 +763,7 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
? svg`<g class="glow-base-layer" aria-hidden="true" pointer-events="none">${glowBaseShapes}</g>`
: nothing}
${passageGlowTunnels}
${glowPools}
${wallUnion
? svg`<g class="wallbodies" style="--room-stroke:${wallStroke}">
${wallUnion.paths.map((component) => svg`
+133
View File
@@ -0,0 +1,133 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import {
createGlowRuntimeState, disposeGlowRuntime, readGlowClip,
resolveGlowCandidates, resolveLightBarrierRevision,
transitionGlowSource, writeGlowClip,
} from '../test-build/glow-scene.js';
const square = (id, x0, x1) => ({
id,
poly: [[x0, 0], [x1, 0], [x1, 100], [x0, 100]],
});
test('shared light revision admits only floor-to-floor architectural passages', () => {
const space = {
id: 's', rooms: [square('left', 0, 100), square('right', 100, 200)],
partitions: [], room_drafts: [], wall_columns: [],
};
const opening = (id, type, rx, amount) => ({
id, type, rx, ry: 50, rlen: 40, angle: 90, amount,
contact: type === 'door' ? `binary_sensor.${id}` : null,
});
const openings = [
opening('inside-door', 'door', 100, 0.51),
opening('outside-door', 'door', 0, 1),
opening('window', 'window', 100, 1),
opening('passage', 'passage', 100, 1),
];
const revision = resolveLightBarrierRevision({
rawSpaceConfig: { id: 's', rooms: space.rooms },
space,
openings,
cellCm: 5,
gridPitch: 20,
openingAmount: (candidate) => candidate.amount,
});
assert.deepEqual(
revision.passageStates.map(({ opening: candidate }) => candidate.id),
['inside-door', 'passage'],
);
assert.equal(revision.passageStates[0].amount, 0.5);
const sameBucket = resolveLightBarrierRevision({
rawSpaceConfig: { id: 's', rooms: space.rooms }, space,
openings: openings.map((candidate) => candidate.id === 'inside-door'
? { ...candidate, amount: 0.52 } : candidate),
cellCm: 5, gridPitch: 20,
openingAmount: (candidate) => candidate.amount,
});
assert.equal(sameBucket.fingerprint, revision.fingerprint,
'leaf animation inside one quantised aperture bucket must reuse barriers');
const nextBucket = resolveLightBarrierRevision({
rawSpaceConfig: { id: 's', rooms: space.rooms }, space,
openings: openings.map((candidate) => candidate.id === 'inside-door'
? { ...candidate, amount: 0.8 } : candidate),
cellCm: 5, gridPitch: 20,
openingAmount: (candidate) => candidate.amount,
});
assert.notEqual(nextBucket.fingerprint, revision.fingerprint);
});
test('one shared source projection owns radius, colour and marker-stable identity', () => {
const device = {
id: 'lamp', name: 'Lamp', model: '', area: 'living', space: 's',
icon: 'mdi:lightbulb', entities: [], virtual: true,
marker: {
id: 'lamp', binding: 'virtual', is_light: true,
glow_radius_cm: 175, glow_color: { c: '#123456' },
},
};
const [candidate] = resolveGlowCandidates({
hass: { states: {} }, devices: [device], spaceId: 's',
defaultColor: '#ffffff', paletteAlpha: 0.7,
defaultRadiusUnits: 1200, cellCm: 5, gridPitch: 20,
position: () => ({ x: 25, y: 75 }),
});
assert.equal(candidate.key, 's|lamp');
assert.equal(candidate.sourceEid, '');
assert.deepEqual(candidate.pos, { x: 25, y: 75 });
assert.equal(candidate.radius, 700);
assert.equal(candidate.appearance.c, '#123456');
assert.ok(candidate.appearance.alpha > 0 && candidate.appearance.alpha <= 0.7);
});
test('shared Glow runtime is bounded and tears down every timer and source', () => {
let seq = 0;
const timers = new Set();
const rafs = new Set();
const fakeWindow = {
setTimeout: () => { const id = ++seq; timers.add(id); return id; },
clearTimeout: (id) => timers.delete(id),
requestAnimationFrame: () => { const id = ++seq; rafs.add(id); return id; },
cancelAnimationFrame: (id) => rafs.delete(id),
};
const host = {
window: () => fakeWindow,
isConnected: () => true,
requestUpdate: () => undefined,
reducedMotion: () => false,
};
const state = createGlowRuntimeState();
const entering = transitionGlowSource(state, host, 's|lamp', true);
assert.equal(entering.entering, true);
assert.equal(state.renderedSources.has('s|lamp'), true);
transitionGlowSource(state, host, 's|lamp', false);
assert.equal(state.fadeTimers.has('s|lamp'), true);
for (let index = 0; index < 300; index++) {
writeGlowClip(state, `clip-${index}`, { lit: [`M ${index} 0 Z`] }, 256);
}
assert.equal(state.clipCache.size, 256);
assert.equal(readGlowClip(state, 'clip-299').hit, true);
assert.equal(readGlowClip(state, 'clip-0').hit, false);
disposeGlowRuntime(state, host);
assert.equal(state.renderedSources.size, 0);
assert.equal(state.clipCache.size, 0);
assert.equal(state.fadeTimers.size, 0);
assert.equal(timers.size, 0);
assert.equal(rafs.size, 0);
});
test('space card exposes one explicit default-off visual-editor flag', () => {
const card = readFileSync(new URL('../src/space-card.ts', import.meta.url), 'utf8');
const editor = readFileSync(new URL('../src/space-editor.ts', import.meta.url), 'utf8');
assert.match(card, /light_pools\?: boolean/);
assert.match(card, /light_pools: false/);
assert.match(card, /lightPools: this\._config\.light_pools === true/);
assert.match(editor, /name: 'light_pools', selector: \{ boolean: \{\} \}/);
assert.match(editor, /light_pools: t\(L, 'editor\.light_pools'\)/);
});
+21 -14
View File
@@ -760,34 +760,41 @@ test('golden overrides fail closed on misspelled fixture references', () => {
test('a light source paints exactly one region: the floor it can see', () => {
const source = readHouseplanProductionSource();
const glow = readFileSync(new URL('../src/glow-scene.ts', import.meta.url), 'utf8');
// One region per source, and it is the visibility polygon clipped to floor.
assert.match(source, /visibilityPolygon\(\[pos\.x, pos\.y\], R, occluders/);
assert.match(source,
/lit: seen\.length >= 3\s*\? intersectionPaths\(\[seen\], floor, \{/);
assert.match(source, /onBoundsFailure: \(\{ boundIndex, phase \}\) =>/,
assert.match(glow,
/visibilityPolygon\(\s*\[input\.source\.x, input\.source\.y\], input\.radius/);
assert.match(glow,
/lit: seen\.length >= 3\s*\? intersectionPaths\(\[seen\], input\.scene\.floor, \{/);
assert.match(glow, /onBoundsFailure: \(\{ boundIndex, phase \}\) =>/,
'room-local boolean failures must be observable without restoring an unclipped fan');
assert.match(source, /<circle class="glow-pool"/);
assert.match(glow, /<circle class="glow-pool"/);
assert.match(source, /return renderGlowPools\(\{/,
'the full card must consume the shared pool renderer');
// No second layer of LIGHT may come back: a spill path or a shadow mask of
// its own is how the rendered result and the computed geometry got to
// disagree (#71, #73). The single blur below is the penumbra of the one
// region, measured in screen pixels and applied in one pass.
const layer = source.slice(source.indexOf('_renderGlowLayer'));
const glow = layer.slice(0, layer.indexOf('_renderAlignDialog'));
assert.doesNotMatch(glow, /glow-spill|glow-shadow|glow-blocker/);
// Exactly one blur, for the whole layer, sized in screen pixels.
assert.equal((glow.match(/<feGaussianBlur/g) || []).length, 1);
assert.doesNotMatch(glow, /<mask /);
assert.match(source, /const nextFeather = GLOW_EDGE_FEATHER_PX \/ 2 \/ \(perUnit > 0 \? perUnit : 1\)/);
assert.match(source, /const featherEnabled = !this\._pinchStart && !this\._panStart[\s\S]*_glowFeatherSuspendUntil/);
assert.match(source, /filter=\$\{featherEnabled \? 'url\(#hp-glowfeather\)' : nothing\}/,
assert.match(glow, /const next = GLOW_EDGE_FEATHER_PX \/ 2 \/ \(perUnit > 0 \? perUnit : 1\)/);
assert.match(source,
/resolveGlowFeather\(\s*this\._glowRuntimeState, perUnit, !this\._pinchStart && !this\._panStart/);
assert.match(glow,
/filter=\$\{input\.featherEnabled \? 'url\(#hp-glowfeather\)' : nothing\}/,
'the expensive whole-layer filter must be bypassed during a viewport gesture/transition');
// Barriers are keyed by their own content: `_cfgEpoch` lags behind geometry
// edited in place, and a stale barrier set lights straight through a wall.
assert.doesNotMatch(glow, /_cfgEpoch/);
assert.match(source, /const geometryFingerprint = contentFingerprint\(\[raw, this\._cellCm, this\._gridPitch\]\)/);
assert.match(source, /const fingerprint = contentFingerprint\(\[geometryFingerprint, openingStateSignature\]\)/);
assert.match(source, /const cacheKey = `\$\{space\.id\}\|\$\{fingerprint\}`/);
assert.match(source, /sharedFingerprint === geometryFingerprint/,
assert.match(glow,
/const geometryFingerprint = contentFingerprint\(\[\s*input\.rawSpaceConfig, input\.cellCm, input\.gridPitch/);
assert.match(glow,
/fingerprint: contentFingerprint\(\[geometryFingerprint, openingStateSignature\]\)/);
assert.match(glow,
/const cacheKey = `\$\{input\.space\.id\}\|\$\{revision\.fingerprint\}`/);
assert.match(glow, /sharedFingerprint === revision\.geometryFingerprint/,
'an in-place edit must never recut stale shared masonry');
});
+16 -4
View File
@@ -5,6 +5,7 @@ import {
assertCardContract,
GLOW_CARD_CONTRACT,
LARGE_HOUSE_CARD_CONTRACT,
SPACE_GLOW_CARD_CONTRACT,
} from '../demo/performance/card-contract.mjs';
import { readHouseplanProductionSource } from './houseplan-source.mjs';
@@ -31,7 +32,10 @@ test('large-house benchmark declares every private card member it consumes', ()
});
test('Glow benchmark declares every private card member it consumes', () => {
const declared = declaredMembers(GLOW_CARD_CONTRACT);
const declared = new Set([
...declaredMembers(GLOW_CARD_CONTRACT),
...declaredMembers(SPACE_GLOW_CARD_CONTRACT),
]);
assert.deepEqual(
directCardMembers('demo/benchmark_glow.mjs').filter((name) => !declared.has(name)),
[],
@@ -46,6 +50,11 @@ test('performance contracts reference real production members', () => {
`${contract.label} declares missing production member ${name}`);
}
}
const staticSource = readFileSync(new URL('../src/space-card.ts', import.meta.url), 'utf8');
for (const name of declaredMembers(SPACE_GLOW_CARD_CONTRACT)) {
assert.match(staticSource, new RegExp(`\\b(?:private\\s+(?:(?:declare|readonly|get)\\s+)*|get\\s+)${name}\\b`),
`${SPACE_GLOW_CARD_CONTRACT.label} declares missing production member ${name}`);
}
});
test('contract accepts recent optional fields only when their runtime type is valid', () => {
@@ -133,10 +142,13 @@ test('wall and light geometry reuse bounded caches before structural work', () =
const light = source.slice(lightStart, lightEnd);
assert.ok(
light.indexOf('lruRead(this._lightBarrierPool, cacheKey)')
< light.indexOf('const zeroWalls = this._zeroWalls();'),
< light.indexOf('buildLightBarrierScene({'),
'a cached light barrier must return before geometry classification',
);
assert.match(light, /contentFingerprint\(\[/);
assert.match(light, /recutWallBodiesGeometry\(sharedWallGeometry, roomPassages, lightPhysical\)/);
assert.match(light, /resolveLightBarrierRevision\(\{/);
const shared = readFileSync(new URL('../src/glow-scene.ts', import.meta.url), 'utf8');
assert.match(shared, /contentFingerprint\(\[/);
assert.match(shared,
/recutWallBodiesGeometry\(input\.sharedWallGeometry, roomPassages, opaqueBodies\)/);
assert.match(light, /lruWrite\(this\._lightBarrierPool, cacheKey, entry, 8\)/);
});
+4 -1
View File
@@ -13,6 +13,7 @@ test('ordinary Validate keeps only the bounded candidate performance smoke', ()
'--variants=60 --samples=3 --warmups=1',
'--absolute-only',
'budgets-glow-smoke.json',
'budgets-space-glow-smoke.json',
'cancel-in-progress: true',
]) assert.ok(workflow.includes(contract), `missing fast-gate contract: ${contract}`);
assert.ok(!workflow.includes('Capture base and candidate profiles'));
@@ -34,13 +35,15 @@ test('full performance is isolated to stable, scheduled and manual entry points'
'- plan-snap',
'- blend',
'- overlay',
'- space-default',
'- space-glow',
'PROFILE: ${{ matrix.profile }}',
'name: full-performance-${{ matrix.profile }}',
'--samples=7 --warmups=1',
]) assert.ok(workflow.includes(contract), `missing full-gate contract: ${contract}`);
assert.ok(workflow.includes('if [ -f baseline/scripts/bundle-sync.mjs ]; then'));
assert.equal((workflow.match(/--samples=7 --warmups=1/g) || []).length, 10);
assert.equal((workflow.match(/--samples=7 --warmups=1/g) || []).length, 14);
const release = readWorkflow('release.yml');
assert.ok(release.includes('if: ${{ !github.event.release.prerelease }}'));
+1 -1
View File
@@ -15,7 +15,7 @@
"src/ha-binding-status.ts",
"src/integration-provider.ts", "src/vacuum.ts",
"src/sun.ts",
"src/light-visibility.ts",
"src/light-visibility.ts", "src/glow-scene.ts",
"src/resize.ts", "src/resize-labels.ts", "src/resize-controller.ts", "src/wall-record-preservation.ts",
"src/rules.ts",
"src/devices.ts", "src/device-inbox.ts",