From 609872b7b817ba07ade6c9584dc6dd3c11cc97f1 Mon Sep 17 00:00:00 2001 From: JB Date: Sat, 4 Jul 2026 11:37:25 +0300 Subject: [PATCH] ux v1.4.2: grid pitch halved (GRID_N=120), grid dots rendered above plan, dacha rooms snapped to grid nodes (instance data via config/set) --- custom_components/houseplan/const.py | 2 +- .../houseplan/frontend/houseplan-card.js | 14 ++++++++------ dist/houseplan-card.js | 14 ++++++++------ docs/CHANGELOG.md | 9 +++++++++ docs/DEVELOPMENT.md | 9 +++++++++ package.json | 2 +- src/houseplan-card.ts | 18 ++++++++++-------- 7 files changed, 46 insertions(+), 22 deletions(-) diff --git a/custom_components/houseplan/const.py b/custom_components/houseplan/const.py index 8fd1e0d..b06130b 100755 --- a/custom_components/houseplan/const.py +++ b/custom_components/houseplan/const.py @@ -8,7 +8,7 @@ FRONTEND_URL = "/houseplan_files/houseplan-card.js" PLANS_URL = "/houseplan_files/plans" PLANS_DIR = "houseplan/plans" # относительно каталога конфигурации HA CONF_ADMIN_ONLY = "admin_only" -VERSION = "1.4.0" +VERSION = "1.4.2" DEFAULT_CONFIG: dict = { "spaces": [], diff --git a/custom_components/houseplan/frontend/houseplan-card.js b/custom_components/houseplan/frontend/houseplan-card.js index ddf893e..8dd2d0d 100755 --- a/custom_components/houseplan/frontend/houseplan-card.js +++ b/custom_components/houseplan/frontend/houseplan-card.js @@ -4,7 +4,7 @@ const I=globalThis,M=I.ShadowRoot&&(void 0===I.ShadyCSS||I.ShadyCSS.nativeShadow .schema=${aI} .computeLabel=${I=>sI[I.name]||I.name} @value-changed=${this._valueChanged} - >`:v}_valueChanged(I){const M={...this._config,...I.detail.value},i=new Event("config-changed",{bubbles:!0,composed:!0});i.detail={config:M},this.dispatchEvent(i)}}mI.properties={hass:{attribute:!1},_config:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",mI);const HI="houseplan_card_layout_v1",AI=1e3,LI={f1:[1196.6656,1467.26],f2:[1170.0986,1073]},PI=I=>{const M=Math.max(0,Math.min(120,(I-40)/140*120));return`hsl(${Math.round(M)}, 85%, 55%)`},xI=(I,M,i)=>{const N=new Event(M,{bubbles:!0,composed:!0});N.detail=i??{},I.dispatchEvent(N)},eI=(I,M)=>{let i;return(...N)=>{clearTimeout(i),i=window.setTimeout(()=>I(...N),M)}};class bI extends gI{constructor(){super(...arguments),this._space="f1",this._edit=!1,this._layout={},this._serverStorage=!1,this._loaded=!1,this._serverCfg=null,this._devices=[],this._regSignature="",this._defPos={},this._menuDev=null,this._menuXY={x:0,y:0},this._tip=null,this._selId=null,this._toast="",this._migrating=!1,this._markup=!1,this._tool="draw",this._path=[],this._pathSegs=[],this._cursorPt=null,this._areaSel="",this._nameSel="",this._roomDialog=!1,this._keyHandler=I=>this._onKey(I),this._drag=null,this._persistLayout=eI(()=>{this._serverStorage?this.hass.callWS({type:"houseplan/layout/set",layout:this._layout}).catch(I=>this._showToast("Не удалось сохранить на сервере: "+(I?.message||I))):localStorage.setItem(HI,JSON.stringify(this._layout))},600),this._saveConfig=eI(()=>{this._serverCfg&&this.hass.callWS({type:"houseplan/config/set",config:this._serverCfg}).catch(I=>this._showToast("Не удалось сохранить конфиг: "+(I?.message||I)))},500)}connectedCallback(){super.connectedCallback(),window.addEventListener("keydown",this._keyHandler)}disconnectedCallback(){window.removeEventListener("keydown",this._keyHandler),super.disconnectedCallback()}_onKey(I){if(!this._markup)return;return"Escape"===I.key||(I.ctrlKey||I.metaKey)&&"z"===I.key.toLowerCase()?this._roomDialog?(I.preventDefault(),void this._roomDialogCancel()):void("draw"===this._tool&&this._path.length&&(I.preventDefault(),this._undoPoint())):void 0}_undoPoint(){if(!this._path.length)return;if(1===this._path.length)return this._path=[],void(this._pathSegs=[]);const I=this._pathSegs[this._pathSegs.length-1];this._pathSegs=this._pathSegs.slice(0,-1),I&&this._removeSegmentByKey(I),this._path=this._path.slice(0,-1)}_removeSegmentByKey(I){const M=this._curSpaceCfg;if(!M?.segments)return;const i=this._segments.findIndex(M=>this._segKey([M[0],M[1]],[M[2],M[3]])===I);i>=0&&(M.segments.splice(i,1),this._saveConfig())}static getConfigElement(){return document.createElement("houseplan-card-editor")}static getStubConfig(){return{type:"custom:houseplan-card",title:"План дома"}}setConfig(I){this._config={icon_size:2.5,show_temperature:!0,live_states:!0,show_signal:!0,...I},I.default_floor&&(this._space=I.default_floor)}getCardSize(){return 12}get _norm(){return!(!this._serverCfg||!this._serverCfg.spaces.length)}get _model(){if(this._norm)return this._serverCfg.spaces.map(I=>{const M=AI/I.aspect;return{id:I.id,title:I.title,vb:[I.view_box[0]*AI,I.view_box[1]*M,I.view_box[2]*AI,I.view_box[3]*M],bg:I.plan_url?{href:I.plan_url,x:0,y:0,w:AI,h:M}:null,rooms:I.rooms.map(I=>({id:I.id,name:I.name,area:I.area??null,x:null!=I.x?I.x*AI:void 0,y:null!=I.y?I.y*M:void 0,w:null!=I.w?I.w*AI:void 0,h:null!=I.h?I.h*M:void 0,poly:I.poly?I.poly.map(I=>[I[0]*AI,I[1]*M]):void 0}))}});const I=this._config?.rooms?.length?this._config.rooms:wI;return Object.keys(zI).map(M=>{const i=dI[M],N=kI[M];return{id:M,title:OI[M]||M,vb:zI[M],bg:N&&i?{href:N,x:i[0],y:i[1],w:i[2],h:i[3]}:null,rooms:I.filter(I=>I.floor===M).map(I=>({name:I.name,area:I.area||null,x:I.x,y:I.y,w:I.w,h:I.h}))}})}_spaceModel(I){const M=this._model;return M.find(M=>M.id===(I??this._space))||M[0]}get _areaToSpace(){const I={};for(const M of this._model)for(const i of M.rooms)i.area&&(I[i.area]={space:M.id,room:i});return I}get _settings(){return this._serverCfg?.settings||{}}get _excluded(){const I=this._settings.exclude_integrations;return I?new Set(I):yI}willUpdate(I){I.has("hass")&&this.hass&&(this._loaded||(this._loaded=!0,this._loadFromServer()),this._maybeRebuildDevices())}async _loadFromServer(){try{const[I,M]=await Promise.all([this.hass.callWS({type:"houseplan/config/get"}),this.hass.callWS({type:"houseplan/layout/get"})]);this._serverStorage=!0;const i=I?.config;this._serverCfg=i&&Array.isArray(i.spaces)?i:null,this._layout=M?.layout||{},this._norm&&!this._model.find(I=>I.id===this._space)&&(this._space=this._model[0]?.id||this._space)}catch(I){this._serverStorage=!1,this._serverCfg=null;try{this._layout=JSON.parse(localStorage.getItem(HI)||"{}")||{}}catch{this._layout={}}this._showToast("Интеграция houseplan не найдена — позиции сохраняются локально")}this._regSignature="",this.requestUpdate()}_maybeRebuildDevices(){const I=this.hass;if(!I?.devices||!I?.entities||!I?.areas)return;const M=Object.keys(I.devices).length+":"+Object.keys(I.entities).length+":"+Object.keys(I.areas).length+":"+(this._norm?"n":"l");M===this._regSignature&&this._devices.length||(this._regSignature=M,this._devices=this._buildDevices(),this._defPos=this._defaultPositions())}_entitiesByDevice(){const I={};for(const[M,i]of Object.entries(this.hass.entities))i?.device_id&&(I[i.device_id]=I[i.device_id]||[]).push(M);return I}_domainOfDevice(I,M){if(I.identifiers?.[0]?.[0])return I.identifiers[0][0];for(const I of M){const M=this.hass.entities[I]?.platform;if(M)return M}return""}_primaryEntity(I,M){const i=I.map(I=>({eid:I,reg:this.hass.entities[I],st:this.hass.states[I]})).filter(I=>I.reg&&!I.reg.hidden),N=i.filter(I=>!I.reg.entity_category),T=N.length?N:i;if("mdi:thermometer"===M||"mdi:air-filter"===M){const I=T.find(I=>this._isTempEntity(I.eid));if(I)return I.eid}for(const I of tI){const M=T.find(M=>M.eid.split(".")[0]===I);if(M)return M.eid}return T[0]?.eid}_isTempEntity(I){const M=this.hass.states[I];if(!M)return/_temperature$/.test(I);const i=M.attributes||{};return"temperature"===i.device_class||/°C|°F/.test(i.unit_of_measurement||"")||/_temperature$/.test(I)}_lqiFor(I){const M=[];for(const i of I){const I=this.hass.states[i];if(!(/_linkquality$/.test(i)||"lqi"===(I?.attributes?.unit_of_measurement||""))||!I)continue;const N=parseFloat(I.state);isNaN(N)||M.push(N)}return M.length?Math.round(M.reduce((I,M)=>I+M,0)/M.length):null}_roomLqi(I){if(!I)return null;const M=[];for(const i of this._devices){if(i.area!==I||i.virtual)continue;const N=this._lqiFor(i.entities);null!=N&&M.push(N)}return M.length?Math.round(M.reduce((I,M)=>I+M,0)/M.length):null}_tempFor(I){for(const M of I){if(!this._isTempEntity(M))continue;const I=this.hass.states[M];if(!I)continue;const i=parseFloat(I.state);if(!isNaN(i))return Math.round(10*i)/10}return null}_buildDevices(){const I=this.hass,M=this._areaToSpace,i=this._serverCfg?.device_overrides||{},N=!1!==this._settings.group_lights,T=this._excluded,D=this._entitiesByDevice(),j={};for(const M of Object.values(I.devices))"Group"===M.model&&M.area_id&&(j[M.area_id]=M);const g={},c=[];for(const N of Object.values(I.devices)){const I=N.area_id;if(!I||!M[I])continue;if("service"===N.entry_type)continue;const j=i[N.id]||{};if(j.hidden)continue;const w=D[N.id]||[],z=this._domainOfDevice(N,w);if(T.has(z))continue;if("Group"===N.model)continue;if(/scene/i.test(N.model||""))continue;if(/bridge/i.test((N.model||"")+(N.name||"")))continue;if("myheat"===z&&N.via_device_id)continue;const O=(j.name||N.name_by_user||N.name||"без имени").trim(),k=O+"|"+I;if(g[k])continue;g[k]=1;let d=ZI(O,N.model);w.some(I=>I.startsWith("lock."))&&(d="mdi:lock"),j.icon&&(d=j.icon);const y={id:N.id,name:O,model:N.model||"",area:I,space:M[I].space,icon:d,entities:w};y.primary=this._primaryEntity(w,d),"mdi:thermometer"!==d&&"mdi:air-filter"!==d||(y.temp=this._tempFor(w)),c.push(y)}const w=[];if(N){const I={};for(const M of c)"mdi:lightbulb"===M.icon?(I[M.area]=I[M.area]||[]).push(M):w.push(M);for(const i of Object.keys(I)){const N=I[i];if(N.length<2){w.push(...N);continue}const T=j[i],g={id:"grp_"+i,name:SI[i]||"Лампы",model:"группа · "+N.length+" шт",area:i,space:M[i].space,icon:"mdi:lightbulb-group",entities:N.flatMap(I=>I.entities),link:T?.id||null,members:N.map(I=>({id:I.id,name:I.name,primary:I.primary}))};if(T){const I=D[T.id]||[];g.linkPrimary=this._primaryEntity(I,"mdi:lightbulb")}w.push(g)}}else w.push(...c);for(const I of this._serverCfg?.virtual_devices||[])w.push({id:"virt_"+I.id,name:I.name,model:I.note||"виртуальное устройство",area:"",space:I.space,icon:I.icon,entities:I.entity_id?[I.entity_id]:[],primary:I.entity_id||void 0,virtual:I});return w}_defaultPositions(){const I={};for(const M of this._model)for(const i of M.rooms){if(!i.area)continue;const N=this._devices.filter(I=>I.area===i.area&&I.space===M.id);if(!N.length)continue;const T=.12*Math.min(i.w,i.h),D=i.w-2*T,j=i.h-2*T,g=Math.max(1,Math.round(Math.sqrt(N.length*D/Math.max(j,1)))),c=Math.ceil(N.length/g),w=D/g,z=j/Math.max(c,1);N.forEach((M,N)=>{const D=N%g,j=Math.floor(N/g);I[M.id]={x:i.x+T+w*(D+.5),y:i.y+T+z*(j+.5)}})}return I}_pos(I){const M=this._spaceModel(I.space);if(M.vb,I.virtual){const M=this._norm?AI/(this._serverCfg.spaces.find(M=>M.id===I.space)?.aspect||1):0;return{x:I.virtual.x*(this._norm?AI:1),y:I.virtual.y*(this._norm?M:1)}}const i=this._layout[I.id];if(i)if(this._norm){if(i.s===I.space){const M=this._serverCfg.spaces.find(M=>M.id===I.space)?.aspect||1;return{x:i.x*AI,y:i.y*(AI/M)}}}else if(void 0===i.s)return{x:i.x,y:i.y};if(this._defPos[I.id])return this._defPos[I.id];const N=M.vb;return{x:N[0]+N[2]/2,y:N[1]+N[3]/2}}_savePos(I,M,i){if(this._norm){const N=this._serverCfg.spaces.find(M=>M.id===I.space)?.aspect||1;this._layout={...this._layout,[I.id]:{s:I.space,x:M/AI,y:i/(AI/N)}}}else this._layout={...this._layout,[I.id]:{x:Math.round(M),y:Math.round(i)}};this._persistLayout()}_stateClass(I){if(!this._config?.live_states)return"";const M=I=>I?this.hass.states[I]:void 0,i=I.members?I.members.map(I=>M(I.primary)).filter(Boolean):[M(I.primary)].filter(Boolean);if(!i.length)return"";if(i.every(I=>"unavailable"===I.state))return"unavail";const N=I.members&&i.find(I=>"on"===I.state)||i[0],T=N.entity_id.split(".")[0];if(["light","switch","fan","humidifier"].includes(T))return"on"===N.state?"on":"";if("cover"===T||"valve"===T)return["open","opening"].includes(N.state)?"open":"";if("lock"===T)return["unlocked","open"].includes(N.state)?"open":"";if("binary_sensor"===T){const I=N.attributes?.device_class;if(["door","window","garage_door","opening","gas","smoke","moisture","problem"].includes(I))return"on"===N.state?"open":""}return"media_player"===T?["playing","on"].includes(N.state)?"on":"":"vacuum"===T&&["cleaning","returning"].includes(N.state)?"on":""}_liveTemp(I){return this._config?.show_temperature?"mdi:thermometer"!==I.icon&&"mdi:air-filter"!==I.icon?null:this._tempFor(I.entities):null}_openMoreInfo(I){I?xI(this,"hass-more-info",{entityId:I}):this._showToast("У устройства нет подходящей сущности")}_clickDevice(I,M){if(I.stopPropagation(),!this._drag?.moved)if(this._edit)this._selId=M.id;else{if(!M.virtual||M.primary)return M.members?(this._menuDev=M,this._menuXY={x:I.clientX,y:I.clientY},void(this._tip=null)):void this._openMoreInfo(M.primary);this._showToast(M.name+(M.virtual.note?" — "+M.virtual.note:""))}}_clickRoom(I){var M;!this._edit&&I.area&&(M="/config/areas/area/"+I.area,history.pushState(null,"",M),xI(window,"location-changed",{replace:!1}))}_pointerDown(I,M){if(!this._edit||M.virtual)return;I.preventDefault();const i=this._pos(M);this._drag={id:M.id,sx:I.clientX,sy:I.clientY,ox:i.x,oy:i.y,moved:!1},I.target.setPointerCapture(I.pointerId),this._tip=null}_pointerMove(I,M){if(!this._drag||this._drag.id!==M.id)return;const i=this.renderRoot.querySelector(".stage");if(!i)return;const N=this._spaceModel().vb,T=i.getBoundingClientRect(),D=(I.clientX-this._drag.sx)/T.width*N[2],j=(I.clientY-this._drag.sy)/T.height*N[3];Math.abs(I.clientX-this._drag.sx)+Math.abs(I.clientY-this._drag.sy)>3&&(this._drag.moved=!0);const g=.008*Math.min(N[2],N[3]),c=Math.max(N[0]+g,Math.min(N[0]+N[2]-g,this._drag.ox+D)),w=Math.max(N[1]+g,Math.min(N[1]+N[3]-g,this._drag.oy+j));this._savePos(M,c,w)}_pointerUp(I,M){if(!this._drag||this._drag.id!==M.id)return;const i=this._drag.moved;this._drag=i?this._drag:null,i&&(this._selId=M.id,window.setTimeout(()=>this._drag=null,0))}_applyXY(I,M){if(!this._selId)return;const i=parseFloat(M);if(isNaN(i))return;const N=this._devices.find(I=>I.id===this._selId);if(!N||N.virtual)return;const T={...this._pos(N)};T[I]=i,this._savePos(N,T.x,T.y)}_resetLayout(){confirm("Сбросить позиции всех иконок к авто-раскладке?")&&(this._layout={},this._persistLayout())}_showToast(I){this._toast=I,clearTimeout(this._toastTimer),this._toastTimer=window.setTimeout(()=>{this._toast=""},3500)}_showTip(I,M,i,N){this._drag||(this._tip={x:I.clientX,y:I.clientY,title:M,meta:i,lqi:N})}get _gridPitch(){return AI/60}get _curSpaceCfg(){return this._serverCfg?.spaces.find(I=>I.id===this._space)}get _spaceH(){const I=this._curSpaceCfg;return I?AI/I.aspect:AI}get _segments(){const I=this._curSpaceCfg,M=this._spaceH;return(I?.segments||[]).map(I=>[I[0]*AI,I[1]*M,I[2]*AI,I[3]*M])}_toggleMarkup(){this._norm?(this._markup=!this._markup,this._edit=!1,this._path=[],this._cursorPt=null,this._tool="draw"):this._showToast("Разметка доступна после переноса конфига на сервер (режим правки → «На сервер»)")}_svgPoint(I){const M=this.renderRoot.querySelector(".stage"),i=this._spaceModel().vb,N=M.getBoundingClientRect();return[i[0]+(I.clientX-N.left)/N.width*i[2],i[1]+(I.clientY-N.top)/N.height*i[3]]}_snap(I){const M=this._gridPitch;return[Math.round(I[0]/M)*M,Math.round(I[1]/M)*M]}_samePt(I,M){return Math.abs(I[0]-M[0])<.001&&Math.abs(I[1]-M[1])<.001}_segKey(I,M){const[i,N]=I[0]this._segKey([I[0],I[1]],[I[2],I[3]])===T);return!D&&(i.segments=i.segments||[],i.segments.push([I[0]/AI,I[1]/N,M[0]/AI,M[1]/N]),this._saveConfig(),!0)}_distToSeg(I,M){const[i,N]=I,[T,D,j,g]=M,c=j-T,w=g-D;let z=((i-T)*c+(N-D)*w)/(c*c+w*w||1);z=Math.max(0,Math.min(1,z));const O=T+z*c,k=D+z*w;return Math.hypot(i-O,N-k)}_pointInRoom(I,M){if(M.poly){let i=!1;const N=M.poly;for(let M=0,T=N.length-1;MI[1]!=c>I[1]&&I[0]<(g-D)*(I[1]-j)/(c-j)+D&&(i=!i)}return i}return null!=M.x&&I[0]>=M.x&&I[0]<=M.x+M.w&&I[1]>=M.y&&I[1]<=M.y+M.h}_markupClick(I){if(!this._markup)return;const M=this._svgPoint(I);if("erase"===this._tool){const I=this._curSpaceCfg;if(!I?.segments?.length)return;const i=this._segments;let N=-1,T=.5*this._gridPitch;return i.forEach((I,i)=>{const D=this._distToSeg(M,I);D=0&&(I.segments.splice(N,1),this._saveConfig(),this.requestUpdate()))}if("delroom"===this._tool){const I=[...this._spaceModel().rooms].reverse().find(I=>this._pointInRoom(M,I));if(!I)return;if(!confirm(`Удалить комнату «${I.name}»?`))return;const i=this._curSpaceCfg;return i.rooms=i.rooms.filter(M=>M.id!==I.id),this._saveConfig(),this._regSignature="",this._maybeRebuildDevices(),void this.requestUpdate()}const i=this._snap(M);if(!this._path.length)return this._path=[i],void(this._pathSegs=[]);const N=this._path[this._path.length-1];if(this._samePt(i,N))return;const T=this._addSegment(N,i);this._pathSegs=[...this._pathSegs,T?this._segKey(N,i):null],this._path=[...this._path,i],this._path.length>=4&&this._samePt(i,this._path[0])&&(this._cursorPt=null,this._nameSel="",this._areaSel="",this._roomDialog=!0)}get _contourClosed(){return this._path.length>=4&&this._samePt(this._path[0],this._path[this._path.length-1])}_markupMove(I){this._markup&&"draw"===this._tool&&this._path.length&&!this._contourClosed&&(this._cursorPt=this._snap(this._svgPoint(I)))}_saveRoom(){if(!this._contourClosed||!this._areaSel&&!this._nameSel)return;const I=this._curSpaceCfg;if(!I)return;const M=this._spaceH,i=this._path.slice(0,-1),N=this._areaSel?this.hass.areas[this._areaSel]?.name:"";I.rooms.push({id:"r"+Date.now().toString(36),name:this._nameSel||N||"Комната",area:this._areaSel||null,poly:i.map(I=>[I[0]/AI,I[1]/M])}),this._saveConfig(),this._path=[],this._pathSegs=[],this._areaSel="",this._nameSel="",this._roomDialog=!1,this._regSignature="",this._maybeRebuildDevices(),this._showToast("Комната сохранена")}_cancelPath(){this._path=[],this._pathSegs=[],this._cursorPt=null,this._roomDialog=!1}_roomDialogCancel(){this._roomDialog=!1,this._undoPoint()}get _freeAreas(){const I=new Set;for(const M of this._serverCfg?.spaces||[])for(const i of M.rooms||[])i.area&&I.add(i.area);return Object.values(this.hass?.areas||{}).filter(M=>!I.has(M.area_id)).sort((I,M)=>(I.name||"").localeCompare(M.name||""))}async _migrateToServer(){if(this._serverStorage&&!this._norm&&!this._migrating&&confirm("Перенести текущую конфигурацию (планы, комнаты, раскладку) на сервер HA?")){this._migrating=!0;try{const I=[];for(const M of Object.keys(zI)){const i=zI[M],N=dI[M]||i,[T,D,j,g]=N;let c=null,w=j/g;const z=kI[M];if(z&&dI[M]){const I=LI[M];I&&(w=I[0]/I[1]);const i=z.split(",")[1];c=(await this.hass.callWS({type:"houseplan/plan/set",space_id:M,ext:"svg",data:i})).url}const O=wI.filter(I=>I.floor===M).map((I,i)=>({id:M+"_r"+i,name:I.name,area:I.area||null,x:(I.x-T)/j,y:(I.y-D)/g,w:I.w/j,h:I.h/g}));I.push({id:M,title:OI[M]||M,plan_url:c,aspect:w,view_box:[(i[0]-T)/j,(i[1]-D)/g,i[2]/j,i[3]/g],rooms:O})}const M={spaces:I,device_overrides:{},virtual_devices:[],settings:{exclude_integrations:[...yI],group_lights:!0}},i={};for(const I of this._devices){if(I.virtual)continue;const M=this._layout[I.id]||this._defPos[I.id];if(!M)continue;const N=dI[I.space]||zI[I.space];i[I.id]={s:I.space,x:(M.x-N[0])/N[2],y:(M.y-N[1])/N[3]}}await this.hass.callWS({type:"houseplan/config/set",config:M}),await this.hass.callWS({type:"houseplan/layout/set",layout:i}),this._serverCfg=M,this._layout=i,this._regSignature="",this._maybeRebuildDevices(),this._showToast("Конфигурация перенесена на сервер — карта работает от server config")}catch(I){this._showToast("Ошибка миграции: "+(I?.message||I))}finally{this._migrating=!1}}}render(){if(!this._config||!this.hass)return v;const I=this._model;if(!I.length)return r`
Нет настроенных пространств
`;const M=this._spaceModel(),i=M.vb,N=this._devices.filter(I=>I.space===M.id),T=this._config.icon_size??2.5,D=T>8?2.5:T;return r` + >`:v}_valueChanged(I){const M={...this._config,...I.detail.value},i=new Event("config-changed",{bubbles:!0,composed:!0});i.detail={config:M},this.dispatchEvent(i)}}mI.properties={hass:{attribute:!1},_config:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",mI);const HI="houseplan_card_layout_v1",AI=1e3,LI={f1:[1196.6656,1467.26],f2:[1170.0986,1073]},PI=I=>{const M=Math.max(0,Math.min(120,(I-40)/140*120));return`hsl(${Math.round(M)}, 85%, 55%)`},xI=(I,M,i)=>{const N=new Event(M,{bubbles:!0,composed:!0});N.detail=i??{},I.dispatchEvent(N)},eI=(I,M)=>{let i;return(...N)=>{clearTimeout(i),i=window.setTimeout(()=>I(...N),M)}};class bI extends gI{constructor(){super(...arguments),this._space="f1",this._edit=!1,this._layout={},this._serverStorage=!1,this._loaded=!1,this._serverCfg=null,this._devices=[],this._regSignature="",this._defPos={},this._menuDev=null,this._menuXY={x:0,y:0},this._tip=null,this._selId=null,this._toast="",this._migrating=!1,this._markup=!1,this._tool="draw",this._path=[],this._pathSegs=[],this._cursorPt=null,this._areaSel="",this._nameSel="",this._roomDialog=!1,this._keyHandler=I=>this._onKey(I),this._drag=null,this._persistLayout=eI(()=>{this._serverStorage?this.hass.callWS({type:"houseplan/layout/set",layout:this._layout}).catch(I=>this._showToast("Не удалось сохранить на сервере: "+(I?.message||I))):localStorage.setItem(HI,JSON.stringify(this._layout))},600),this._saveConfig=eI(()=>{this._serverCfg&&this.hass.callWS({type:"houseplan/config/set",config:this._serverCfg}).catch(I=>this._showToast("Не удалось сохранить конфиг: "+(I?.message||I)))},500)}connectedCallback(){super.connectedCallback(),window.addEventListener("keydown",this._keyHandler)}disconnectedCallback(){window.removeEventListener("keydown",this._keyHandler),super.disconnectedCallback()}_onKey(I){if(!this._markup)return;return"Escape"===I.key||(I.ctrlKey||I.metaKey)&&"z"===I.key.toLowerCase()?this._roomDialog?(I.preventDefault(),void this._roomDialogCancel()):void("draw"===this._tool&&this._path.length&&(I.preventDefault(),this._undoPoint())):void 0}_undoPoint(){if(!this._path.length)return;if(1===this._path.length)return this._path=[],void(this._pathSegs=[]);const I=this._pathSegs[this._pathSegs.length-1];this._pathSegs=this._pathSegs.slice(0,-1),I&&this._removeSegmentByKey(I),this._path=this._path.slice(0,-1)}_removeSegmentByKey(I){const M=this._curSpaceCfg;if(!M?.segments)return;const i=this._segments.findIndex(M=>this._segKey([M[0],M[1]],[M[2],M[3]])===I);i>=0&&(M.segments.splice(i,1),this._saveConfig())}static getConfigElement(){return document.createElement("houseplan-card-editor")}static getStubConfig(){return{type:"custom:houseplan-card",title:"План дома"}}setConfig(I){this._config={icon_size:2.5,show_temperature:!0,live_states:!0,show_signal:!0,...I},I.default_floor&&(this._space=I.default_floor)}getCardSize(){return 12}get _norm(){return!(!this._serverCfg||!this._serverCfg.spaces.length)}get _model(){if(this._norm)return this._serverCfg.spaces.map(I=>{const M=AI/I.aspect;return{id:I.id,title:I.title,vb:[I.view_box[0]*AI,I.view_box[1]*M,I.view_box[2]*AI,I.view_box[3]*M],bg:I.plan_url?{href:I.plan_url,x:0,y:0,w:AI,h:M}:null,rooms:I.rooms.map(I=>({id:I.id,name:I.name,area:I.area??null,x:null!=I.x?I.x*AI:void 0,y:null!=I.y?I.y*M:void 0,w:null!=I.w?I.w*AI:void 0,h:null!=I.h?I.h*M:void 0,poly:I.poly?I.poly.map(I=>[I[0]*AI,I[1]*M]):void 0}))}});const I=this._config?.rooms?.length?this._config.rooms:wI;return Object.keys(zI).map(M=>{const i=dI[M],N=kI[M];return{id:M,title:OI[M]||M,vb:zI[M],bg:N&&i?{href:N,x:i[0],y:i[1],w:i[2],h:i[3]}:null,rooms:I.filter(I=>I.floor===M).map(I=>({name:I.name,area:I.area||null,x:I.x,y:I.y,w:I.w,h:I.h}))}})}_spaceModel(I){const M=this._model;return M.find(M=>M.id===(I??this._space))||M[0]}get _areaToSpace(){const I={};for(const M of this._model)for(const i of M.rooms)i.area&&(I[i.area]={space:M.id,room:i});return I}get _settings(){return this._serverCfg?.settings||{}}get _excluded(){const I=this._settings.exclude_integrations;return I?new Set(I):yI}willUpdate(I){I.has("hass")&&this.hass&&(this._loaded||(this._loaded=!0,this._loadFromServer()),this._maybeRebuildDevices())}async _loadFromServer(){try{const[I,M]=await Promise.all([this.hass.callWS({type:"houseplan/config/get"}),this.hass.callWS({type:"houseplan/layout/get"})]);this._serverStorage=!0;const i=I?.config;this._serverCfg=i&&Array.isArray(i.spaces)?i:null,this._layout=M?.layout||{},this._norm&&!this._model.find(I=>I.id===this._space)&&(this._space=this._model[0]?.id||this._space)}catch(I){this._serverStorage=!1,this._serverCfg=null;try{this._layout=JSON.parse(localStorage.getItem(HI)||"{}")||{}}catch{this._layout={}}this._showToast("Интеграция houseplan не найдена — позиции сохраняются локально")}this._regSignature="",this.requestUpdate()}_maybeRebuildDevices(){const I=this.hass;if(!I?.devices||!I?.entities||!I?.areas)return;const M=Object.keys(I.devices).length+":"+Object.keys(I.entities).length+":"+Object.keys(I.areas).length+":"+(this._norm?"n":"l");M===this._regSignature&&this._devices.length||(this._regSignature=M,this._devices=this._buildDevices(),this._defPos=this._defaultPositions())}_entitiesByDevice(){const I={};for(const[M,i]of Object.entries(this.hass.entities))i?.device_id&&(I[i.device_id]=I[i.device_id]||[]).push(M);return I}_domainOfDevice(I,M){if(I.identifiers?.[0]?.[0])return I.identifiers[0][0];for(const I of M){const M=this.hass.entities[I]?.platform;if(M)return M}return""}_primaryEntity(I,M){const i=I.map(I=>({eid:I,reg:this.hass.entities[I],st:this.hass.states[I]})).filter(I=>I.reg&&!I.reg.hidden),N=i.filter(I=>!I.reg.entity_category),T=N.length?N:i;if("mdi:thermometer"===M||"mdi:air-filter"===M){const I=T.find(I=>this._isTempEntity(I.eid));if(I)return I.eid}for(const I of tI){const M=T.find(M=>M.eid.split(".")[0]===I);if(M)return M.eid}return T[0]?.eid}_isTempEntity(I){const M=this.hass.states[I];if(!M)return/_temperature$/.test(I);const i=M.attributes||{};return"temperature"===i.device_class||/°C|°F/.test(i.unit_of_measurement||"")||/_temperature$/.test(I)}_lqiFor(I){const M=[];for(const i of I){const I=this.hass.states[i];if(!(/_linkquality$/.test(i)||"lqi"===(I?.attributes?.unit_of_measurement||""))||!I)continue;const N=parseFloat(I.state);isNaN(N)||M.push(N)}return M.length?Math.round(M.reduce((I,M)=>I+M,0)/M.length):null}_roomLqi(I){if(!I)return null;const M=[];for(const i of this._devices){if(i.area!==I||i.virtual)continue;const N=this._lqiFor(i.entities);null!=N&&M.push(N)}return M.length?Math.round(M.reduce((I,M)=>I+M,0)/M.length):null}_tempFor(I){for(const M of I){if(!this._isTempEntity(M))continue;const I=this.hass.states[M];if(!I)continue;const i=parseFloat(I.state);if(!isNaN(i))return Math.round(10*i)/10}return null}_buildDevices(){const I=this.hass,M=this._areaToSpace,i=this._serverCfg?.device_overrides||{},N=!1!==this._settings.group_lights,T=this._excluded,D=this._entitiesByDevice(),j={};for(const M of Object.values(I.devices))"Group"===M.model&&M.area_id&&(j[M.area_id]=M);const g={},c=[];for(const N of Object.values(I.devices)){const I=N.area_id;if(!I||!M[I])continue;if("service"===N.entry_type)continue;const j=i[N.id]||{};if(j.hidden)continue;const w=D[N.id]||[],z=this._domainOfDevice(N,w);if(T.has(z))continue;if("Group"===N.model)continue;if(/scene/i.test(N.model||""))continue;if(/bridge/i.test((N.model||"")+(N.name||"")))continue;if("myheat"===z&&N.via_device_id)continue;const O=(j.name||N.name_by_user||N.name||"без имени").trim(),k=O+"|"+I;if(g[k])continue;g[k]=1;let d=ZI(O,N.model);w.some(I=>I.startsWith("lock."))&&(d="mdi:lock"),j.icon&&(d=j.icon);const y={id:N.id,name:O,model:N.model||"",area:I,space:M[I].space,icon:d,entities:w};y.primary=this._primaryEntity(w,d),"mdi:thermometer"!==d&&"mdi:air-filter"!==d||(y.temp=this._tempFor(w)),c.push(y)}const w=[];if(N){const I={};for(const M of c)"mdi:lightbulb"===M.icon?(I[M.area]=I[M.area]||[]).push(M):w.push(M);for(const i of Object.keys(I)){const N=I[i];if(N.length<2){w.push(...N);continue}const T=j[i],g={id:"grp_"+i,name:SI[i]||"Лампы",model:"группа · "+N.length+" шт",area:i,space:M[i].space,icon:"mdi:lightbulb-group",entities:N.flatMap(I=>I.entities),link:T?.id||null,members:N.map(I=>({id:I.id,name:I.name,primary:I.primary}))};if(T){const I=D[T.id]||[];g.linkPrimary=this._primaryEntity(I,"mdi:lightbulb")}w.push(g)}}else w.push(...c);for(const I of this._serverCfg?.virtual_devices||[])w.push({id:"virt_"+I.id,name:I.name,model:I.note||"виртуальное устройство",area:"",space:I.space,icon:I.icon,entities:I.entity_id?[I.entity_id]:[],primary:I.entity_id||void 0,virtual:I});return w}_defaultPositions(){const I={};for(const M of this._model)for(const i of M.rooms){if(!i.area)continue;const N=this._devices.filter(I=>I.area===i.area&&I.space===M.id);if(!N.length)continue;const T=.12*Math.min(i.w,i.h),D=i.w-2*T,j=i.h-2*T,g=Math.max(1,Math.round(Math.sqrt(N.length*D/Math.max(j,1)))),c=Math.ceil(N.length/g),w=D/g,z=j/Math.max(c,1);N.forEach((M,N)=>{const D=N%g,j=Math.floor(N/g);I[M.id]={x:i.x+T+w*(D+.5),y:i.y+T+z*(j+.5)}})}return I}_pos(I){const M=this._spaceModel(I.space);if(M.vb,I.virtual){const M=this._norm?AI/(this._serverCfg.spaces.find(M=>M.id===I.space)?.aspect||1):0;return{x:I.virtual.x*(this._norm?AI:1),y:I.virtual.y*(this._norm?M:1)}}const i=this._layout[I.id];if(i)if(this._norm){if(i.s===I.space){const M=this._serverCfg.spaces.find(M=>M.id===I.space)?.aspect||1;return{x:i.x*AI,y:i.y*(AI/M)}}}else if(void 0===i.s)return{x:i.x,y:i.y};if(this._defPos[I.id])return this._defPos[I.id];const N=M.vb;return{x:N[0]+N[2]/2,y:N[1]+N[3]/2}}_savePos(I,M,i){if(this._norm){const N=this._serverCfg.spaces.find(M=>M.id===I.space)?.aspect||1;this._layout={...this._layout,[I.id]:{s:I.space,x:M/AI,y:i/(AI/N)}}}else this._layout={...this._layout,[I.id]:{x:Math.round(M),y:Math.round(i)}};this._persistLayout()}_stateClass(I){if(!this._config?.live_states)return"";const M=I=>I?this.hass.states[I]:void 0,i=I.members?I.members.map(I=>M(I.primary)).filter(Boolean):[M(I.primary)].filter(Boolean);if(!i.length)return"";if(i.every(I=>"unavailable"===I.state))return"unavail";const N=I.members&&i.find(I=>"on"===I.state)||i[0],T=N.entity_id.split(".")[0];if(["light","switch","fan","humidifier"].includes(T))return"on"===N.state?"on":"";if("cover"===T||"valve"===T)return["open","opening"].includes(N.state)?"open":"";if("lock"===T)return["unlocked","open"].includes(N.state)?"open":"";if("binary_sensor"===T){const I=N.attributes?.device_class;if(["door","window","garage_door","opening","gas","smoke","moisture","problem"].includes(I))return"on"===N.state?"open":""}return"media_player"===T?["playing","on"].includes(N.state)?"on":"":"vacuum"===T&&["cleaning","returning"].includes(N.state)?"on":""}_liveTemp(I){return this._config?.show_temperature?"mdi:thermometer"!==I.icon&&"mdi:air-filter"!==I.icon?null:this._tempFor(I.entities):null}_openMoreInfo(I){I?xI(this,"hass-more-info",{entityId:I}):this._showToast("У устройства нет подходящей сущности")}_clickDevice(I,M){if(I.stopPropagation(),!this._drag?.moved)if(this._edit)this._selId=M.id;else{if(!M.virtual||M.primary)return M.members?(this._menuDev=M,this._menuXY={x:I.clientX,y:I.clientY},void(this._tip=null)):void this._openMoreInfo(M.primary);this._showToast(M.name+(M.virtual.note?" — "+M.virtual.note:""))}}_clickRoom(I){var M;!this._edit&&I.area&&(M="/config/areas/area/"+I.area,history.pushState(null,"",M),xI(window,"location-changed",{replace:!1}))}_pointerDown(I,M){if(!this._edit||M.virtual)return;I.preventDefault();const i=this._pos(M);this._drag={id:M.id,sx:I.clientX,sy:I.clientY,ox:i.x,oy:i.y,moved:!1},I.target.setPointerCapture(I.pointerId),this._tip=null}_pointerMove(I,M){if(!this._drag||this._drag.id!==M.id)return;const i=this.renderRoot.querySelector(".stage");if(!i)return;const N=this._spaceModel().vb,T=i.getBoundingClientRect(),D=(I.clientX-this._drag.sx)/T.width*N[2],j=(I.clientY-this._drag.sy)/T.height*N[3];Math.abs(I.clientX-this._drag.sx)+Math.abs(I.clientY-this._drag.sy)>3&&(this._drag.moved=!0);const g=.008*Math.min(N[2],N[3]),c=Math.max(N[0]+g,Math.min(N[0]+N[2]-g,this._drag.ox+D)),w=Math.max(N[1]+g,Math.min(N[1]+N[3]-g,this._drag.oy+j));this._savePos(M,c,w)}_pointerUp(I,M){if(!this._drag||this._drag.id!==M.id)return;const i=this._drag.moved;this._drag=i?this._drag:null,i&&(this._selId=M.id,window.setTimeout(()=>this._drag=null,0))}_applyXY(I,M){if(!this._selId)return;const i=parseFloat(M);if(isNaN(i))return;const N=this._devices.find(I=>I.id===this._selId);if(!N||N.virtual)return;const T={...this._pos(N)};T[I]=i,this._savePos(N,T.x,T.y)}_resetLayout(){confirm("Сбросить позиции всех иконок к авто-раскладке?")&&(this._layout={},this._persistLayout())}_showToast(I){this._toast=I,clearTimeout(this._toastTimer),this._toastTimer=window.setTimeout(()=>{this._toast=""},3500)}_showTip(I,M,i,N){this._drag||(this._tip={x:I.clientX,y:I.clientY,title:M,meta:i,lqi:N})}get _gridPitch(){return AI/120}get _curSpaceCfg(){return this._serverCfg?.spaces.find(I=>I.id===this._space)}get _spaceH(){const I=this._curSpaceCfg;return I?AI/I.aspect:AI}get _segments(){const I=this._curSpaceCfg,M=this._spaceH;return(I?.segments||[]).map(I=>[I[0]*AI,I[1]*M,I[2]*AI,I[3]*M])}_toggleMarkup(){this._norm?(this._markup=!this._markup,this._edit=!1,this._path=[],this._cursorPt=null,this._tool="draw"):this._showToast("Разметка доступна после переноса конфига на сервер (режим правки → «На сервер»)")}_svgPoint(I){const M=this.renderRoot.querySelector(".stage"),i=this._spaceModel().vb,N=M.getBoundingClientRect();return[i[0]+(I.clientX-N.left)/N.width*i[2],i[1]+(I.clientY-N.top)/N.height*i[3]]}_snap(I){const M=this._gridPitch;return[Math.round(I[0]/M)*M,Math.round(I[1]/M)*M]}_samePt(I,M){return Math.abs(I[0]-M[0])<.001&&Math.abs(I[1]-M[1])<.001}_segKey(I,M){const[i,N]=I[0]this._segKey([I[0],I[1]],[I[2],I[3]])===T);return!D&&(i.segments=i.segments||[],i.segments.push([I[0]/AI,I[1]/N,M[0]/AI,M[1]/N]),this._saveConfig(),!0)}_distToSeg(I,M){const[i,N]=I,[T,D,j,g]=M,c=j-T,w=g-D;let z=((i-T)*c+(N-D)*w)/(c*c+w*w||1);z=Math.max(0,Math.min(1,z));const O=T+z*c,k=D+z*w;return Math.hypot(i-O,N-k)}_pointInRoom(I,M){if(M.poly){let i=!1;const N=M.poly;for(let M=0,T=N.length-1;MI[1]!=c>I[1]&&I[0]<(g-D)*(I[1]-j)/(c-j)+D&&(i=!i)}return i}return null!=M.x&&I[0]>=M.x&&I[0]<=M.x+M.w&&I[1]>=M.y&&I[1]<=M.y+M.h}_markupClick(I){if(!this._markup)return;const M=this._svgPoint(I);if("erase"===this._tool){const I=this._curSpaceCfg;if(!I?.segments?.length)return;const i=this._segments;let N=-1,T=.5*this._gridPitch;return i.forEach((I,i)=>{const D=this._distToSeg(M,I);D=0&&(I.segments.splice(N,1),this._saveConfig(),this.requestUpdate()))}if("delroom"===this._tool){const I=[...this._spaceModel().rooms].reverse().find(I=>this._pointInRoom(M,I));if(!I)return;if(!confirm(`Удалить комнату «${I.name}»?`))return;const i=this._curSpaceCfg;return i.rooms=i.rooms.filter(M=>M.id!==I.id),this._saveConfig(),this._regSignature="",this._maybeRebuildDevices(),void this.requestUpdate()}const i=this._snap(M);if(!this._path.length)return this._path=[i],void(this._pathSegs=[]);const N=this._path[this._path.length-1];if(this._samePt(i,N))return;const T=this._addSegment(N,i);this._pathSegs=[...this._pathSegs,T?this._segKey(N,i):null],this._path=[...this._path,i],this._path.length>=4&&this._samePt(i,this._path[0])&&(this._cursorPt=null,this._nameSel="",this._areaSel="",this._roomDialog=!0)}get _contourClosed(){return this._path.length>=4&&this._samePt(this._path[0],this._path[this._path.length-1])}_markupMove(I){this._markup&&"draw"===this._tool&&this._path.length&&!this._contourClosed&&(this._cursorPt=this._snap(this._svgPoint(I)))}_saveRoom(){if(!this._contourClosed||!this._areaSel&&!this._nameSel)return;const I=this._curSpaceCfg;if(!I)return;const M=this._spaceH,i=this._path.slice(0,-1),N=this._areaSel?this.hass.areas[this._areaSel]?.name:"";I.rooms.push({id:"r"+Date.now().toString(36),name:this._nameSel||N||"Комната",area:this._areaSel||null,poly:i.map(I=>[I[0]/AI,I[1]/M])}),this._saveConfig(),this._path=[],this._pathSegs=[],this._areaSel="",this._nameSel="",this._roomDialog=!1,this._regSignature="",this._maybeRebuildDevices(),this._showToast("Комната сохранена")}_cancelPath(){this._path=[],this._pathSegs=[],this._cursorPt=null,this._roomDialog=!1}_roomDialogCancel(){this._roomDialog=!1,this._undoPoint()}get _freeAreas(){const I=new Set;for(const M of this._serverCfg?.spaces||[])for(const i of M.rooms||[])i.area&&I.add(i.area);return Object.values(this.hass?.areas||{}).filter(M=>!I.has(M.area_id)).sort((I,M)=>(I.name||"").localeCompare(M.name||""))}async _migrateToServer(){if(this._serverStorage&&!this._norm&&!this._migrating&&confirm("Перенести текущую конфигурацию (планы, комнаты, раскладку) на сервер HA?")){this._migrating=!0;try{const I=[];for(const M of Object.keys(zI)){const i=zI[M],N=dI[M]||i,[T,D,j,g]=N;let c=null,w=j/g;const z=kI[M];if(z&&dI[M]){const I=LI[M];I&&(w=I[0]/I[1]);const i=z.split(",")[1];c=(await this.hass.callWS({type:"houseplan/plan/set",space_id:M,ext:"svg",data:i})).url}const O=wI.filter(I=>I.floor===M).map((I,i)=>({id:M+"_r"+i,name:I.name,area:I.area||null,x:(I.x-T)/j,y:(I.y-D)/g,w:I.w/j,h:I.h/g}));I.push({id:M,title:OI[M]||M,plan_url:c,aspect:w,view_box:[(i[0]-T)/j,(i[1]-D)/g,i[2]/j,i[3]/g],rooms:O})}const M={spaces:I,device_overrides:{},virtual_devices:[],settings:{exclude_integrations:[...yI],group_lights:!0}},i={};for(const I of this._devices){if(I.virtual)continue;const M=this._layout[I.id]||this._defPos[I.id];if(!M)continue;const N=dI[I.space]||zI[I.space];i[I.id]={s:I.space,x:(M.x-N[0])/N[2],y:(M.y-N[1])/N[3]}}await this.hass.callWS({type:"houseplan/config/set",config:M}),await this.hass.callWS({type:"houseplan/layout/set",layout:i}),this._serverCfg=M,this._layout=i,this._regSignature="",this._maybeRebuildDevices(),this._showToast("Конфигурация перенесена на сервер — карта работает от server config")}catch(I){this._showToast("Ошибка миграции: "+(I?.message||I))}finally{this._migrating=!1}}}render(){if(!this._config||!this.hass)return v;const I=this._model;if(!I.length)return r`
Нет настроенных пространств
`;const M=this._spaceModel(),i=M.vb,N=this._devices.filter(I=>I.space===M.id),T=this._config.icon_size??2.5,D=T>8?2.5:T;return r`
@@ -76,15 +76,15 @@ const I=globalThis,M=I.ShadowRoot&&(void 0===I.ShadyCSS||I.ShadyCSS.nativeShadow ${null!=j?r`${j}°`:v} ${null!=g?r`${g}`:v} -
`}_roomCenter(I){if(I.poly){const M=I.poly.length;return[I.poly.reduce((I,M)=>I+M[0],0)/M,I.poly.reduce((I,M)=>I+M[1],0)/M]}return[I.x+I.w/2,I.y+.1*Math.min(I.w,I.h)]}_renderMarkupDefs(I){const M=this._gridPitch,i=.09*M;return p` +
`}_roomCenter(I){if(I.poly){const M=I.poly.length;return[I.poly.reduce((I,M)=>I+M[0],0)/M,I.poly.reduce((I,M)=>I+M[1],0)/M]}return[I.x+I.w/2,I.y+.1*Math.min(I.w,I.h)]}_renderMarkupDefs(I){const M=this._gridPitch,i=.14*M;return p` - - `}_renderMarkupLayer(I){const M=this._segments,i=this._path,N=this._gridPitch;return p` + `}_renderMarkupLayer(I){const M=this._segments,i=this._path,N=this._gridPitch;return p` + ${M.map(I=>p``)} ${i.length>1?p``:v} ${i.length&&this._cursorPt&&"draw"===this._tool&&!this._contourClosed?p`"houseplan-card"===I.type)||window.customCards.push({type:"houseplan-card",name:"House Plan Card",description:"Интерактивный план дома: пространства, комнаты, устройства с живыми состояниями и drag-раскладкой."}),console.info("%c HOUSEPLAN-CARD %c v1.4.1 ","background:#3ea6ff;color:#04121f;font-weight:700",""); + `,customElements.get("houseplan-card")||customElements.define("houseplan-card",bI),window.customCards=window.customCards||[],window.customCards.find(I=>"houseplan-card"===I.type)||window.customCards.push({type:"houseplan-card",name:"House Plan Card",description:"Интерактивный план дома: пространства, комнаты, устройства с живыми состояниями и drag-раскладкой."}),console.info("%c HOUSEPLAN-CARD %c v1.4.2 ","background:#3ea6ff;color:#04121f;font-weight:700",""); diff --git a/dist/houseplan-card.js b/dist/houseplan-card.js index ddf893e..8dd2d0d 100755 --- a/dist/houseplan-card.js +++ b/dist/houseplan-card.js @@ -4,7 +4,7 @@ const I=globalThis,M=I.ShadowRoot&&(void 0===I.ShadyCSS||I.ShadyCSS.nativeShadow .schema=${aI} .computeLabel=${I=>sI[I.name]||I.name} @value-changed=${this._valueChanged} - >`:v}_valueChanged(I){const M={...this._config,...I.detail.value},i=new Event("config-changed",{bubbles:!0,composed:!0});i.detail={config:M},this.dispatchEvent(i)}}mI.properties={hass:{attribute:!1},_config:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",mI);const HI="houseplan_card_layout_v1",AI=1e3,LI={f1:[1196.6656,1467.26],f2:[1170.0986,1073]},PI=I=>{const M=Math.max(0,Math.min(120,(I-40)/140*120));return`hsl(${Math.round(M)}, 85%, 55%)`},xI=(I,M,i)=>{const N=new Event(M,{bubbles:!0,composed:!0});N.detail=i??{},I.dispatchEvent(N)},eI=(I,M)=>{let i;return(...N)=>{clearTimeout(i),i=window.setTimeout(()=>I(...N),M)}};class bI extends gI{constructor(){super(...arguments),this._space="f1",this._edit=!1,this._layout={},this._serverStorage=!1,this._loaded=!1,this._serverCfg=null,this._devices=[],this._regSignature="",this._defPos={},this._menuDev=null,this._menuXY={x:0,y:0},this._tip=null,this._selId=null,this._toast="",this._migrating=!1,this._markup=!1,this._tool="draw",this._path=[],this._pathSegs=[],this._cursorPt=null,this._areaSel="",this._nameSel="",this._roomDialog=!1,this._keyHandler=I=>this._onKey(I),this._drag=null,this._persistLayout=eI(()=>{this._serverStorage?this.hass.callWS({type:"houseplan/layout/set",layout:this._layout}).catch(I=>this._showToast("Не удалось сохранить на сервере: "+(I?.message||I))):localStorage.setItem(HI,JSON.stringify(this._layout))},600),this._saveConfig=eI(()=>{this._serverCfg&&this.hass.callWS({type:"houseplan/config/set",config:this._serverCfg}).catch(I=>this._showToast("Не удалось сохранить конфиг: "+(I?.message||I)))},500)}connectedCallback(){super.connectedCallback(),window.addEventListener("keydown",this._keyHandler)}disconnectedCallback(){window.removeEventListener("keydown",this._keyHandler),super.disconnectedCallback()}_onKey(I){if(!this._markup)return;return"Escape"===I.key||(I.ctrlKey||I.metaKey)&&"z"===I.key.toLowerCase()?this._roomDialog?(I.preventDefault(),void this._roomDialogCancel()):void("draw"===this._tool&&this._path.length&&(I.preventDefault(),this._undoPoint())):void 0}_undoPoint(){if(!this._path.length)return;if(1===this._path.length)return this._path=[],void(this._pathSegs=[]);const I=this._pathSegs[this._pathSegs.length-1];this._pathSegs=this._pathSegs.slice(0,-1),I&&this._removeSegmentByKey(I),this._path=this._path.slice(0,-1)}_removeSegmentByKey(I){const M=this._curSpaceCfg;if(!M?.segments)return;const i=this._segments.findIndex(M=>this._segKey([M[0],M[1]],[M[2],M[3]])===I);i>=0&&(M.segments.splice(i,1),this._saveConfig())}static getConfigElement(){return document.createElement("houseplan-card-editor")}static getStubConfig(){return{type:"custom:houseplan-card",title:"План дома"}}setConfig(I){this._config={icon_size:2.5,show_temperature:!0,live_states:!0,show_signal:!0,...I},I.default_floor&&(this._space=I.default_floor)}getCardSize(){return 12}get _norm(){return!(!this._serverCfg||!this._serverCfg.spaces.length)}get _model(){if(this._norm)return this._serverCfg.spaces.map(I=>{const M=AI/I.aspect;return{id:I.id,title:I.title,vb:[I.view_box[0]*AI,I.view_box[1]*M,I.view_box[2]*AI,I.view_box[3]*M],bg:I.plan_url?{href:I.plan_url,x:0,y:0,w:AI,h:M}:null,rooms:I.rooms.map(I=>({id:I.id,name:I.name,area:I.area??null,x:null!=I.x?I.x*AI:void 0,y:null!=I.y?I.y*M:void 0,w:null!=I.w?I.w*AI:void 0,h:null!=I.h?I.h*M:void 0,poly:I.poly?I.poly.map(I=>[I[0]*AI,I[1]*M]):void 0}))}});const I=this._config?.rooms?.length?this._config.rooms:wI;return Object.keys(zI).map(M=>{const i=dI[M],N=kI[M];return{id:M,title:OI[M]||M,vb:zI[M],bg:N&&i?{href:N,x:i[0],y:i[1],w:i[2],h:i[3]}:null,rooms:I.filter(I=>I.floor===M).map(I=>({name:I.name,area:I.area||null,x:I.x,y:I.y,w:I.w,h:I.h}))}})}_spaceModel(I){const M=this._model;return M.find(M=>M.id===(I??this._space))||M[0]}get _areaToSpace(){const I={};for(const M of this._model)for(const i of M.rooms)i.area&&(I[i.area]={space:M.id,room:i});return I}get _settings(){return this._serverCfg?.settings||{}}get _excluded(){const I=this._settings.exclude_integrations;return I?new Set(I):yI}willUpdate(I){I.has("hass")&&this.hass&&(this._loaded||(this._loaded=!0,this._loadFromServer()),this._maybeRebuildDevices())}async _loadFromServer(){try{const[I,M]=await Promise.all([this.hass.callWS({type:"houseplan/config/get"}),this.hass.callWS({type:"houseplan/layout/get"})]);this._serverStorage=!0;const i=I?.config;this._serverCfg=i&&Array.isArray(i.spaces)?i:null,this._layout=M?.layout||{},this._norm&&!this._model.find(I=>I.id===this._space)&&(this._space=this._model[0]?.id||this._space)}catch(I){this._serverStorage=!1,this._serverCfg=null;try{this._layout=JSON.parse(localStorage.getItem(HI)||"{}")||{}}catch{this._layout={}}this._showToast("Интеграция houseplan не найдена — позиции сохраняются локально")}this._regSignature="",this.requestUpdate()}_maybeRebuildDevices(){const I=this.hass;if(!I?.devices||!I?.entities||!I?.areas)return;const M=Object.keys(I.devices).length+":"+Object.keys(I.entities).length+":"+Object.keys(I.areas).length+":"+(this._norm?"n":"l");M===this._regSignature&&this._devices.length||(this._regSignature=M,this._devices=this._buildDevices(),this._defPos=this._defaultPositions())}_entitiesByDevice(){const I={};for(const[M,i]of Object.entries(this.hass.entities))i?.device_id&&(I[i.device_id]=I[i.device_id]||[]).push(M);return I}_domainOfDevice(I,M){if(I.identifiers?.[0]?.[0])return I.identifiers[0][0];for(const I of M){const M=this.hass.entities[I]?.platform;if(M)return M}return""}_primaryEntity(I,M){const i=I.map(I=>({eid:I,reg:this.hass.entities[I],st:this.hass.states[I]})).filter(I=>I.reg&&!I.reg.hidden),N=i.filter(I=>!I.reg.entity_category),T=N.length?N:i;if("mdi:thermometer"===M||"mdi:air-filter"===M){const I=T.find(I=>this._isTempEntity(I.eid));if(I)return I.eid}for(const I of tI){const M=T.find(M=>M.eid.split(".")[0]===I);if(M)return M.eid}return T[0]?.eid}_isTempEntity(I){const M=this.hass.states[I];if(!M)return/_temperature$/.test(I);const i=M.attributes||{};return"temperature"===i.device_class||/°C|°F/.test(i.unit_of_measurement||"")||/_temperature$/.test(I)}_lqiFor(I){const M=[];for(const i of I){const I=this.hass.states[i];if(!(/_linkquality$/.test(i)||"lqi"===(I?.attributes?.unit_of_measurement||""))||!I)continue;const N=parseFloat(I.state);isNaN(N)||M.push(N)}return M.length?Math.round(M.reduce((I,M)=>I+M,0)/M.length):null}_roomLqi(I){if(!I)return null;const M=[];for(const i of this._devices){if(i.area!==I||i.virtual)continue;const N=this._lqiFor(i.entities);null!=N&&M.push(N)}return M.length?Math.round(M.reduce((I,M)=>I+M,0)/M.length):null}_tempFor(I){for(const M of I){if(!this._isTempEntity(M))continue;const I=this.hass.states[M];if(!I)continue;const i=parseFloat(I.state);if(!isNaN(i))return Math.round(10*i)/10}return null}_buildDevices(){const I=this.hass,M=this._areaToSpace,i=this._serverCfg?.device_overrides||{},N=!1!==this._settings.group_lights,T=this._excluded,D=this._entitiesByDevice(),j={};for(const M of Object.values(I.devices))"Group"===M.model&&M.area_id&&(j[M.area_id]=M);const g={},c=[];for(const N of Object.values(I.devices)){const I=N.area_id;if(!I||!M[I])continue;if("service"===N.entry_type)continue;const j=i[N.id]||{};if(j.hidden)continue;const w=D[N.id]||[],z=this._domainOfDevice(N,w);if(T.has(z))continue;if("Group"===N.model)continue;if(/scene/i.test(N.model||""))continue;if(/bridge/i.test((N.model||"")+(N.name||"")))continue;if("myheat"===z&&N.via_device_id)continue;const O=(j.name||N.name_by_user||N.name||"без имени").trim(),k=O+"|"+I;if(g[k])continue;g[k]=1;let d=ZI(O,N.model);w.some(I=>I.startsWith("lock."))&&(d="mdi:lock"),j.icon&&(d=j.icon);const y={id:N.id,name:O,model:N.model||"",area:I,space:M[I].space,icon:d,entities:w};y.primary=this._primaryEntity(w,d),"mdi:thermometer"!==d&&"mdi:air-filter"!==d||(y.temp=this._tempFor(w)),c.push(y)}const w=[];if(N){const I={};for(const M of c)"mdi:lightbulb"===M.icon?(I[M.area]=I[M.area]||[]).push(M):w.push(M);for(const i of Object.keys(I)){const N=I[i];if(N.length<2){w.push(...N);continue}const T=j[i],g={id:"grp_"+i,name:SI[i]||"Лампы",model:"группа · "+N.length+" шт",area:i,space:M[i].space,icon:"mdi:lightbulb-group",entities:N.flatMap(I=>I.entities),link:T?.id||null,members:N.map(I=>({id:I.id,name:I.name,primary:I.primary}))};if(T){const I=D[T.id]||[];g.linkPrimary=this._primaryEntity(I,"mdi:lightbulb")}w.push(g)}}else w.push(...c);for(const I of this._serverCfg?.virtual_devices||[])w.push({id:"virt_"+I.id,name:I.name,model:I.note||"виртуальное устройство",area:"",space:I.space,icon:I.icon,entities:I.entity_id?[I.entity_id]:[],primary:I.entity_id||void 0,virtual:I});return w}_defaultPositions(){const I={};for(const M of this._model)for(const i of M.rooms){if(!i.area)continue;const N=this._devices.filter(I=>I.area===i.area&&I.space===M.id);if(!N.length)continue;const T=.12*Math.min(i.w,i.h),D=i.w-2*T,j=i.h-2*T,g=Math.max(1,Math.round(Math.sqrt(N.length*D/Math.max(j,1)))),c=Math.ceil(N.length/g),w=D/g,z=j/Math.max(c,1);N.forEach((M,N)=>{const D=N%g,j=Math.floor(N/g);I[M.id]={x:i.x+T+w*(D+.5),y:i.y+T+z*(j+.5)}})}return I}_pos(I){const M=this._spaceModel(I.space);if(M.vb,I.virtual){const M=this._norm?AI/(this._serverCfg.spaces.find(M=>M.id===I.space)?.aspect||1):0;return{x:I.virtual.x*(this._norm?AI:1),y:I.virtual.y*(this._norm?M:1)}}const i=this._layout[I.id];if(i)if(this._norm){if(i.s===I.space){const M=this._serverCfg.spaces.find(M=>M.id===I.space)?.aspect||1;return{x:i.x*AI,y:i.y*(AI/M)}}}else if(void 0===i.s)return{x:i.x,y:i.y};if(this._defPos[I.id])return this._defPos[I.id];const N=M.vb;return{x:N[0]+N[2]/2,y:N[1]+N[3]/2}}_savePos(I,M,i){if(this._norm){const N=this._serverCfg.spaces.find(M=>M.id===I.space)?.aspect||1;this._layout={...this._layout,[I.id]:{s:I.space,x:M/AI,y:i/(AI/N)}}}else this._layout={...this._layout,[I.id]:{x:Math.round(M),y:Math.round(i)}};this._persistLayout()}_stateClass(I){if(!this._config?.live_states)return"";const M=I=>I?this.hass.states[I]:void 0,i=I.members?I.members.map(I=>M(I.primary)).filter(Boolean):[M(I.primary)].filter(Boolean);if(!i.length)return"";if(i.every(I=>"unavailable"===I.state))return"unavail";const N=I.members&&i.find(I=>"on"===I.state)||i[0],T=N.entity_id.split(".")[0];if(["light","switch","fan","humidifier"].includes(T))return"on"===N.state?"on":"";if("cover"===T||"valve"===T)return["open","opening"].includes(N.state)?"open":"";if("lock"===T)return["unlocked","open"].includes(N.state)?"open":"";if("binary_sensor"===T){const I=N.attributes?.device_class;if(["door","window","garage_door","opening","gas","smoke","moisture","problem"].includes(I))return"on"===N.state?"open":""}return"media_player"===T?["playing","on"].includes(N.state)?"on":"":"vacuum"===T&&["cleaning","returning"].includes(N.state)?"on":""}_liveTemp(I){return this._config?.show_temperature?"mdi:thermometer"!==I.icon&&"mdi:air-filter"!==I.icon?null:this._tempFor(I.entities):null}_openMoreInfo(I){I?xI(this,"hass-more-info",{entityId:I}):this._showToast("У устройства нет подходящей сущности")}_clickDevice(I,M){if(I.stopPropagation(),!this._drag?.moved)if(this._edit)this._selId=M.id;else{if(!M.virtual||M.primary)return M.members?(this._menuDev=M,this._menuXY={x:I.clientX,y:I.clientY},void(this._tip=null)):void this._openMoreInfo(M.primary);this._showToast(M.name+(M.virtual.note?" — "+M.virtual.note:""))}}_clickRoom(I){var M;!this._edit&&I.area&&(M="/config/areas/area/"+I.area,history.pushState(null,"",M),xI(window,"location-changed",{replace:!1}))}_pointerDown(I,M){if(!this._edit||M.virtual)return;I.preventDefault();const i=this._pos(M);this._drag={id:M.id,sx:I.clientX,sy:I.clientY,ox:i.x,oy:i.y,moved:!1},I.target.setPointerCapture(I.pointerId),this._tip=null}_pointerMove(I,M){if(!this._drag||this._drag.id!==M.id)return;const i=this.renderRoot.querySelector(".stage");if(!i)return;const N=this._spaceModel().vb,T=i.getBoundingClientRect(),D=(I.clientX-this._drag.sx)/T.width*N[2],j=(I.clientY-this._drag.sy)/T.height*N[3];Math.abs(I.clientX-this._drag.sx)+Math.abs(I.clientY-this._drag.sy)>3&&(this._drag.moved=!0);const g=.008*Math.min(N[2],N[3]),c=Math.max(N[0]+g,Math.min(N[0]+N[2]-g,this._drag.ox+D)),w=Math.max(N[1]+g,Math.min(N[1]+N[3]-g,this._drag.oy+j));this._savePos(M,c,w)}_pointerUp(I,M){if(!this._drag||this._drag.id!==M.id)return;const i=this._drag.moved;this._drag=i?this._drag:null,i&&(this._selId=M.id,window.setTimeout(()=>this._drag=null,0))}_applyXY(I,M){if(!this._selId)return;const i=parseFloat(M);if(isNaN(i))return;const N=this._devices.find(I=>I.id===this._selId);if(!N||N.virtual)return;const T={...this._pos(N)};T[I]=i,this._savePos(N,T.x,T.y)}_resetLayout(){confirm("Сбросить позиции всех иконок к авто-раскладке?")&&(this._layout={},this._persistLayout())}_showToast(I){this._toast=I,clearTimeout(this._toastTimer),this._toastTimer=window.setTimeout(()=>{this._toast=""},3500)}_showTip(I,M,i,N){this._drag||(this._tip={x:I.clientX,y:I.clientY,title:M,meta:i,lqi:N})}get _gridPitch(){return AI/60}get _curSpaceCfg(){return this._serverCfg?.spaces.find(I=>I.id===this._space)}get _spaceH(){const I=this._curSpaceCfg;return I?AI/I.aspect:AI}get _segments(){const I=this._curSpaceCfg,M=this._spaceH;return(I?.segments||[]).map(I=>[I[0]*AI,I[1]*M,I[2]*AI,I[3]*M])}_toggleMarkup(){this._norm?(this._markup=!this._markup,this._edit=!1,this._path=[],this._cursorPt=null,this._tool="draw"):this._showToast("Разметка доступна после переноса конфига на сервер (режим правки → «На сервер»)")}_svgPoint(I){const M=this.renderRoot.querySelector(".stage"),i=this._spaceModel().vb,N=M.getBoundingClientRect();return[i[0]+(I.clientX-N.left)/N.width*i[2],i[1]+(I.clientY-N.top)/N.height*i[3]]}_snap(I){const M=this._gridPitch;return[Math.round(I[0]/M)*M,Math.round(I[1]/M)*M]}_samePt(I,M){return Math.abs(I[0]-M[0])<.001&&Math.abs(I[1]-M[1])<.001}_segKey(I,M){const[i,N]=I[0]this._segKey([I[0],I[1]],[I[2],I[3]])===T);return!D&&(i.segments=i.segments||[],i.segments.push([I[0]/AI,I[1]/N,M[0]/AI,M[1]/N]),this._saveConfig(),!0)}_distToSeg(I,M){const[i,N]=I,[T,D,j,g]=M,c=j-T,w=g-D;let z=((i-T)*c+(N-D)*w)/(c*c+w*w||1);z=Math.max(0,Math.min(1,z));const O=T+z*c,k=D+z*w;return Math.hypot(i-O,N-k)}_pointInRoom(I,M){if(M.poly){let i=!1;const N=M.poly;for(let M=0,T=N.length-1;MI[1]!=c>I[1]&&I[0]<(g-D)*(I[1]-j)/(c-j)+D&&(i=!i)}return i}return null!=M.x&&I[0]>=M.x&&I[0]<=M.x+M.w&&I[1]>=M.y&&I[1]<=M.y+M.h}_markupClick(I){if(!this._markup)return;const M=this._svgPoint(I);if("erase"===this._tool){const I=this._curSpaceCfg;if(!I?.segments?.length)return;const i=this._segments;let N=-1,T=.5*this._gridPitch;return i.forEach((I,i)=>{const D=this._distToSeg(M,I);D=0&&(I.segments.splice(N,1),this._saveConfig(),this.requestUpdate()))}if("delroom"===this._tool){const I=[...this._spaceModel().rooms].reverse().find(I=>this._pointInRoom(M,I));if(!I)return;if(!confirm(`Удалить комнату «${I.name}»?`))return;const i=this._curSpaceCfg;return i.rooms=i.rooms.filter(M=>M.id!==I.id),this._saveConfig(),this._regSignature="",this._maybeRebuildDevices(),void this.requestUpdate()}const i=this._snap(M);if(!this._path.length)return this._path=[i],void(this._pathSegs=[]);const N=this._path[this._path.length-1];if(this._samePt(i,N))return;const T=this._addSegment(N,i);this._pathSegs=[...this._pathSegs,T?this._segKey(N,i):null],this._path=[...this._path,i],this._path.length>=4&&this._samePt(i,this._path[0])&&(this._cursorPt=null,this._nameSel="",this._areaSel="",this._roomDialog=!0)}get _contourClosed(){return this._path.length>=4&&this._samePt(this._path[0],this._path[this._path.length-1])}_markupMove(I){this._markup&&"draw"===this._tool&&this._path.length&&!this._contourClosed&&(this._cursorPt=this._snap(this._svgPoint(I)))}_saveRoom(){if(!this._contourClosed||!this._areaSel&&!this._nameSel)return;const I=this._curSpaceCfg;if(!I)return;const M=this._spaceH,i=this._path.slice(0,-1),N=this._areaSel?this.hass.areas[this._areaSel]?.name:"";I.rooms.push({id:"r"+Date.now().toString(36),name:this._nameSel||N||"Комната",area:this._areaSel||null,poly:i.map(I=>[I[0]/AI,I[1]/M])}),this._saveConfig(),this._path=[],this._pathSegs=[],this._areaSel="",this._nameSel="",this._roomDialog=!1,this._regSignature="",this._maybeRebuildDevices(),this._showToast("Комната сохранена")}_cancelPath(){this._path=[],this._pathSegs=[],this._cursorPt=null,this._roomDialog=!1}_roomDialogCancel(){this._roomDialog=!1,this._undoPoint()}get _freeAreas(){const I=new Set;for(const M of this._serverCfg?.spaces||[])for(const i of M.rooms||[])i.area&&I.add(i.area);return Object.values(this.hass?.areas||{}).filter(M=>!I.has(M.area_id)).sort((I,M)=>(I.name||"").localeCompare(M.name||""))}async _migrateToServer(){if(this._serverStorage&&!this._norm&&!this._migrating&&confirm("Перенести текущую конфигурацию (планы, комнаты, раскладку) на сервер HA?")){this._migrating=!0;try{const I=[];for(const M of Object.keys(zI)){const i=zI[M],N=dI[M]||i,[T,D,j,g]=N;let c=null,w=j/g;const z=kI[M];if(z&&dI[M]){const I=LI[M];I&&(w=I[0]/I[1]);const i=z.split(",")[1];c=(await this.hass.callWS({type:"houseplan/plan/set",space_id:M,ext:"svg",data:i})).url}const O=wI.filter(I=>I.floor===M).map((I,i)=>({id:M+"_r"+i,name:I.name,area:I.area||null,x:(I.x-T)/j,y:(I.y-D)/g,w:I.w/j,h:I.h/g}));I.push({id:M,title:OI[M]||M,plan_url:c,aspect:w,view_box:[(i[0]-T)/j,(i[1]-D)/g,i[2]/j,i[3]/g],rooms:O})}const M={spaces:I,device_overrides:{},virtual_devices:[],settings:{exclude_integrations:[...yI],group_lights:!0}},i={};for(const I of this._devices){if(I.virtual)continue;const M=this._layout[I.id]||this._defPos[I.id];if(!M)continue;const N=dI[I.space]||zI[I.space];i[I.id]={s:I.space,x:(M.x-N[0])/N[2],y:(M.y-N[1])/N[3]}}await this.hass.callWS({type:"houseplan/config/set",config:M}),await this.hass.callWS({type:"houseplan/layout/set",layout:i}),this._serverCfg=M,this._layout=i,this._regSignature="",this._maybeRebuildDevices(),this._showToast("Конфигурация перенесена на сервер — карта работает от server config")}catch(I){this._showToast("Ошибка миграции: "+(I?.message||I))}finally{this._migrating=!1}}}render(){if(!this._config||!this.hass)return v;const I=this._model;if(!I.length)return r`
Нет настроенных пространств
`;const M=this._spaceModel(),i=M.vb,N=this._devices.filter(I=>I.space===M.id),T=this._config.icon_size??2.5,D=T>8?2.5:T;return r` + >`:v}_valueChanged(I){const M={...this._config,...I.detail.value},i=new Event("config-changed",{bubbles:!0,composed:!0});i.detail={config:M},this.dispatchEvent(i)}}mI.properties={hass:{attribute:!1},_config:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",mI);const HI="houseplan_card_layout_v1",AI=1e3,LI={f1:[1196.6656,1467.26],f2:[1170.0986,1073]},PI=I=>{const M=Math.max(0,Math.min(120,(I-40)/140*120));return`hsl(${Math.round(M)}, 85%, 55%)`},xI=(I,M,i)=>{const N=new Event(M,{bubbles:!0,composed:!0});N.detail=i??{},I.dispatchEvent(N)},eI=(I,M)=>{let i;return(...N)=>{clearTimeout(i),i=window.setTimeout(()=>I(...N),M)}};class bI extends gI{constructor(){super(...arguments),this._space="f1",this._edit=!1,this._layout={},this._serverStorage=!1,this._loaded=!1,this._serverCfg=null,this._devices=[],this._regSignature="",this._defPos={},this._menuDev=null,this._menuXY={x:0,y:0},this._tip=null,this._selId=null,this._toast="",this._migrating=!1,this._markup=!1,this._tool="draw",this._path=[],this._pathSegs=[],this._cursorPt=null,this._areaSel="",this._nameSel="",this._roomDialog=!1,this._keyHandler=I=>this._onKey(I),this._drag=null,this._persistLayout=eI(()=>{this._serverStorage?this.hass.callWS({type:"houseplan/layout/set",layout:this._layout}).catch(I=>this._showToast("Не удалось сохранить на сервере: "+(I?.message||I))):localStorage.setItem(HI,JSON.stringify(this._layout))},600),this._saveConfig=eI(()=>{this._serverCfg&&this.hass.callWS({type:"houseplan/config/set",config:this._serverCfg}).catch(I=>this._showToast("Не удалось сохранить конфиг: "+(I?.message||I)))},500)}connectedCallback(){super.connectedCallback(),window.addEventListener("keydown",this._keyHandler)}disconnectedCallback(){window.removeEventListener("keydown",this._keyHandler),super.disconnectedCallback()}_onKey(I){if(!this._markup)return;return"Escape"===I.key||(I.ctrlKey||I.metaKey)&&"z"===I.key.toLowerCase()?this._roomDialog?(I.preventDefault(),void this._roomDialogCancel()):void("draw"===this._tool&&this._path.length&&(I.preventDefault(),this._undoPoint())):void 0}_undoPoint(){if(!this._path.length)return;if(1===this._path.length)return this._path=[],void(this._pathSegs=[]);const I=this._pathSegs[this._pathSegs.length-1];this._pathSegs=this._pathSegs.slice(0,-1),I&&this._removeSegmentByKey(I),this._path=this._path.slice(0,-1)}_removeSegmentByKey(I){const M=this._curSpaceCfg;if(!M?.segments)return;const i=this._segments.findIndex(M=>this._segKey([M[0],M[1]],[M[2],M[3]])===I);i>=0&&(M.segments.splice(i,1),this._saveConfig())}static getConfigElement(){return document.createElement("houseplan-card-editor")}static getStubConfig(){return{type:"custom:houseplan-card",title:"План дома"}}setConfig(I){this._config={icon_size:2.5,show_temperature:!0,live_states:!0,show_signal:!0,...I},I.default_floor&&(this._space=I.default_floor)}getCardSize(){return 12}get _norm(){return!(!this._serverCfg||!this._serverCfg.spaces.length)}get _model(){if(this._norm)return this._serverCfg.spaces.map(I=>{const M=AI/I.aspect;return{id:I.id,title:I.title,vb:[I.view_box[0]*AI,I.view_box[1]*M,I.view_box[2]*AI,I.view_box[3]*M],bg:I.plan_url?{href:I.plan_url,x:0,y:0,w:AI,h:M}:null,rooms:I.rooms.map(I=>({id:I.id,name:I.name,area:I.area??null,x:null!=I.x?I.x*AI:void 0,y:null!=I.y?I.y*M:void 0,w:null!=I.w?I.w*AI:void 0,h:null!=I.h?I.h*M:void 0,poly:I.poly?I.poly.map(I=>[I[0]*AI,I[1]*M]):void 0}))}});const I=this._config?.rooms?.length?this._config.rooms:wI;return Object.keys(zI).map(M=>{const i=dI[M],N=kI[M];return{id:M,title:OI[M]||M,vb:zI[M],bg:N&&i?{href:N,x:i[0],y:i[1],w:i[2],h:i[3]}:null,rooms:I.filter(I=>I.floor===M).map(I=>({name:I.name,area:I.area||null,x:I.x,y:I.y,w:I.w,h:I.h}))}})}_spaceModel(I){const M=this._model;return M.find(M=>M.id===(I??this._space))||M[0]}get _areaToSpace(){const I={};for(const M of this._model)for(const i of M.rooms)i.area&&(I[i.area]={space:M.id,room:i});return I}get _settings(){return this._serverCfg?.settings||{}}get _excluded(){const I=this._settings.exclude_integrations;return I?new Set(I):yI}willUpdate(I){I.has("hass")&&this.hass&&(this._loaded||(this._loaded=!0,this._loadFromServer()),this._maybeRebuildDevices())}async _loadFromServer(){try{const[I,M]=await Promise.all([this.hass.callWS({type:"houseplan/config/get"}),this.hass.callWS({type:"houseplan/layout/get"})]);this._serverStorage=!0;const i=I?.config;this._serverCfg=i&&Array.isArray(i.spaces)?i:null,this._layout=M?.layout||{},this._norm&&!this._model.find(I=>I.id===this._space)&&(this._space=this._model[0]?.id||this._space)}catch(I){this._serverStorage=!1,this._serverCfg=null;try{this._layout=JSON.parse(localStorage.getItem(HI)||"{}")||{}}catch{this._layout={}}this._showToast("Интеграция houseplan не найдена — позиции сохраняются локально")}this._regSignature="",this.requestUpdate()}_maybeRebuildDevices(){const I=this.hass;if(!I?.devices||!I?.entities||!I?.areas)return;const M=Object.keys(I.devices).length+":"+Object.keys(I.entities).length+":"+Object.keys(I.areas).length+":"+(this._norm?"n":"l");M===this._regSignature&&this._devices.length||(this._regSignature=M,this._devices=this._buildDevices(),this._defPos=this._defaultPositions())}_entitiesByDevice(){const I={};for(const[M,i]of Object.entries(this.hass.entities))i?.device_id&&(I[i.device_id]=I[i.device_id]||[]).push(M);return I}_domainOfDevice(I,M){if(I.identifiers?.[0]?.[0])return I.identifiers[0][0];for(const I of M){const M=this.hass.entities[I]?.platform;if(M)return M}return""}_primaryEntity(I,M){const i=I.map(I=>({eid:I,reg:this.hass.entities[I],st:this.hass.states[I]})).filter(I=>I.reg&&!I.reg.hidden),N=i.filter(I=>!I.reg.entity_category),T=N.length?N:i;if("mdi:thermometer"===M||"mdi:air-filter"===M){const I=T.find(I=>this._isTempEntity(I.eid));if(I)return I.eid}for(const I of tI){const M=T.find(M=>M.eid.split(".")[0]===I);if(M)return M.eid}return T[0]?.eid}_isTempEntity(I){const M=this.hass.states[I];if(!M)return/_temperature$/.test(I);const i=M.attributes||{};return"temperature"===i.device_class||/°C|°F/.test(i.unit_of_measurement||"")||/_temperature$/.test(I)}_lqiFor(I){const M=[];for(const i of I){const I=this.hass.states[i];if(!(/_linkquality$/.test(i)||"lqi"===(I?.attributes?.unit_of_measurement||""))||!I)continue;const N=parseFloat(I.state);isNaN(N)||M.push(N)}return M.length?Math.round(M.reduce((I,M)=>I+M,0)/M.length):null}_roomLqi(I){if(!I)return null;const M=[];for(const i of this._devices){if(i.area!==I||i.virtual)continue;const N=this._lqiFor(i.entities);null!=N&&M.push(N)}return M.length?Math.round(M.reduce((I,M)=>I+M,0)/M.length):null}_tempFor(I){for(const M of I){if(!this._isTempEntity(M))continue;const I=this.hass.states[M];if(!I)continue;const i=parseFloat(I.state);if(!isNaN(i))return Math.round(10*i)/10}return null}_buildDevices(){const I=this.hass,M=this._areaToSpace,i=this._serverCfg?.device_overrides||{},N=!1!==this._settings.group_lights,T=this._excluded,D=this._entitiesByDevice(),j={};for(const M of Object.values(I.devices))"Group"===M.model&&M.area_id&&(j[M.area_id]=M);const g={},c=[];for(const N of Object.values(I.devices)){const I=N.area_id;if(!I||!M[I])continue;if("service"===N.entry_type)continue;const j=i[N.id]||{};if(j.hidden)continue;const w=D[N.id]||[],z=this._domainOfDevice(N,w);if(T.has(z))continue;if("Group"===N.model)continue;if(/scene/i.test(N.model||""))continue;if(/bridge/i.test((N.model||"")+(N.name||"")))continue;if("myheat"===z&&N.via_device_id)continue;const O=(j.name||N.name_by_user||N.name||"без имени").trim(),k=O+"|"+I;if(g[k])continue;g[k]=1;let d=ZI(O,N.model);w.some(I=>I.startsWith("lock."))&&(d="mdi:lock"),j.icon&&(d=j.icon);const y={id:N.id,name:O,model:N.model||"",area:I,space:M[I].space,icon:d,entities:w};y.primary=this._primaryEntity(w,d),"mdi:thermometer"!==d&&"mdi:air-filter"!==d||(y.temp=this._tempFor(w)),c.push(y)}const w=[];if(N){const I={};for(const M of c)"mdi:lightbulb"===M.icon?(I[M.area]=I[M.area]||[]).push(M):w.push(M);for(const i of Object.keys(I)){const N=I[i];if(N.length<2){w.push(...N);continue}const T=j[i],g={id:"grp_"+i,name:SI[i]||"Лампы",model:"группа · "+N.length+" шт",area:i,space:M[i].space,icon:"mdi:lightbulb-group",entities:N.flatMap(I=>I.entities),link:T?.id||null,members:N.map(I=>({id:I.id,name:I.name,primary:I.primary}))};if(T){const I=D[T.id]||[];g.linkPrimary=this._primaryEntity(I,"mdi:lightbulb")}w.push(g)}}else w.push(...c);for(const I of this._serverCfg?.virtual_devices||[])w.push({id:"virt_"+I.id,name:I.name,model:I.note||"виртуальное устройство",area:"",space:I.space,icon:I.icon,entities:I.entity_id?[I.entity_id]:[],primary:I.entity_id||void 0,virtual:I});return w}_defaultPositions(){const I={};for(const M of this._model)for(const i of M.rooms){if(!i.area)continue;const N=this._devices.filter(I=>I.area===i.area&&I.space===M.id);if(!N.length)continue;const T=.12*Math.min(i.w,i.h),D=i.w-2*T,j=i.h-2*T,g=Math.max(1,Math.round(Math.sqrt(N.length*D/Math.max(j,1)))),c=Math.ceil(N.length/g),w=D/g,z=j/Math.max(c,1);N.forEach((M,N)=>{const D=N%g,j=Math.floor(N/g);I[M.id]={x:i.x+T+w*(D+.5),y:i.y+T+z*(j+.5)}})}return I}_pos(I){const M=this._spaceModel(I.space);if(M.vb,I.virtual){const M=this._norm?AI/(this._serverCfg.spaces.find(M=>M.id===I.space)?.aspect||1):0;return{x:I.virtual.x*(this._norm?AI:1),y:I.virtual.y*(this._norm?M:1)}}const i=this._layout[I.id];if(i)if(this._norm){if(i.s===I.space){const M=this._serverCfg.spaces.find(M=>M.id===I.space)?.aspect||1;return{x:i.x*AI,y:i.y*(AI/M)}}}else if(void 0===i.s)return{x:i.x,y:i.y};if(this._defPos[I.id])return this._defPos[I.id];const N=M.vb;return{x:N[0]+N[2]/2,y:N[1]+N[3]/2}}_savePos(I,M,i){if(this._norm){const N=this._serverCfg.spaces.find(M=>M.id===I.space)?.aspect||1;this._layout={...this._layout,[I.id]:{s:I.space,x:M/AI,y:i/(AI/N)}}}else this._layout={...this._layout,[I.id]:{x:Math.round(M),y:Math.round(i)}};this._persistLayout()}_stateClass(I){if(!this._config?.live_states)return"";const M=I=>I?this.hass.states[I]:void 0,i=I.members?I.members.map(I=>M(I.primary)).filter(Boolean):[M(I.primary)].filter(Boolean);if(!i.length)return"";if(i.every(I=>"unavailable"===I.state))return"unavail";const N=I.members&&i.find(I=>"on"===I.state)||i[0],T=N.entity_id.split(".")[0];if(["light","switch","fan","humidifier"].includes(T))return"on"===N.state?"on":"";if("cover"===T||"valve"===T)return["open","opening"].includes(N.state)?"open":"";if("lock"===T)return["unlocked","open"].includes(N.state)?"open":"";if("binary_sensor"===T){const I=N.attributes?.device_class;if(["door","window","garage_door","opening","gas","smoke","moisture","problem"].includes(I))return"on"===N.state?"open":""}return"media_player"===T?["playing","on"].includes(N.state)?"on":"":"vacuum"===T&&["cleaning","returning"].includes(N.state)?"on":""}_liveTemp(I){return this._config?.show_temperature?"mdi:thermometer"!==I.icon&&"mdi:air-filter"!==I.icon?null:this._tempFor(I.entities):null}_openMoreInfo(I){I?xI(this,"hass-more-info",{entityId:I}):this._showToast("У устройства нет подходящей сущности")}_clickDevice(I,M){if(I.stopPropagation(),!this._drag?.moved)if(this._edit)this._selId=M.id;else{if(!M.virtual||M.primary)return M.members?(this._menuDev=M,this._menuXY={x:I.clientX,y:I.clientY},void(this._tip=null)):void this._openMoreInfo(M.primary);this._showToast(M.name+(M.virtual.note?" — "+M.virtual.note:""))}}_clickRoom(I){var M;!this._edit&&I.area&&(M="/config/areas/area/"+I.area,history.pushState(null,"",M),xI(window,"location-changed",{replace:!1}))}_pointerDown(I,M){if(!this._edit||M.virtual)return;I.preventDefault();const i=this._pos(M);this._drag={id:M.id,sx:I.clientX,sy:I.clientY,ox:i.x,oy:i.y,moved:!1},I.target.setPointerCapture(I.pointerId),this._tip=null}_pointerMove(I,M){if(!this._drag||this._drag.id!==M.id)return;const i=this.renderRoot.querySelector(".stage");if(!i)return;const N=this._spaceModel().vb,T=i.getBoundingClientRect(),D=(I.clientX-this._drag.sx)/T.width*N[2],j=(I.clientY-this._drag.sy)/T.height*N[3];Math.abs(I.clientX-this._drag.sx)+Math.abs(I.clientY-this._drag.sy)>3&&(this._drag.moved=!0);const g=.008*Math.min(N[2],N[3]),c=Math.max(N[0]+g,Math.min(N[0]+N[2]-g,this._drag.ox+D)),w=Math.max(N[1]+g,Math.min(N[1]+N[3]-g,this._drag.oy+j));this._savePos(M,c,w)}_pointerUp(I,M){if(!this._drag||this._drag.id!==M.id)return;const i=this._drag.moved;this._drag=i?this._drag:null,i&&(this._selId=M.id,window.setTimeout(()=>this._drag=null,0))}_applyXY(I,M){if(!this._selId)return;const i=parseFloat(M);if(isNaN(i))return;const N=this._devices.find(I=>I.id===this._selId);if(!N||N.virtual)return;const T={...this._pos(N)};T[I]=i,this._savePos(N,T.x,T.y)}_resetLayout(){confirm("Сбросить позиции всех иконок к авто-раскладке?")&&(this._layout={},this._persistLayout())}_showToast(I){this._toast=I,clearTimeout(this._toastTimer),this._toastTimer=window.setTimeout(()=>{this._toast=""},3500)}_showTip(I,M,i,N){this._drag||(this._tip={x:I.clientX,y:I.clientY,title:M,meta:i,lqi:N})}get _gridPitch(){return AI/120}get _curSpaceCfg(){return this._serverCfg?.spaces.find(I=>I.id===this._space)}get _spaceH(){const I=this._curSpaceCfg;return I?AI/I.aspect:AI}get _segments(){const I=this._curSpaceCfg,M=this._spaceH;return(I?.segments||[]).map(I=>[I[0]*AI,I[1]*M,I[2]*AI,I[3]*M])}_toggleMarkup(){this._norm?(this._markup=!this._markup,this._edit=!1,this._path=[],this._cursorPt=null,this._tool="draw"):this._showToast("Разметка доступна после переноса конфига на сервер (режим правки → «На сервер»)")}_svgPoint(I){const M=this.renderRoot.querySelector(".stage"),i=this._spaceModel().vb,N=M.getBoundingClientRect();return[i[0]+(I.clientX-N.left)/N.width*i[2],i[1]+(I.clientY-N.top)/N.height*i[3]]}_snap(I){const M=this._gridPitch;return[Math.round(I[0]/M)*M,Math.round(I[1]/M)*M]}_samePt(I,M){return Math.abs(I[0]-M[0])<.001&&Math.abs(I[1]-M[1])<.001}_segKey(I,M){const[i,N]=I[0]this._segKey([I[0],I[1]],[I[2],I[3]])===T);return!D&&(i.segments=i.segments||[],i.segments.push([I[0]/AI,I[1]/N,M[0]/AI,M[1]/N]),this._saveConfig(),!0)}_distToSeg(I,M){const[i,N]=I,[T,D,j,g]=M,c=j-T,w=g-D;let z=((i-T)*c+(N-D)*w)/(c*c+w*w||1);z=Math.max(0,Math.min(1,z));const O=T+z*c,k=D+z*w;return Math.hypot(i-O,N-k)}_pointInRoom(I,M){if(M.poly){let i=!1;const N=M.poly;for(let M=0,T=N.length-1;MI[1]!=c>I[1]&&I[0]<(g-D)*(I[1]-j)/(c-j)+D&&(i=!i)}return i}return null!=M.x&&I[0]>=M.x&&I[0]<=M.x+M.w&&I[1]>=M.y&&I[1]<=M.y+M.h}_markupClick(I){if(!this._markup)return;const M=this._svgPoint(I);if("erase"===this._tool){const I=this._curSpaceCfg;if(!I?.segments?.length)return;const i=this._segments;let N=-1,T=.5*this._gridPitch;return i.forEach((I,i)=>{const D=this._distToSeg(M,I);D=0&&(I.segments.splice(N,1),this._saveConfig(),this.requestUpdate()))}if("delroom"===this._tool){const I=[...this._spaceModel().rooms].reverse().find(I=>this._pointInRoom(M,I));if(!I)return;if(!confirm(`Удалить комнату «${I.name}»?`))return;const i=this._curSpaceCfg;return i.rooms=i.rooms.filter(M=>M.id!==I.id),this._saveConfig(),this._regSignature="",this._maybeRebuildDevices(),void this.requestUpdate()}const i=this._snap(M);if(!this._path.length)return this._path=[i],void(this._pathSegs=[]);const N=this._path[this._path.length-1];if(this._samePt(i,N))return;const T=this._addSegment(N,i);this._pathSegs=[...this._pathSegs,T?this._segKey(N,i):null],this._path=[...this._path,i],this._path.length>=4&&this._samePt(i,this._path[0])&&(this._cursorPt=null,this._nameSel="",this._areaSel="",this._roomDialog=!0)}get _contourClosed(){return this._path.length>=4&&this._samePt(this._path[0],this._path[this._path.length-1])}_markupMove(I){this._markup&&"draw"===this._tool&&this._path.length&&!this._contourClosed&&(this._cursorPt=this._snap(this._svgPoint(I)))}_saveRoom(){if(!this._contourClosed||!this._areaSel&&!this._nameSel)return;const I=this._curSpaceCfg;if(!I)return;const M=this._spaceH,i=this._path.slice(0,-1),N=this._areaSel?this.hass.areas[this._areaSel]?.name:"";I.rooms.push({id:"r"+Date.now().toString(36),name:this._nameSel||N||"Комната",area:this._areaSel||null,poly:i.map(I=>[I[0]/AI,I[1]/M])}),this._saveConfig(),this._path=[],this._pathSegs=[],this._areaSel="",this._nameSel="",this._roomDialog=!1,this._regSignature="",this._maybeRebuildDevices(),this._showToast("Комната сохранена")}_cancelPath(){this._path=[],this._pathSegs=[],this._cursorPt=null,this._roomDialog=!1}_roomDialogCancel(){this._roomDialog=!1,this._undoPoint()}get _freeAreas(){const I=new Set;for(const M of this._serverCfg?.spaces||[])for(const i of M.rooms||[])i.area&&I.add(i.area);return Object.values(this.hass?.areas||{}).filter(M=>!I.has(M.area_id)).sort((I,M)=>(I.name||"").localeCompare(M.name||""))}async _migrateToServer(){if(this._serverStorage&&!this._norm&&!this._migrating&&confirm("Перенести текущую конфигурацию (планы, комнаты, раскладку) на сервер HA?")){this._migrating=!0;try{const I=[];for(const M of Object.keys(zI)){const i=zI[M],N=dI[M]||i,[T,D,j,g]=N;let c=null,w=j/g;const z=kI[M];if(z&&dI[M]){const I=LI[M];I&&(w=I[0]/I[1]);const i=z.split(",")[1];c=(await this.hass.callWS({type:"houseplan/plan/set",space_id:M,ext:"svg",data:i})).url}const O=wI.filter(I=>I.floor===M).map((I,i)=>({id:M+"_r"+i,name:I.name,area:I.area||null,x:(I.x-T)/j,y:(I.y-D)/g,w:I.w/j,h:I.h/g}));I.push({id:M,title:OI[M]||M,plan_url:c,aspect:w,view_box:[(i[0]-T)/j,(i[1]-D)/g,i[2]/j,i[3]/g],rooms:O})}const M={spaces:I,device_overrides:{},virtual_devices:[],settings:{exclude_integrations:[...yI],group_lights:!0}},i={};for(const I of this._devices){if(I.virtual)continue;const M=this._layout[I.id]||this._defPos[I.id];if(!M)continue;const N=dI[I.space]||zI[I.space];i[I.id]={s:I.space,x:(M.x-N[0])/N[2],y:(M.y-N[1])/N[3]}}await this.hass.callWS({type:"houseplan/config/set",config:M}),await this.hass.callWS({type:"houseplan/layout/set",layout:i}),this._serverCfg=M,this._layout=i,this._regSignature="",this._maybeRebuildDevices(),this._showToast("Конфигурация перенесена на сервер — карта работает от server config")}catch(I){this._showToast("Ошибка миграции: "+(I?.message||I))}finally{this._migrating=!1}}}render(){if(!this._config||!this.hass)return v;const I=this._model;if(!I.length)return r`
Нет настроенных пространств
`;const M=this._spaceModel(),i=M.vb,N=this._devices.filter(I=>I.space===M.id),T=this._config.icon_size??2.5,D=T>8?2.5:T;return r`
@@ -76,15 +76,15 @@ const I=globalThis,M=I.ShadowRoot&&(void 0===I.ShadyCSS||I.ShadyCSS.nativeShadow ${null!=j?r`${j}°`:v} ${null!=g?r`${g}`:v} -
`}_roomCenter(I){if(I.poly){const M=I.poly.length;return[I.poly.reduce((I,M)=>I+M[0],0)/M,I.poly.reduce((I,M)=>I+M[1],0)/M]}return[I.x+I.w/2,I.y+.1*Math.min(I.w,I.h)]}_renderMarkupDefs(I){const M=this._gridPitch,i=.09*M;return p` +
`}_roomCenter(I){if(I.poly){const M=I.poly.length;return[I.poly.reduce((I,M)=>I+M[0],0)/M,I.poly.reduce((I,M)=>I+M[1],0)/M]}return[I.x+I.w/2,I.y+.1*Math.min(I.w,I.h)]}_renderMarkupDefs(I){const M=this._gridPitch,i=.14*M;return p` - - `}_renderMarkupLayer(I){const M=this._segments,i=this._path,N=this._gridPitch;return p` + `}_renderMarkupLayer(I){const M=this._segments,i=this._path,N=this._gridPitch;return p` + ${M.map(I=>p``)} ${i.length>1?p``:v} ${i.length&&this._cursorPt&&"draw"===this._tool&&!this._contourClosed?p`"houseplan-card"===I.type)||window.customCards.push({type:"houseplan-card",name:"House Plan Card",description:"Интерактивный план дома: пространства, комнаты, устройства с живыми состояниями и drag-раскладкой."}),console.info("%c HOUSEPLAN-CARD %c v1.4.1 ","background:#3ea6ff;color:#04121f;font-weight:700",""); + `,customElements.get("houseplan-card")||customElements.define("houseplan-card",bI),window.customCards=window.customCards||[],window.customCards.find(I=>"houseplan-card"===I.type)||window.customCards.push({type:"houseplan-card",name:"House Plan Card",description:"Интерактивный план дома: пространства, комнаты, устройства с живыми состояниями и drag-раскладкой."}),console.info("%c HOUSEPLAN-CARD %c v1.4.2 ","background:#3ea6ff;color:#04121f;font-weight:700",""); diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 2df0ea0..02c3c2f 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## v1.4.2 — 2026-07-04 (сетка разметки) +- Шаг сетки уменьшен в 2 раза: GRID_N 60 → 120 (шаг 8.33 рендер-ед. ≈ 0.83% ширины плана). +- Точки сетки рендерятся ПОВЕРХ плана и комнат (grid-rect перенесён из под-плана в верхний + markup-слой); точки контрастнее (r=0.14g, обводка). +- Данные дачи: границы всех 13 комнат привязаны к узлам сетки (снап рёбер, сдвиги ≤ полшага; + правка только конфига инстанса через houseplan/config/set, код не менялся). +- Кэш карточки: URL модуля ?v= берётся из const.VERSION — при обновлении фронтенда бампать + const.py и рестартовать HA, иначе браузеры держат старый модуль в memory-cache. + ## v1.4.1 — 2026-07-04 (UX разметки) - Esc / Ctrl+Z при рисовании убирают последнюю точку (и её линию, если она была добавлена этим шагом; переиспользованные чужие стены не трогаются). diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 078d5ff..dbfb76f 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -38,6 +38,15 @@ cp dist/houseplan-card.js custom_components/houseplan/frontend/ - После деплоя JS — проверить в браузере (Ctrl+F5) и console (не должно быть ошибок из houseplan-card.js; битый бандл роняет все дашборды). +## Кэш фронтенда и «пустой вид» + +- URL модуля карточки содержит `?v=`. Браузеры держат ES-модуль в + memory-cache: после деплоя нового JS **бампните VERSION в const.py и рестартуйте HA**, + иначе обычный F5 оставит старую версию. +- После reload страницы HA-фронтенд (с kiosk-mode) иногда оставляет вид пустым + («InvalidStateError: Transition was aborted», hui-view не создаётся 1–2 мин). + Лечится повторной SPA-навигацией: pushState + событие location-changed, или подождать. + ## Релиз Тег `vX.Y.Z` + GitHub Release → workflow `.github/workflows/release.yml` собирает и прикладывает diff --git a/package.json b/package.json index 4324dcf..5573069 100755 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "houseplan-card", - "version": "1.4.1", + "version": "1.4.2", "description": "Interactive house plan Lovelace card for Home Assistant (dacha Kirillovskoe)", "license": "MIT", "type": "module", diff --git a/src/houseplan-card.ts b/src/houseplan-card.ts index f8d3920..c6d19f1 100755 --- a/src/houseplan-card.ts +++ b/src/houseplan-card.ts @@ -12,7 +12,7 @@ import { FLOOR_BG, FLOOR_BG_RECT } from './data/backgrounds'; import { EXCLUDED_DOMAINS, GROUP_TITLES, iconFor, DOMAIN_PRIORITY } from './rules'; import './editor'; -const CARD_VERSION = '1.4.1'; +const CARD_VERSION = '1.4.2'; const LS_KEY = 'houseplan_card_layout_v1'; const NORM_W = 1000; // ширина рендер-пространства для нормированных конфигов @@ -33,7 +33,7 @@ interface RoomCfg { poly?: number[][]; // полигон в рендер-единицах (модель) / нормированный (конфиг) } -const GRID_N = 60; // точек сетки по ширине плана +const GRID_N = 120; // точек сетки по ширине плана type MarkupTool = 'draw' | 'erase' | 'delroom'; interface SpaceModel { @@ -1170,9 +1170,9 @@ class HouseplanCard extends LitElement { return [r.x! + r.w! / 2, r.y! + Math.min(r.w!, r.h!) * 0.1]; } - private _renderMarkupDefs(vb: number[]): TemplateResult { + private _renderMarkupDefs(_vb: number[]): TemplateResult { const g = this._gridPitch; - const dotR = g * 0.09; + const dotR = g * 0.14; return svg` @@ -1180,15 +1180,15 @@ class HouseplanCard extends LitElement { - - `; + `; } - private _renderMarkupLayer(_vb: number[]): TemplateResult { + private _renderMarkupLayer(vb: number[]): TemplateResult { const segs = this._segments; const path = this._path; const g = this._gridPitch; return svg` + ${segs.map((s) => svg``)} ${path.length > 1 ? svg`` @@ -1487,7 +1487,9 @@ class HouseplanCard extends LitElement { } .griddot { fill: var(--hp-accent); - opacity: 0.45; + opacity: 0.75; + stroke: rgba(0, 0, 0, 0.35); + stroke-width: 0.4; } .seg { stroke: var(--hp-accent);