diff --git a/dist/chartdeck-card.js b/dist/chartdeck-card.js index 15162ee..aa9682a 100644 --- a/dist/chartdeck-card.js +++ b/dist/chartdeck-card.js @@ -3,88 +3,88 @@ * Copyright 2019 Google LLC * SPDX-License-Identifier: BSD-3-Clause */ -const e=globalThis,t=e.ShadowRoot&&(void 0===e.ShadyCSS||e.ShadyCSS.nativeShadow)&&"adoptedStyleSheets"in Document.prototype&&"replace"in CSSStyleSheet.prototype,n=Symbol(),o=new WeakMap;let a=class{constructor(e,t,o){if(this._$cssResult$=!0,o!==n)throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead.");this.cssText=e,this.t=t}get styleSheet(){let e=this.o;const n=this.t;if(t&&void 0===e){const t=void 0!==n&&1===n.length;t&&(e=o.get(n)),void 0===e&&((this.o=e=new CSSStyleSheet).replaceSync(this.cssText),t&&o.set(n,e))}return e}toString(){return this.cssText}};const i=(e,...t)=>{const o=1===e.length?e[0]:t.reduce((t,n,o)=>t+(e=>{if(!0===e._$cssResult$)return e.cssText;if("number"==typeof e)return e;throw Error("Value passed to 'css' function must be a 'css' function result: "+e+". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.")})(n)+e[o+1],e[0]);return new a(o,e,n)},r=t?e=>e:e=>e instanceof CSSStyleSheet?(e=>{let t="";for(const n of e.cssRules)t+=n.cssText;return(e=>new a("string"==typeof e?e:e+"",void 0,n))(t)})(e):e,{is:s,defineProperty:l,getOwnPropertyDescriptor:c,getOwnPropertyNames:d,getOwnPropertySymbols:h,getPrototypeOf:u}=Object,p=globalThis,m=p.trustedTypes,f=m?m.emptyScript:"",g=p.reactiveElementPolyfillSupport,y=(e,t)=>e,_={toAttribute(e,t){switch(t){case Boolean:e=e?f:null;break;case Object:case Array:e=null==e?e:JSON.stringify(e)}return e},fromAttribute(e,t){let n=e;switch(t){case Boolean:n=null!==e;break;case Number:n=null===e?null:Number(e);break;case Object:case Array:try{n=JSON.parse(e)}catch(e){n=null}}return n}},v=(e,t)=>!s(e,t),b={attribute:!0,type:String,converter:_,reflect:!1,useDefault:!1,hasChanged:v}; +const e=globalThis,t=e.ShadowRoot&&(void 0===e.ShadyCSS||e.ShadyCSS.nativeShadow)&&"adoptedStyleSheets"in Document.prototype&&"replace"in CSSStyleSheet.prototype,n=Symbol(),o=new WeakMap;let a=class{constructor(e,t,o){if(this._$cssResult$=!0,o!==n)throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead.");this.cssText=e,this.t=t}get styleSheet(){let e=this.o;const n=this.t;if(t&&void 0===e){const t=void 0!==n&&1===n.length;t&&(e=o.get(n)),void 0===e&&((this.o=e=new CSSStyleSheet).replaceSync(this.cssText),t&&o.set(n,e))}return e}toString(){return this.cssText}};const r=(e,...t)=>{const o=1===e.length?e[0]:t.reduce((t,n,o)=>t+(e=>{if(!0===e._$cssResult$)return e.cssText;if("number"==typeof e)return e;throw Error("Value passed to 'css' function must be a 'css' function result: "+e+". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.")})(n)+e[o+1],e[0]);return new a(o,e,n)},i=t?e=>e:e=>e instanceof CSSStyleSheet?(e=>{let t="";for(const n of e.cssRules)t+=n.cssText;return(e=>new a("string"==typeof e?e:e+"",void 0,n))(t)})(e):e,{is:s,defineProperty:l,getOwnPropertyDescriptor:c,getOwnPropertyNames:d,getOwnPropertySymbols:h,getPrototypeOf:u}=Object,m=globalThis,p=m.trustedTypes,f=p?p.emptyScript:"",g=m.reactiveElementPolyfillSupport,y=(e,t)=>e,v={toAttribute(e,t){switch(t){case Boolean:e=e?f:null;break;case Object:case Array:e=null==e?e:JSON.stringify(e)}return e},fromAttribute(e,t){let n=e;switch(t){case Boolean:n=null!==e;break;case Number:n=null===e?null:Number(e);break;case Object:case Array:try{n=JSON.parse(e)}catch(e){n=null}}return n}},_=(e,t)=>!s(e,t),b={attribute:!0,type:String,converter:v,reflect:!1,useDefault:!1,hasChanged:_}; /** * @license * Copyright 2017 Google LLC * SPDX-License-Identifier: BSD-3-Clause - */Symbol.metadata??=Symbol("metadata"),p.litPropertyMetadata??=new WeakMap;let $=class extends HTMLElement{static addInitializer(e){this._$Ei(),(this.l??=[]).push(e)}static get observedAttributes(){return this.finalize(),this._$Eh&&[...this._$Eh.keys()]}static createProperty(e,t=b){if(t.state&&(t.attribute=!1),this._$Ei(),this.prototype.hasOwnProperty(e)&&((t=Object.create(t)).wrapped=!0),this.elementProperties.set(e,t),!t.noAccessor){const n=Symbol(),o=this.getPropertyDescriptor(e,n,t);void 0!==o&&l(this.prototype,e,o)}}static getPropertyDescriptor(e,t,n){const{get:o,set:a}=c(this.prototype,e)??{get(){return this[t]},set(e){this[t]=e}};return{get:o,set(t){const i=o?.call(this);a?.call(this,t),this.requestUpdate(e,i,n)},configurable:!0,enumerable:!0}}static getPropertyOptions(e){return this.elementProperties.get(e)??b}static _$Ei(){if(this.hasOwnProperty(y("elementProperties")))return;const e=u(this);e.finalize(),void 0!==e.l&&(this.l=[...e.l]),this.elementProperties=new Map(e.elementProperties)}static finalize(){if(this.hasOwnProperty(y("finalized")))return;if(this.finalized=!0,this._$Ei(),this.hasOwnProperty(y("properties"))){const e=this.properties,t=[...d(e),...h(e)];for(const n of t)this.createProperty(n,e[n])}const e=this[Symbol.metadata];if(null!==e){const t=litPropertyMetadata.get(e);if(void 0!==t)for(const[e,n]of t)this.elementProperties.set(e,n)}this._$Eh=new Map;for(const[t,n]of this.elementProperties){const e=this._$Eu(t,n);void 0!==e&&this._$Eh.set(e,t)}this.elementStyles=this.finalizeStyles(this.styles)}static finalizeStyles(e){const t=[];if(Array.isArray(e)){const n=new Set(e.flat(1/0).reverse());for(const e of n)t.unshift(r(e))}else void 0!==e&&t.push(r(e));return t}static _$Eu(e,t){const n=t.attribute;return!1===n?void 0:"string"==typeof n?n:"string"==typeof e?e.toLowerCase():void 0}constructor(){super(),this._$Ep=void 0,this.isUpdatePending=!1,this.hasUpdated=!1,this._$Em=null,this._$Ev()}_$Ev(){this._$ES=new Promise(e=>this.enableUpdating=e),this._$AL=new Map,this._$E_(),this.requestUpdate(),this.constructor.l?.forEach(e=>e(this))}addController(e){(this._$EO??=new Set).add(e),void 0!==this.renderRoot&&this.isConnected&&e.hostConnected?.()}removeController(e){this._$EO?.delete(e)}_$E_(){const e=new Map,t=this.constructor.elementProperties;for(const n of t.keys())this.hasOwnProperty(n)&&(e.set(n,this[n]),delete this[n]);e.size>0&&(this._$Ep=e)}createRenderRoot(){const n=this.shadowRoot??this.attachShadow(this.constructor.shadowRootOptions);return((n,o)=>{if(t)n.adoptedStyleSheets=o.map(e=>e instanceof CSSStyleSheet?e:e.styleSheet);else for(const t of o){const o=document.createElement("style"),a=e.litNonce;void 0!==a&&o.setAttribute("nonce",a),o.textContent=t.cssText,n.appendChild(o)}})(n,this.constructor.elementStyles),n}connectedCallback(){this.renderRoot??=this.createRenderRoot(),this.enableUpdating(!0),this._$EO?.forEach(e=>e.hostConnected?.())}enableUpdating(e){}disconnectedCallback(){this._$EO?.forEach(e=>e.hostDisconnected?.())}attributeChangedCallback(e,t,n){this._$AK(e,n)}_$ET(e,t){const n=this.constructor.elementProperties.get(e),o=this.constructor._$Eu(e,n);if(void 0!==o&&!0===n.reflect){const a=(void 0!==n.converter?.toAttribute?n.converter:_).toAttribute(t,n.type);this._$Em=e,null==a?this.removeAttribute(o):this.setAttribute(o,a),this._$Em=null}}_$AK(e,t){const n=this.constructor,o=n._$Eh.get(e);if(void 0!==o&&this._$Em!==o){const e=n.getPropertyOptions(o),a="function"==typeof e.converter?{fromAttribute:e.converter}:void 0!==e.converter?.fromAttribute?e.converter:_;this._$Em=o;const i=a.fromAttribute(t,e.type);this[o]=i??this._$Ej?.get(o)??i,this._$Em=null}}requestUpdate(e,t,n,o=!1,a){if(void 0!==e){const i=this.constructor;if(!1===o&&(a=this[e]),n??=i.getPropertyOptions(e),!((n.hasChanged??v)(a,t)||n.useDefault&&n.reflect&&a===this._$Ej?.get(e)&&!this.hasAttribute(i._$Eu(e,n))))return;this.C(e,t,n)}!1===this.isUpdatePending&&(this._$ES=this._$EP())}C(e,t,{useDefault:n,reflect:o,wrapped:a},i){n&&!(this._$Ej??=new Map).has(e)&&(this._$Ej.set(e,i??t??this[e]),!0!==a||void 0!==i)||(this._$AL.has(e)||(this.hasUpdated||n||(t=void 0),this._$AL.set(e,t)),!0===o&&this._$Em!==e&&(this._$Eq??=new Set).add(e))}async _$EP(){this.isUpdatePending=!0;try{await this._$ES}catch(e){Promise.reject(e)}const e=this.scheduleUpdate();return null!=e&&await e,!this.isUpdatePending}scheduleUpdate(){return this.performUpdate()}performUpdate(){if(!this.isUpdatePending)return;if(!this.hasUpdated){if(this.renderRoot??=this.createRenderRoot(),this._$Ep){for(const[e,t]of this._$Ep)this[e]=t;this._$Ep=void 0}const e=this.constructor.elementProperties;if(e.size>0)for(const[t,n]of e){const{wrapped:e}=n,o=this[t];!0!==e||this._$AL.has(t)||void 0===o||this.C(t,void 0,n,o)}}let e=!1;const t=this._$AL;try{e=this.shouldUpdate(t),e?(this.willUpdate(t),this._$EO?.forEach(e=>e.hostUpdate?.()),this.update(t)):this._$EM()}catch(t){throw e=!1,this._$EM(),t}e&&this._$AE(t)}willUpdate(e){}_$AE(e){this._$EO?.forEach(e=>e.hostUpdated?.()),this.hasUpdated||(this.hasUpdated=!0,this.firstUpdated(e)),this.updated(e)}_$EM(){this._$AL=new Map,this.isUpdatePending=!1}get updateComplete(){return this.getUpdateComplete()}getUpdateComplete(){return this._$ES}shouldUpdate(e){return!0}update(e){this._$Eq&&=this._$Eq.forEach(e=>this._$ET(e,this[e])),this._$EM()}updated(e){}firstUpdated(e){}};$.elementStyles=[],$.shadowRootOptions={mode:"open"},$[y("elementProperties")]=new Map,$[y("finalized")]=new Map,g?.({ReactiveElement:$}),(p.reactiveElementVersions??=[]).push("2.1.2"); + */Symbol.metadata??=Symbol("metadata"),m.litPropertyMetadata??=new WeakMap;let x=class extends HTMLElement{static addInitializer(e){this._$Ei(),(this.l??=[]).push(e)}static get observedAttributes(){return this.finalize(),this._$Eh&&[...this._$Eh.keys()]}static createProperty(e,t=b){if(t.state&&(t.attribute=!1),this._$Ei(),this.prototype.hasOwnProperty(e)&&((t=Object.create(t)).wrapped=!0),this.elementProperties.set(e,t),!t.noAccessor){const n=Symbol(),o=this.getPropertyDescriptor(e,n,t);void 0!==o&&l(this.prototype,e,o)}}static getPropertyDescriptor(e,t,n){const{get:o,set:a}=c(this.prototype,e)??{get(){return this[t]},set(e){this[t]=e}};return{get:o,set(t){const r=o?.call(this);a?.call(this,t),this.requestUpdate(e,r,n)},configurable:!0,enumerable:!0}}static getPropertyOptions(e){return this.elementProperties.get(e)??b}static _$Ei(){if(this.hasOwnProperty(y("elementProperties")))return;const e=u(this);e.finalize(),void 0!==e.l&&(this.l=[...e.l]),this.elementProperties=new Map(e.elementProperties)}static finalize(){if(this.hasOwnProperty(y("finalized")))return;if(this.finalized=!0,this._$Ei(),this.hasOwnProperty(y("properties"))){const e=this.properties,t=[...d(e),...h(e)];for(const n of t)this.createProperty(n,e[n])}const e=this[Symbol.metadata];if(null!==e){const t=litPropertyMetadata.get(e);if(void 0!==t)for(const[e,n]of t)this.elementProperties.set(e,n)}this._$Eh=new Map;for(const[t,n]of this.elementProperties){const e=this._$Eu(t,n);void 0!==e&&this._$Eh.set(e,t)}this.elementStyles=this.finalizeStyles(this.styles)}static finalizeStyles(e){const t=[];if(Array.isArray(e)){const n=new Set(e.flat(1/0).reverse());for(const e of n)t.unshift(i(e))}else void 0!==e&&t.push(i(e));return t}static _$Eu(e,t){const n=t.attribute;return!1===n?void 0:"string"==typeof n?n:"string"==typeof e?e.toLowerCase():void 0}constructor(){super(),this._$Ep=void 0,this.isUpdatePending=!1,this.hasUpdated=!1,this._$Em=null,this._$Ev()}_$Ev(){this._$ES=new Promise(e=>this.enableUpdating=e),this._$AL=new Map,this._$E_(),this.requestUpdate(),this.constructor.l?.forEach(e=>e(this))}addController(e){(this._$EO??=new Set).add(e),void 0!==this.renderRoot&&this.isConnected&&e.hostConnected?.()}removeController(e){this._$EO?.delete(e)}_$E_(){const e=new Map,t=this.constructor.elementProperties;for(const n of t.keys())this.hasOwnProperty(n)&&(e.set(n,this[n]),delete this[n]);e.size>0&&(this._$Ep=e)}createRenderRoot(){const n=this.shadowRoot??this.attachShadow(this.constructor.shadowRootOptions);return((n,o)=>{if(t)n.adoptedStyleSheets=o.map(e=>e instanceof CSSStyleSheet?e:e.styleSheet);else for(const t of o){const o=document.createElement("style"),a=e.litNonce;void 0!==a&&o.setAttribute("nonce",a),o.textContent=t.cssText,n.appendChild(o)}})(n,this.constructor.elementStyles),n}connectedCallback(){this.renderRoot??=this.createRenderRoot(),this.enableUpdating(!0),this._$EO?.forEach(e=>e.hostConnected?.())}enableUpdating(e){}disconnectedCallback(){this._$EO?.forEach(e=>e.hostDisconnected?.())}attributeChangedCallback(e,t,n){this._$AK(e,n)}_$ET(e,t){const n=this.constructor.elementProperties.get(e),o=this.constructor._$Eu(e,n);if(void 0!==o&&!0===n.reflect){const a=(void 0!==n.converter?.toAttribute?n.converter:v).toAttribute(t,n.type);this._$Em=e,null==a?this.removeAttribute(o):this.setAttribute(o,a),this._$Em=null}}_$AK(e,t){const n=this.constructor,o=n._$Eh.get(e);if(void 0!==o&&this._$Em!==o){const e=n.getPropertyOptions(o),a="function"==typeof e.converter?{fromAttribute:e.converter}:void 0!==e.converter?.fromAttribute?e.converter:v;this._$Em=o;const r=a.fromAttribute(t,e.type);this[o]=r??this._$Ej?.get(o)??r,this._$Em=null}}requestUpdate(e,t,n,o=!1,a){if(void 0!==e){const r=this.constructor;if(!1===o&&(a=this[e]),n??=r.getPropertyOptions(e),!((n.hasChanged??_)(a,t)||n.useDefault&&n.reflect&&a===this._$Ej?.get(e)&&!this.hasAttribute(r._$Eu(e,n))))return;this.C(e,t,n)}!1===this.isUpdatePending&&(this._$ES=this._$EP())}C(e,t,{useDefault:n,reflect:o,wrapped:a},r){n&&!(this._$Ej??=new Map).has(e)&&(this._$Ej.set(e,r??t??this[e]),!0!==a||void 0!==r)||(this._$AL.has(e)||(this.hasUpdated||n||(t=void 0),this._$AL.set(e,t)),!0===o&&this._$Em!==e&&(this._$Eq??=new Set).add(e))}async _$EP(){this.isUpdatePending=!0;try{await this._$ES}catch(e){Promise.reject(e)}const e=this.scheduleUpdate();return null!=e&&await e,!this.isUpdatePending}scheduleUpdate(){return this.performUpdate()}performUpdate(){if(!this.isUpdatePending)return;if(!this.hasUpdated){if(this.renderRoot??=this.createRenderRoot(),this._$Ep){for(const[e,t]of this._$Ep)this[e]=t;this._$Ep=void 0}const e=this.constructor.elementProperties;if(e.size>0)for(const[t,n]of e){const{wrapped:e}=n,o=this[t];!0!==e||this._$AL.has(t)||void 0===o||this.C(t,void 0,n,o)}}let e=!1;const t=this._$AL;try{e=this.shouldUpdate(t),e?(this.willUpdate(t),this._$EO?.forEach(e=>e.hostUpdate?.()),this.update(t)):this._$EM()}catch(t){throw e=!1,this._$EM(),t}e&&this._$AE(t)}willUpdate(e){}_$AE(e){this._$EO?.forEach(e=>e.hostUpdated?.()),this.hasUpdated||(this.hasUpdated=!0,this.firstUpdated(e)),this.updated(e)}_$EM(){this._$AL=new Map,this.isUpdatePending=!1}get updateComplete(){return this.getUpdateComplete()}getUpdateComplete(){return this._$ES}shouldUpdate(e){return!0}update(e){this._$Eq&&=this._$Eq.forEach(e=>this._$ET(e,this[e])),this._$EM()}updated(e){}firstUpdated(e){}};x.elementStyles=[],x.shadowRootOptions={mode:"open"},x[y("elementProperties")]=new Map,x[y("finalized")]=new Map,g?.({ReactiveElement:x}),(m.reactiveElementVersions??=[]).push("2.1.2"); /** * @license * Copyright 2017 Google LLC * SPDX-License-Identifier: BSD-3-Clause */ -const x=globalThis,w=e=>e,k=x.trustedTypes,M=k?k.createPolicy("lit-html",{createHTML:e=>e}):void 0,A="$lit$",E=`lit$${Math.random().toFixed(9).slice(2)}$`,S="?"+E,C=`<${S}>`,O=document,L=()=>O.createComment(""),z=e=>null===e||"object"!=typeof e&&"function"!=typeof e,T=Array.isArray,N="[ \t\n\f\r]",j=/<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g,P=/-->/g,U=/>/g,F=RegExp(`>|${N}(?:([^\\s"'>=/]+)(${N}*=${N}*(?:[^ \t\n\f\r"'\`<>=]|("|')|))|$)`,"g"),H=/'/g,D=/"/g,B=/^(?:script|style|textarea|title)$/i,V=e=>(t,...n)=>({_$litType$:e,strings:t,values:n}),R=V(1),I=V(2),W=Symbol.for("lit-noChange"),Z=Symbol.for("lit-nothing"),q=new WeakMap,Y=O.createTreeWalker(O,129);function G(e,t){if(!T(e)||!e.hasOwnProperty("raw"))throw Error("invalid template strings array");return void 0!==M?M.createHTML(t):t}class X{constructor({strings:e,_$litType$:t},n){let o;this.parts=[];let a=0,i=0;const r=e.length-1,s=this.parts,[l,c]=((e,t)=>{const n=e.length-1,o=[];let a,i=2===t?"":3===t?"":"",r=j;for(let s=0;s"===l[0]?(r=a??j,c=-1):void 0===l[1]?c=-2:(c=r.lastIndex-l[2].length,n=l[1],r=void 0===l[3]?F:'"'===l[3]?D:H):r===D||r===H?r=F:r===P||r===U?r=j:(r=F,a=void 0);const h=r===F&&e[s+1].startsWith("/>")?" ":"";i+=r===j?t+C:c>=0?(o.push(n),t.slice(0,c)+A+t.slice(c)+E+h):t+E+(-2===c?s:h)}return[G(e,i+(e[n]||"")+(2===t?"":3===t?"":"")),o]})(e,t);if(this.el=X.createElement(l,n),Y.currentNode=this.el.content,2===t||3===t){const e=this.el.content.firstChild;e.replaceWith(...e.childNodes)}for(;null!==(o=Y.nextNode())&&s.length0){o.textContent=k?k.emptyScript:"";for(let n=0;nT(e)||"function"==typeof e?.[Symbol.iterator])(e)?this.k(e):this._(e)}O(e){return this._$AA.parentNode.insertBefore(e,this._$AB)}T(e){this._$AH!==e&&(this._$AR(),this._$AH=this.O(e))}_(e){this._$AH!==Z&&z(this._$AH)?this._$AA.nextSibling.data=e:this.T(O.createTextNode(e)),this._$AH=e}$(e){const{values:t,_$litType$:n}=e,o="number"==typeof n?this._$AC(e):(void 0===n.el&&(n.el=X.createElement(G(n.h,n.h[0]),this.options)),n);if(this._$AH?._$AD===o)this._$AH.p(t);else{const e=new K(o,this),n=e.u(this.options);e.p(t),this.T(n),this._$AH=e}}_$AC(e){let t=q.get(e.strings);return void 0===t&&q.set(e.strings,t=new X(e)),t}k(e){T(this._$AH)||(this._$AH=[],this._$AR());const t=this._$AH;let n,o=0;for(const a of e)o===t.length?t.push(n=new Q(this.O(L()),this.O(L()),this,this.options)):n=t[o],n._$AI(a),o++;o2||""!==n[0]||""!==n[1]?(this._$AH=Array(n.length-1).fill(new String),this.strings=n):this._$AH=Z}_$AI(e,t=this,n,o){const a=this.strings;let i=!1;if(void 0===a)e=J(this,e,t,0),i=!z(e)||e!==this._$AH&&e!==W,i&&(this._$AH=e);else{const o=e;let r,s;for(e=a[0],r=0;re,k=$.trustedTypes,M=k?k.createPolicy("lit-html",{createHTML:e=>e}):void 0,A="$lit$",S=`lit$${Math.random().toFixed(9).slice(2)}$`,E="?"+S,C=`<${E}>`,z=document,L=()=>z.createComment(""),T=e=>null===e||"object"!=typeof e&&"function"!=typeof e,j=Array.isArray,O="[ \t\n\f\r]",N=/<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g,U=/-->/g,F=/>/g,P=RegExp(`>|${O}(?:([^\\s"'>=/]+)(${O}*=${O}*(?:[^ \t\n\f\r"'\`<>=]|("|')|))|$)`,"g"),D=/'/g,H=/"/g,B=/^(?:script|style|textarea|title)$/i,I=e=>(t,...n)=>({_$litType$:e,strings:t,values:n}),R=I(1),V=I(2),q=Symbol.for("lit-noChange"),W=Symbol.for("lit-nothing"),Z=new WeakMap,Y=z.createTreeWalker(z,129);function G(e,t){if(!j(e)||!e.hasOwnProperty("raw"))throw Error("invalid template strings array");return void 0!==M?M.createHTML(t):t}class X{constructor({strings:e,_$litType$:t},n){let o;this.parts=[];let a=0,r=0;const i=e.length-1,s=this.parts,[l,c]=((e,t)=>{const n=e.length-1,o=[];let a,r=2===t?"":3===t?"":"",i=N;for(let s=0;s"===l[0]?(i=a??N,c=-1):void 0===l[1]?c=-2:(c=i.lastIndex-l[2].length,n=l[1],i=void 0===l[3]?P:'"'===l[3]?H:D):i===H||i===D?i=P:i===U||i===F?i=N:(i=P,a=void 0);const h=i===P&&e[s+1].startsWith("/>")?" ":"";r+=i===N?t+C:c>=0?(o.push(n),t.slice(0,c)+A+t.slice(c)+S+h):t+S+(-2===c?s:h)}return[G(e,r+(e[n]||"")+(2===t?"":3===t?"":"")),o]})(e,t);if(this.el=X.createElement(l,n),Y.currentNode=this.el.content,2===t||3===t){const e=this.el.content.firstChild;e.replaceWith(...e.childNodes)}for(;null!==(o=Y.nextNode())&&s.length0){o.textContent=k?k.emptyScript:"";for(let n=0;nj(e)||"function"==typeof e?.[Symbol.iterator])(e)?this.k(e):this._(e)}O(e){return this._$AA.parentNode.insertBefore(e,this._$AB)}T(e){this._$AH!==e&&(this._$AR(),this._$AH=this.O(e))}_(e){this._$AH!==W&&T(this._$AH)?this._$AA.nextSibling.data=e:this.T(z.createTextNode(e)),this._$AH=e}$(e){const{values:t,_$litType$:n}=e,o="number"==typeof n?this._$AC(e):(void 0===n.el&&(n.el=X.createElement(G(n.h,n.h[0]),this.options)),n);if(this._$AH?._$AD===o)this._$AH.p(t);else{const e=new K(o,this),n=e.u(this.options);e.p(t),this.T(n),this._$AH=e}}_$AC(e){let t=Z.get(e.strings);return void 0===t&&Z.set(e.strings,t=new X(e)),t}k(e){j(this._$AH)||(this._$AH=[],this._$AR());const t=this._$AH;let n,o=0;for(const a of e)o===t.length?t.push(n=new Q(this.O(L()),this.O(L()),this,this.options)):n=t[o],n._$AI(a),o++;o2||""!==n[0]||""!==n[1]?(this._$AH=Array(n.length-1).fill(new String),this.strings=n):this._$AH=W}_$AI(e,t=this,n,o){const a=this.strings;let r=!1;if(void 0===a)e=J(this,e,t,0),r=!T(e)||e!==this._$AH&&e!==q,r&&(this._$AH=e);else{const o=e;let i,s;for(e=a[0],i=0;i{const o=n?.renderBefore??t;let a=o._$litPart$;if(void 0===a){const e=n?.renderBefore??null;o._$litPart$=a=new Q(t.insertBefore(L(),e),e,void 0,n??{})}return a._$AI(e),a})(t,this.renderRoot,this.renderOptions)}connectedCallback(){super.connectedCallback(),this._$Do?.setConnected(!0)}disconnectedCallback(){super.disconnectedCallback(),this._$Do?.setConnected(!1)}render(){return W}};le._$litElement$=!0,le.finalized=!0,se.litElementHydrateSupport?.({LitElement:le});const ce=se.litElementPolyfillSupport;ce?.({LitElement:le}),(se.litElementVersions??=[]).push("4.2.2"); + */let le=class extends x{constructor(){super(...arguments),this.renderOptions={host:this},this._$Do=void 0}createRenderRoot(){const e=super.createRenderRoot();return this.renderOptions.renderBefore??=e.firstChild,e}update(e){const t=this.render();this.hasUpdated||(this.renderOptions.isConnected=this.isConnected),super.update(e),this._$Do=((e,t,n)=>{const o=n?.renderBefore??t;let a=o._$litPart$;if(void 0===a){const e=n?.renderBefore??null;o._$litPart$=a=new Q(t.insertBefore(L(),e),e,void 0,n??{})}return a._$AI(e),a})(t,this.renderRoot,this.renderOptions)}connectedCallback(){super.connectedCallback(),this._$Do?.setConnected(!0)}disconnectedCallback(){super.disconnectedCallback(),this._$Do?.setConnected(!1)}render(){return q}};le._$litElement$=!0,le.finalized=!0,se.litElementHydrateSupport?.({LitElement:le});const ce=se.litElementPolyfillSupport;ce?.({LitElement:le}),(se.litElementVersions??=[]).push("4.2.2"); /** * @license * Copyright 2017 Google LLC * SPDX-License-Identifier: BSD-3-Clause */ -const de=1,he=2,ue=3,pe=4,me=e=>(...t)=>({_$litDirective$:e,values:t});let fe=class{constructor(e){}get _$AU(){return this._$AM._$AU}_$AT(e,t,n){this._$Ct=e,this._$AM=t,this._$Ci=n}_$AS(e,t){return this.update(e,t)}update(e,t){return this.render(...t)}}; +const de=1,he=2,ue=3,me=4,pe=e=>(...t)=>({_$litDirective$:e,values:t});let fe=class{constructor(e){}get _$AU(){return this._$AM._$AU}_$AT(e,t,n){this._$Ct=e,this._$AM=t,this._$Ci=n}_$AS(e,t){return this.update(e,t)}update(e,t){return this.render(...t)}}; /** * @license * Copyright 2020 Google LLC * SPDX-License-Identifier: BSD-3-Clause - */const{I:ge}=ie,ye=e=>e,_e=()=>document.createComment(""),ve=(e,t,n)=>{const o=e._$AA.parentNode,a=void 0===t?e._$AB:t._$AA;if(void 0===n){const t=o.insertBefore(_e(),a),i=o.insertBefore(_e(),a);n=new ge(t,i,e,e.options)}else{const t=n._$AB.nextSibling,i=n._$AM,r=i!==e;if(r){let t;n._$AQ?.(e),n._$AM=e,void 0!==n._$AP&&(t=e._$AU)!==i._$AU&&n._$AP(t)}if(t!==a||r){let e=n._$AA;for(;e!==t;){const t=ye(e).nextSibling;ye(o).insertBefore(e,a),e=t}}}return n},be=(e,t,n=e)=>(e._$AI(t,n),e),$e={},xe=(e,t=$e)=>e._$AH=t,we=e=>{e._$AR(),e._$AA.remove()},ke=(e,t,n)=>{const o=new Map;for(let a=t;a<=n;a++)o.set(e[a],a);return o},Me=me(class extends fe{constructor(e){if(super(e),e.type!==he)throw Error("repeat() can only be used in text expressions")}dt(e,t,n){let o;void 0===n?n=t:void 0!==t&&(o=t);const a=[],i=[];let r=0;for(const s of e)a[r]=o?o(s,r):r,i[r]=n(s,r),r++;return{values:i,keys:a}}render(e,t,n){return this.dt(e,t,n).values}update(e,[t,n,o]){const a=(e=>e._$AH)(e),{values:i,keys:r}=this.dt(t,n,o);if(!Array.isArray(a))return this.ut=r,i;const s=this.ut??=[],l=[];let c,d,h=0,u=a.length-1,p=0,m=i.length-1;for(;h<=u&&p<=m;)if(null===a[h])h++;else if(null===a[u])u--;else if(s[h]===r[p])l[p]=be(a[h],i[p]),h++,p++;else if(s[u]===r[m])l[m]=be(a[u],i[m]),u--,m--;else if(s[h]===r[m])l[m]=be(a[h],i[m]),ve(e,l[m+1],a[h]),h++,m--;else if(s[u]===r[p])l[p]=be(a[u],i[p]),ve(e,a[h],a[u]),u--,p++;else if(void 0===c&&(c=ke(r,p,m),d=ke(s,h,u)),c.has(s[h]))if(c.has(s[u])){const t=d.get(r[p]),n=void 0!==t?a[t]:null;if(null===n){const t=ve(e,a[h]);be(t,i[p]),l[p]=t}else l[p]=be(n,i[p]),ve(e,a[h],n),a[t]=null;p++}else we(a[u]),u--;else we(a[h]),h++;for(;p<=m;){const t=ve(e,l[m+1]);be(t,i[p]),l[p++]=t}for(;h<=u;){const e=a[h++];null!==e&&we(e)}return this.ut=r,xe(e,l),W}}),Ae=e=>!!e&&"none"!==e.action; + */const{I:ge}=re,ye=e=>e,ve=()=>document.createComment(""),_e=(e,t,n)=>{const o=e._$AA.parentNode,a=void 0===t?e._$AB:t._$AA;if(void 0===n){const t=o.insertBefore(ve(),a),r=o.insertBefore(ve(),a);n=new ge(t,r,e,e.options)}else{const t=n._$AB.nextSibling,r=n._$AM,i=r!==e;if(i){let t;n._$AQ?.(e),n._$AM=e,void 0!==n._$AP&&(t=e._$AU)!==r._$AU&&n._$AP(t)}if(t!==a||i){let e=n._$AA;for(;e!==t;){const t=ye(e).nextSibling;ye(o).insertBefore(e,a),e=t}}}return n},be=(e,t,n=e)=>(e._$AI(t,n),e),xe={},$e=(e,t=xe)=>e._$AH=t,we=e=>{e._$AR(),e._$AA.remove()},ke=(e,t,n)=>{const o=new Map;for(let a=t;a<=n;a++)o.set(e[a],a);return o},Me=pe(class extends fe{constructor(e){if(super(e),e.type!==he)throw Error("repeat() can only be used in text expressions")}dt(e,t,n){let o;void 0===n?n=t:void 0!==t&&(o=t);const a=[],r=[];let i=0;for(const s of e)a[i]=o?o(s,i):i,r[i]=n(s,i),i++;return{values:r,keys:a}}render(e,t,n){return this.dt(e,t,n).values}update(e,[t,n,o]){const a=(e=>e._$AH)(e),{values:r,keys:i}=this.dt(t,n,o);if(!Array.isArray(a))return this.ut=i,r;const s=this.ut??=[],l=[];let c,d,h=0,u=a.length-1,m=0,p=r.length-1;for(;h<=u&&m<=p;)if(null===a[h])h++;else if(null===a[u])u--;else if(s[h]===i[m])l[m]=be(a[h],r[m]),h++,m++;else if(s[u]===i[p])l[p]=be(a[u],r[p]),u--,p--;else if(s[h]===i[p])l[p]=be(a[h],r[p]),_e(e,l[p+1],a[h]),h++,p--;else if(s[u]===i[m])l[m]=be(a[u],r[m]),_e(e,a[h],a[u]),u--,m++;else if(void 0===c&&(c=ke(i,m,p),d=ke(s,h,u)),c.has(s[h]))if(c.has(s[u])){const t=d.get(i[m]),n=void 0!==t?a[t]:null;if(null===n){const t=_e(e,a[h]);be(t,r[m]),l[m]=t}else l[m]=be(n,r[m]),_e(e,a[h],n),a[t]=null;m++}else we(a[u]),u--;else we(a[h]),h++;for(;m<=p;){const t=_e(e,l[p+1]);be(t,r[m]),l[m++]=t}for(;h<=u;){const e=a[h++];null!==e&&we(e)}return this.ut=i,$e(e,l),q}}),Ae=e=>!!e&&"none"!==e.action; /** * @license * Copyright 2017 Google LLC * SPDX-License-Identifier: BSD-3-Clause - */function Ee(e){return Ae(e.tap_action)||Ae(e.hold_action)||Ae(e.double_tap_action)}function Se(e,t){let n,o,a=0,i=0,r=!1,s=!1;const l=n=>{const o=t(),a="tap"===n?o.tap_action:"hold"===n?o.hold_action:o.double_tap_action;Ae(a)&&e.dispatchEvent(new CustomEvent("hass-action",{bubbles:!0,composed:!0,detail:{config:o,action:n}}))},c=()=>{void 0!==n&&clearTimeout(n),n=void 0},d=e=>{const o=e;o.button>0||(r=!0,s=!1,a=o.clientX,i=o.clientY,c(),Ae(t().hold_action)&&(n=setTimeout(()=>{n=void 0,s=!0,l("hold")},500)))},h=e=>{if(!r)return;const t=e;Math.hypot(t.clientX-a,t.clientY-i)>10&&(c(),r=!1)},u=()=>{c(),r=!1},p=()=>{const e=r;c(),r=!1,e&&!s?Ae(t().double_tap_action)?void 0!==o?(clearTimeout(o),o=void 0,l("double_tap")):o=setTimeout(()=>{o=void 0,l("tap")},250):l("tap"):s=!1},m=e=>{const t=e;t.repeat||"Enter"!==t.key&&" "!==t.key||(" "===t.key&&t.preventDefault(),l("tap"))},f=e=>{Ae(t().hold_action)&&e.preventDefault()};return e.addEventListener("pointerdown",d),e.addEventListener("pointermove",h),e.addEventListener("pointerup",p),e.addEventListener("pointercancel",u),e.addEventListener("pointerleave",u),e.addEventListener("keydown",m),e.addEventListener("contextmenu",f),()=>{c(),void 0!==o&&clearTimeout(o),o=void 0,e.removeEventListener("pointerdown",d),e.removeEventListener("pointermove",h),e.removeEventListener("pointerup",p),e.removeEventListener("pointercancel",u),e.removeEventListener("pointerleave",u),e.removeEventListener("keydown",m),e.removeEventListener("contextmenu",f)}}const Ce={primary:{variable:"--primary-color",fallback:"#2f6fe4"},green:{variable:"--success-color",fallback:"#1fae7a"},amber:{variable:"--warning-color",fallback:"#f59e0b"},red:{variable:"--error-color",fallback:"#ef3b5a"},blue:{variable:"--info-color",fallback:"#2f6fe4"},purple:{variable:"--purple-color",fallback:"#8b6cff"},grey:{variable:"--grey-color",fallback:"#8e919a"}};function Oe(e){if(Object.prototype.hasOwnProperty.call(Ce,e)){const{variable:t,fallback:n}=Ce[e];return`var(${t}, ${n})`}return e}const Le=new Set(["amber"]);function ze(e){if(Object.prototype.hasOwnProperty.call(Ce,e))return Le.has(e)?"dark":"light";const t=function(e){const t=e.trim().toLowerCase(),n=/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/.exec(t);if(n){const e=n[1].length<=4?[...n[1]].map(e=>e+e).join(""):n[1],[t,o,a]=[0,2,4].map(t=>parseInt(e.slice(t,t+2),16));return[t,o,a,8===e.length?parseInt(e.slice(6,8),16)/255:1]}const o=/^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)\s*(?:[,/]\s*([\d.]+)(%?)\s*)?\)$/.exec(t);if(o){const[e,t,n]=[o[1],o[2],o[3]].map(e=>Math.min(255,Number(e))),a=void 0===o[4]?1:Number(o[4])/(o[5]?100:1);return[e,t,n,Math.min(1,a)]}}(e);return t?t[3]<.5?"theme":function([e,t,n]){const o=e=>{const t=e/255;return t<=.03928?t/12.92:((t+.055)/1.055)**2.4};return.2126*o(e)+.7152*o(t)+.0722*o(n)}([t[0],t[1],t[2]])>.4?"dark":"light":"light"}function Te(e,t){let n=t[0];for(const o of t){if(!(o.from<=e))break;n=o}return n}const Ne={battery:[{from:0,color:"red",label:"Low"},{from:20,color:"amber"},{from:50,color:"green"}],usage:[{from:0,color:"green"},{from:60,color:"amber"},{from:85,color:"red",label:"High"}],temperature:[{from:-50,color:"blue",label:"Cold"},{from:16,color:"green"},{from:24,color:"amber"},{from:28,color:"red",label:"Hot"}],none:void 0},je=["label","area","floor","device_class","domain","entity"];function Pe(e,t){if(null==e)return;if("object"!=typeof e||Array.isArray(e))throw new Error(`"${t}" must list what to match, such as "area" or "device_class"`);const n="include"===t?je.filter(e=>"entity"!==e):je,o={};for(const[a,i]of Object.entries(e)){if(!n.includes(a))throw new Error(`"${t}" can't match on "${a}". Use: ${n.join(", ")}`);const e=(Array.isArray(i)?i:[i]).filter(e=>null!=e&&""!==e);if(e.some(e=>"string"!=typeof e))throw new Error(`"${t}.${a}" must be text or a list of text`);e.length&&(o[a]=e)}return Object.keys(o).length?o:void 0}function Ue(e){const t=e.state;return"unavailable"===t||"unknown"===t||""!==t.trim()&&Number.isFinite(Number(t))}function Fe(e,t){const n=e.entity_id,o=t.entities?.[n],a=o?.device_id?t.devices?.[o.device_id]:void 0,i=o?.area_id??a?.area_id??void 0,r=e.attributes?.device_class;return{label:[...o?.labels??[],...a?.labels??[]],area:i,floor:i?t.areas?.[i]?.floor_id??void 0:void 0,device_class:"string"==typeof r?r:void 0,domain:n.slice(0,n.indexOf(".")),entity:n}}function He(e,t,n){const o=e[t],a=n[t];return Array.isArray(a)?a.some(e=>o.includes(e)):void 0!==a&&o.includes(a)}function De(e,t){return Object.keys(e).every(n=>He(e,n,t))}function Be(e,t){return!!e&&Object.keys(e).some(n=>He(e,n,t))}const Ve=new WeakMap;const Re=["bar","thin","segmented","tank","ring","needle","arc","line","column","area","combined","pie","rings","pictograph","waffle"],Ie=[{from:-1/0,color:"primary"}],We=["none","ascending","descending"];function Ze(e){if("number"==typeof e)return Number.isFinite(e)?e:void 0;if("string"==typeof e&&""!==e.trim()){const t=Number(e);return Number.isFinite(t)?t:void 0}}function qe(e,t){if(null==e||""===e)return;const n=Ze(e);if(void 0!==n)return n;if("string"==typeof e)return e;throw new Error(`"${t}" must be a number or an entity id`)}function Ye(e,t,n,o=1/0){if(null==e||""===e)return;const a=Ze(e);if(void 0===a||!Number.isInteger(a)||ao){throw new Error(`"${t}" must be a whole number ${o===1/0?`of at least ${n}`:`from ${n} to ${o}`}`)}return a}function Ge(e){if(null==e)return;if(!Array.isArray(e))throw new Error('"severity" must be a list of bands');if(0===e.length)return;const t=e.map((e,t)=>{let n;try{n=qe(e?.from,"from")}catch{n=void 0}if(void 0===n)throw new Error(`Severity band ${t+1}: "from" must be a number or an entity id`);if("string"!=typeof e.color||!e.color)throw new Error(`Severity band ${t+1}: "color" is required`);return{...e,from:n}});return t.every(e=>"number"==typeof e.from)?t.sort((e,t)=>e.from-t.from):t}function Xe(e,t,n){if(null==e)return{action:t};if("object"!=typeof e||"string"!=typeof e.action)throw new Error(`"${n}" must have an "action"`);return e}function Je(e,t){return void 0!==e?e:t}function Ke(e,t,n){const o="string"==typeof e?{entity:e}:e;if(!o||"object"!=typeof o||"string"!=typeof o.entity||!o.entity)throw new Error(`Entity ${n+1} needs an "entity" id`);if(void 0!==o.attribute&&null!==o.attribute&&"string"!=typeof o.attribute)throw new Error(`Entity ${n+1}: "attribute" must be an attribute name`);const a=Je(qe(o.min,"min"),qe(t.min,"min"))??0,i=Je(qe(o.max,"max"),qe(t.max,"max"))??100;if("number"==typeof a&&"number"==typeof i&&a>=i)throw new Error(`"max" (${i}) must be greater than "min" (${a})`);const r={...o,entity:o.entity,min:a,max:i,severity:Ge(o.severity)??Ge(t.severity)??Ie,tap_action:Xe(Je(o.tap_action,t.tap_action),"more-info","tap_action"),hold_action:Xe(Je(o.hold_action,t.hold_action),"none","hold_action"),double_tap_action:Xe(Je(o.double_tap_action,t.double_tap_action),"none","double_tap_action")},s=Je(o.name,t.name),l=Je(o.icon,t.icon),c=Je(o.unit,t.unit),d=Je(qe(o.target,"target"),qe(t.target,"target")),h=Je(Ye(o.decimals,"decimals",0,20),Ye(t.decimals,"decimals",0,20));return void 0!==s&&(r.name=s),void 0!==l&&(r.icon=l),void 0!==c&&(r.unit=c),void 0!==d&&(r.target=d),void 0!==h&&(r.decimals=h),o.attribute||delete r.attribute,r} + */function Se(e){return Ae(e.tap_action)||Ae(e.hold_action)||Ae(e.double_tap_action)}function Ee(e,t){let n,o,a=0,r=0,i=!1,s=!1;const l=n=>{const o=t(),a="tap"===n?o.tap_action:"hold"===n?o.hold_action:o.double_tap_action;Ae(a)&&e.dispatchEvent(new CustomEvent("hass-action",{bubbles:!0,composed:!0,detail:{config:o,action:n}}))},c=()=>{void 0!==n&&clearTimeout(n),n=void 0},d=e=>{const o=e;o.button>0||(i=!0,s=!1,a=o.clientX,r=o.clientY,c(),Ae(t().hold_action)&&(n=setTimeout(()=>{n=void 0,s=!0,l("hold")},500)))},h=e=>{if(!i)return;const t=e;Math.hypot(t.clientX-a,t.clientY-r)>10&&(c(),i=!1)},u=()=>{c(),i=!1},m=()=>{const e=i;c(),i=!1,e&&!s?Ae(t().double_tap_action)?void 0!==o?(clearTimeout(o),o=void 0,l("double_tap")):o=setTimeout(()=>{o=void 0,l("tap")},250):l("tap"):s=!1},p=e=>{const t=e;t.repeat||"Enter"!==t.key&&" "!==t.key||(" "===t.key&&t.preventDefault(),l("tap"))},f=e=>{Ae(t().hold_action)&&e.preventDefault()};return e.addEventListener("pointerdown",d),e.addEventListener("pointermove",h),e.addEventListener("pointerup",m),e.addEventListener("pointercancel",u),e.addEventListener("pointerleave",u),e.addEventListener("keydown",p),e.addEventListener("contextmenu",f),()=>{c(),void 0!==o&&clearTimeout(o),o=void 0,e.removeEventListener("pointerdown",d),e.removeEventListener("pointermove",h),e.removeEventListener("pointerup",m),e.removeEventListener("pointercancel",u),e.removeEventListener("pointerleave",u),e.removeEventListener("keydown",p),e.removeEventListener("contextmenu",f)}}const Ce={primary:{variable:"--primary-color",fallback:"#2f6fe4"},green:{variable:"--success-color",fallback:"#1fae7a"},amber:{variable:"--warning-color",fallback:"#f59e0b"},red:{variable:"--error-color",fallback:"#ef3b5a"},blue:{variable:"--info-color",fallback:"#2f6fe4"},purple:{variable:"--purple-color",fallback:"#8b6cff"},grey:{variable:"--grey-color",fallback:"#8e919a"}};function ze(e){if(Object.prototype.hasOwnProperty.call(Ce,e)){const{variable:t,fallback:n}=Ce[e];return`var(${t}, ${n})`}return e}const Le=new Set(["amber"]);function Te(e){if(Object.prototype.hasOwnProperty.call(Ce,e))return Le.has(e)?"dark":"light";const t=function(e){const t=e.trim().toLowerCase(),n=/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/.exec(t);if(n){const e=n[1].length<=4?[...n[1]].map(e=>e+e).join(""):n[1],[t,o,a]=[0,2,4].map(t=>parseInt(e.slice(t,t+2),16));return[t,o,a,8===e.length?parseInt(e.slice(6,8),16)/255:1]}const o=/^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)\s*(?:[,/]\s*([\d.]+)(%?)\s*)?\)$/.exec(t);if(o){const[e,t,n]=[o[1],o[2],o[3]].map(e=>Math.min(255,Number(e))),a=void 0===o[4]?1:Number(o[4])/(o[5]?100:1);return[e,t,n,Math.min(1,a)]}}(e);return t?t[3]<.5?"theme":function([e,t,n]){const o=e=>{const t=e/255;return t<=.03928?t/12.92:((t+.055)/1.055)**2.4};return.2126*o(e)+.7152*o(t)+.0722*o(n)}([t[0],t[1],t[2]])>.4?"dark":"light":"light"}function je(e,t){let n=t[0];for(const o of t){if(!(o.from<=e))break;n=o}return n}function Oe(e,t,n){const o=n?` ${n}`:"",a=[];return e.forEach((n,r)=>{const i=e[r+1],s=ze(n.color);n.label?a.push({color:s,text:n.label}):Number.isFinite(n.from)?i?a.push({color:s,text:`${t(n.from)}–${t(i.from)}${o}`}):a.push({color:s,text:`${t(n.from)}${o}+`}):i&&a.push({color:s,text:`< ${t(i.from)}${o}`})}),a}const Ne={battery:[{from:0,color:"red",label:"Low"},{from:20,color:"amber"},{from:50,color:"green"}],usage:[{from:0,color:"green"},{from:60,color:"amber"},{from:85,color:"red",label:"High"}],temperature:[{from:-50,color:"blue",label:"Cold"},{from:16,color:"green"},{from:24,color:"amber"},{from:28,color:"red",label:"Hot"}],none:void 0},Ue=["label","area","floor","device_class","domain","entity"];function Fe(e,t){if(null==e)return;if("object"!=typeof e||Array.isArray(e))throw new Error(`"${t}" must list what to match, such as "area" or "device_class"`);const n="include"===t?Ue.filter(e=>"entity"!==e):Ue,o={};for(const[a,r]of Object.entries(e)){if(!n.includes(a))throw new Error(`"${t}" can't match on "${a}". Use: ${n.join(", ")}`);const e=(Array.isArray(r)?r:[r]).filter(e=>null!=e&&""!==e);if(e.some(e=>"string"!=typeof e))throw new Error(`"${t}.${a}" must be text or a list of text`);e.length&&(o[a]=e)}return Object.keys(o).length?o:void 0}function Pe(e){const t=e.state;return"unavailable"===t||"unknown"===t||""!==t.trim()&&Number.isFinite(Number(t))}function De(e,t){const n=e.entity_id,o=t.entities?.[n],a=o?.device_id?t.devices?.[o.device_id]:void 0,r=o?.area_id??a?.area_id??void 0,i=e.attributes?.device_class;return{label:[...o?.labels??[],...a?.labels??[]],area:r,floor:r?t.areas?.[r]?.floor_id??void 0:void 0,device_class:"string"==typeof i?i:void 0,domain:n.slice(0,n.indexOf(".")),entity:n}}function He(e,t,n){const o=e[t],a=n[t];return Array.isArray(a)?a.some(e=>o.includes(e)):void 0!==a&&o.includes(a)}function Be(e,t){return Object.keys(e).every(n=>He(e,n,t))}function Ie(e,t){return!!e&&Object.keys(e).some(n=>He(e,n,t))}const Re=new WeakMap;const Ve=["bar","thin","segmented","tank","ring","needle","arc","line","column","area","combined","pie","rings","pictograph","waffle","histogram","scatter","bubble","heatmap","box"],qe=[{from:-1/0,color:"primary"}],We=["none","ascending","descending"];function Ze(e){if("number"==typeof e)return Number.isFinite(e)?e:void 0;if("string"==typeof e&&""!==e.trim()){const t=Number(e);return Number.isFinite(t)?t:void 0}}function Ye(e,t){if(null==e||""===e)return;const n=Ze(e);if(void 0!==n)return n;if("string"==typeof e)return e;throw new Error(`"${t}" must be a number or an entity id`)}function Ge(e,t,n,o=1/0){if(null==e||""===e)return;const a=Ze(e);if(void 0===a||!Number.isInteger(a)||ao){throw new Error(`"${t}" must be a whole number ${o===1/0?`of at least ${n}`:`from ${n} to ${o}`}`)}return a}function Xe(e){if(null==e)return;if(!Array.isArray(e))throw new Error('"severity" must be a list of bands');if(0===e.length)return;const t=e.map((e,t)=>{let n;try{n=Ye(e?.from,"from")}catch{n=void 0}if(void 0===n)throw new Error(`Severity band ${t+1}: "from" must be a number or an entity id`);if("string"!=typeof e.color||!e.color)throw new Error(`Severity band ${t+1}: "color" is required`);return{...e,from:n}});return t.every(e=>"number"==typeof e.from)?t.sort((e,t)=>e.from-t.from):t}function Je(e,t,n){if(null==e)return{action:t};if("object"!=typeof e||"string"!=typeof e.action)throw new Error(`"${n}" must have an "action"`);return e}function Ke(e,t){return void 0!==e?e:t}function Qe(e,t,n){const o="string"==typeof e?{entity:e}:e;if(!o||"object"!=typeof o||"string"!=typeof o.entity||!o.entity)throw new Error(`Entity ${n+1} needs an "entity" id`);if(void 0!==o.attribute&&null!==o.attribute&&"string"!=typeof o.attribute)throw new Error(`Entity ${n+1}: "attribute" must be an attribute name`);const a=Ke(Ye(o.min,"min"),Ye(t.min,"min"))??0,r=Ke(Ye(o.max,"max"),Ye(t.max,"max"))??100;if("number"==typeof a&&"number"==typeof r&&a>=r)throw new Error(`"max" (${r}) must be greater than "min" (${a})`);const i={...o,entity:o.entity,min:a,max:r,severity:Xe(o.severity)??Xe(t.severity)??qe,tap_action:Je(Ke(o.tap_action,t.tap_action),"more-info","tap_action"),hold_action:Je(Ke(o.hold_action,t.hold_action),"none","hold_action"),double_tap_action:Je(Ke(o.double_tap_action,t.double_tap_action),"none","double_tap_action")},s=Ke(o.name,t.name),l=Ke(o.icon,t.icon),c=Ke(o.unit,t.unit),d=Ke(Ye(o.target,"target"),Ye(t.target,"target")),h=Ke(Ge(o.decimals,"decimals",0,20),Ge(t.decimals,"decimals",0,20));return void 0!==s&&(i.name=s),void 0!==l&&(i.icon=l),void 0!==c&&(i.unit=c),void 0!==d&&(i.target=d),void 0!==h&&(i.decimals=h),o.attribute||delete i.attribute,i} /** * @license * Copyright 2020 Google LLC * SPDX-License-Identifier: BSD-3-Clause */ -const Qe=me(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de&&e.type!==pe)throw Error("The `live` directive is not allowed on child or event bindings");if(!(e=>void 0===e.strings)(e))throw Error("`live` bindings can only contain a single expression")}render(e){return e}update(e,[t]){if(t===W||t===Z)return t;const n=e.element,o=e.name;if(e.type===ue){if(t===n[o])return W}else if(e.type===pe){if(!!t===n.hasAttribute(o))return W}else if(e.type===de&&n.getAttribute(o)===t+"")return W;return xe(e),t}}),et={en:{entity_not_found:"Entity not found",unavailable:"Unavailable",no_entities:"Add an entity in the editor to get started.",no_matches:"No entities match right now.",none_available:"None of these entities are available.",editor_section_entities:"Entities",editor_section_style:"Style",editor_section_chart:"Chart",editor_group_bars:"Bars",editor_group_gauges:"Gauges",editor_group_history:"History charts",editor_group_totals:"Parts of a total",editor_group_compare:"Comparisons",editor_group_distribution:"Distributions",editor_period_last:"Show the last",editor_period_unit:"Unit",editor_period_unit_hour:"Hours",editor_period_unit_day:"Days",editor_period_unit_week:"Weeks",editor_period_unit_month:"Months",editor_source:"Readings from",editor_source_history:"History (every change)",editor_source_statistics:"Long-term statistics",editor_curve:"Line shape",editor_curve_smooth:"Smooth",editor_curve_straight:"Straight",editor_curve_step:"Steps",editor_color_by:"Colour",editor_color_by_series:"One colour per entity",editor_color_by_bands:"By colour band",editor_labels:"Show values on the points",editor_series_color:"Line colour",editor_dash:"Dashed",editor_section_limits:"Limits",editor_section_colours:"Colours",editor_section_display:"Display",editor_section_actions:"Actions",editor_title:"Title",editor_style_bar:"Bar",editor_style_thin:"Thin",editor_style_segmented:"Segmented",editor_style_tank:"Tank",editor_style_ring:"Ring",editor_style_needle:"Needle",editor_style_arc:"Arc",editor_show_name:"Show name",editor_show_value:"Show value",editor_show_icon:"Show icon",editor_show_target:"Show target",editor_show_legend:"Show legend",editor_columns:"Columns",editor_sort:"Sort",editor_sort_none:"As listed",editor_sort_ascending:"Lowest first",editor_sort_descending:"Highest first",editor_decimals:"Decimals",editor_unit:"Unit",editor_animate:"Animate",editor_hide_unavailable:"Hide unavailable entities",editor_limit:"Show at most",editor_include_heading:"Also show every entity that matches",editor_include_help:"New devices appear without editing the card. Pick several of one kind to match any of them; different kinds must all match.",editor_exclude_heading:"Leave out",editor_exclude_help:"Matched entities are left out if they match any of these.",editor_rule_device_class:"Device class",editor_rule_area:"Areas",editor_rule_floor:"Floors",editor_rule_label:"Labels",editor_rule_domain:"Domains",editor_rule_entity:"Entities",editor_tap_action:"Tap action",editor_hold_action:"Hold action",editor_double_tap_action:"Double tap action",editor_attribute:"Attribute (instead of the state)",editor_name:"Name",editor_icon:"Icon",editor_min:"Minimum",editor_max:"Maximum",editor_target:"Target",editor_none:"None",editor_card_setting:"card setting",editor_limit_default:"Default ({value})",editor_limit_number:"Number",editor_limit_entity:"Entity",editor_preset:"Preset",editor_preset_battery:"Battery (low is bad)",editor_preset_usage:"Usage (high is bad)",editor_preset_temperature:"Temperature",editor_preset_none:"None",editor_preset_custom:"Custom",editor_band_from:"From",editor_band_from_entity:"Start from an entity",editor_band_from_number:"Start from a number",editor_band_from_entity_label:"Starts at this entity",editor_band_color:"Colour",editor_band_custom_color:"CSS colour",editor_band_label:"Label",editor_band_icon:"Icon",editor_color_icon:"Colour icons to match their band",editor_color_primary:"Primary",editor_color_green:"Green",editor_color_amber:"Amber",editor_color_red:"Red",editor_color_blue:"Blue",editor_color_purple:"Purple",editor_color_grey:"Grey",editor_color_custom:"Custom…",editor_add_band:"Add band",editor_remove_band:"Remove band",editor_add_entity:"Add entity",editor_move_up:"Move up",editor_move_down:"Move down",editor_remove_entity:"Remove entity",editor_show_options:"Show options",editor_hide_options:"Hide options",editor_not_found:"{entity} (not found)",editor_severity:"Colours"}};function tt(e,t){et[e]={...et[e],...t}}function nt(e){return(e?.locale?.language??e?.language??"en").split("-")[0]}function ot(e,t,n){const o=nt(e),a=et[o]?.[t]??et.en[t]??t;return n?a.replace(/\{(\w+)\}/g,(e,t)=>t in n?String(n[t]):e):a}function at(e){return null==e||""===e}function it(e){return"object"==typeof e&&null!==e&&!Array.isArray(e)}function rt(e,t,n){const[o,...a]=t,i=function(e,t){return Array.isArray(e)?[...e]:it(e)?{...e}:"number"==typeof t?[]:{}}(e,o),r=i,s=a.length?rt(r[o],a,n):n,l=a.length>0&&it(s)&&0===Object.keys(s).length;return at(s)?Array.isArray(i)&&"number"==typeof o?i.splice(o,1):delete r[o]:l&&it(i)?delete r[o]:r[o]=s,i}function st(e,t,n){return 0===t.length||at(n)&&void 0===function(e,t){let n=e;for(const o of t){if("object"!=typeof n||null===n)return;n=n[o]}return n}(e,t)?e:rt(e,t,n)}function lt(e,t){const n=e;return{width:n.line_width??t.line_width??2.5,dots:n.dots??!0===t.dots,onTop:!0===n.on_top}}function ct(e,t){return[...e.filter(e=>!t(e)),...e.filter(t)]}function dt(e){return e.map(([e,t])=>`M${Math.round(100*e)/100},${Math.round(100*t)/100}h0`).join("")}function ht(e){return Math.round(10*(e+3.5))/10}function ut(e,t){if(void 0!==e&&("number"!=typeof e||!Number.isFinite(e)||e<.5||e>12))throw new Error(`${t} must be a number from 0.5 to 12`)}function pt(e,t){if(void 0!==e&&"boolean"!=typeof e)throw new Error(`${t} must be true or false`)}function mt(e){ut(e.line_width,'"line_width"'),pt(e.dots,'"dots"');for(const t of e.entities??[])"object"==typeof t&&(ut(t.line_width,`"line_width" of ${t.entity}`),pt(t.dots,`"dots" of ${t.entity}`),pt(t.on_top,`"on_top" of ${t.entity}`))}const ft=[{name:"title",path:["title"]}],gt=[{name:"show_name",path:["show","name"],default:!0},{name:"show_value",path:["show","value"],default:!0},{name:"show_icon",path:["show","icon"],default:!0},{name:"show_target",path:["show","target"],default:!0},{name:"show_legend",path:["show","legend"],default:!1},{name:"animate",path:["animate"],default:!0},{name:"hide_unavailable",path:["hide_unavailable"],default:!1}],yt=[{name:"color_icon",path:["color_icon"],default:!1}],_t=[{name:"columns",path:["columns"]},{name:"sort",path:["sort"],default:"none"},{name:"limit",path:["limit"]},{name:"decimals",path:["decimals"]},{name:"unit",path:["unit"]}],vt=[{name:"tap_action",path:["tap_action"],default:{action:"more-info"}},{name:"hold_action",path:["hold_action"],default:{action:"none"}},{name:"double_tap_action",path:["double_tap_action"],default:{action:"none"}}],bt=[{name:"name",path:["name"]},{name:"unit",path:["unit"]},{name:"decimals",path:["decimals"]}],$t=[{name:"attribute",path:["attribute"]}],xt=[{name:"series_color",path:["color"]},{name:"curve",path:["curve"]},{name:"line_width",path:["line_width"]},{name:"dash",path:["dash"],default:!1},{name:"on_top",path:["on_top"],default:!1}],wt={number:{min:.5,max:12,step:.5,mode:"box",unit_of_measurement:"px"}},kt=new Map;function Mt(e){let t=kt.get(e);return t||(t=[...xt,{name:"dots",path:["dots"],default:e}],kt.set(e,t)),t}const At=vt.map(({name:e,path:t})=>({name:e,path:t})),Et=[{name:"title",selector:{text:{}}}],St=vt.map(({name:e})=>({name:e,selector:{ui_action:{}}})),Ct=[{type:"grid",name:"",schema:[{name:"name",selector:{text:{}}},{name:"unit",selector:{text:{}}},{name:"decimals",selector:{number:{min:0,max:20,mode:"box"}}}]}],Ot=new Map;let Lt;function zt(e){const t=nt(e);if(Lt?.language!==t){const n=t=>ot(e,t);Lt={language:t,schema:[{type:"grid",name:"",schema:[{name:"series_color",selector:{text:{}}},{name:"curve",selector:{select:{mode:"dropdown",options:["smooth","straight","step"].map(e=>({value:e,label:n(`editor_curve_${e}`)}))}}}]},{type:"grid",name:"",schema:[{name:"line_width",selector:wt},{name:"dash",selector:{boolean:{}}},{name:"dots",selector:{boolean:{}}},{name:"on_top",selector:{boolean:{}}}]}]}}return Lt.schema}function Tt(e){return[{type:"grid",name:"",schema:[{name:"columns",selector:{number:{min:1,max:6,mode:"box"}}},{name:"sort",selector:{select:{mode:"dropdown",options:["none","ascending","descending"].map(t=>({value:t,label:ot(e,`editor_sort_${t}`)}))}}},{name:"limit",selector:{number:{min:1,mode:"box"}}},{name:"decimals",selector:{number:{min:0,max:20,mode:"box"}}},{name:"unit",selector:{text:{}}}]}]}const Nt={include:["device_class","area","floor","label","domain"],exclude:["entity","device_class","area","floor","label","domain"]};function jt(e,t){const n=e=>({select:{multiple:!0,custom_value:!0,mode:"dropdown",options:e}});switch(e){case"device_class":return n(t.deviceClasses);case"domain":return n(t.domains);case"entity":return{entity:{multiple:!0}};default:return{[e]:{multiple:!0}}}}const Pt=new Map;function Ut(e,t){const n=function(e){const t=new Set,n=new Set;for(const o of Object.values(e?.states??{})){const e=o.attributes?.device_class;"string"==typeof e&&e&&t.add(e),n.add(o.entity_id.slice(0,o.entity_id.indexOf(".")))}return{deviceClasses:[...t].sort(),domains:[...n].sort()}}(t),o=`${n.deviceClasses.join()}|${n.domains.join()}`,a=Pt.get(e);if(a?.key===o)return a.schema;const i=Nt[e].map(t=>({name:`${e}_${t}`,selector:jt(t,n)}));return Pt.set(e,{key:o,schema:i}),i}let Ft;function Ht(e,t){let n=e;for(const o of t){if("object"!=typeof n||null===n)return;n=n[o]}return n}function Dt(e,t){if(e===t)return!0;if("object"!=typeof e||"object"!=typeof t||null===e||null===t)return!1;if(Array.isArray(e)!==Array.isArray(t))return!1;const n=Object.keys(e),o=Object.keys(t);return n.length===o.length&&n.every(n=>Dt(e[n],t[n]))}function Bt(e,t){const n={};for(const o of t){const t=Ht(e,o.path)??o.default;void 0!==t&&(n[o.name]=t)}return n}function Vt(e,t,n){const o=Bt(e,t);let a=e;for(const i of t){const e=n[i.name];Dt(o[i.name],e)||(a=st(a,i.path,void 0!==i.default&&Dt(e,i.default)?void 0:e))}return a}const Rt="var(--divider-color, rgba(127, 127, 127, 0.3))",It="var(--primary-color, #03a9f4)",Wt="var(--secondary-text-color, #727272)";function Zt(e,t,n,o){const a=(o-90)*Math.PI/180;return[(e+n*Math.cos(a)).toFixed(2),(t+n*Math.sin(a)).toFixed(2)]}function qt(e,t,n,o){return Zt(e,t,n,o).join(" ")}function Yt(e,t,n,o,a){const i=a-o>180?1:0;return`M ${qt(e,t,n,o)} A ${n} ${n} 0 ${i} 1 ${qt(e,t,n,a)}`}function Gt(e){return R`
- - ${r} + ${i} ${0!==a?R``:Z} -
`}const To=i` + `:W} + `}const Fo=r` .period-nav { display: flex; align-items: center; @@ -142,16 +142,16 @@ const Qe=me(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de border: 1px solid color-mix(in srgb, var(--primary-color) 40%, transparent); white-space: nowrap; } -`;function No(e){!function(e){_o.get(e)?.pause()}(e)}function jo(e,t){const{show:n}=t.config;return n.name||n.value?R`
+`;function Po(e){!function(e){xo.get(e)?.pause()}(e)}function Do(e,t){const{show:n}=t.config;return n.name||n.value?R`
${e.series.map(e=>R`
- ${n.name?R`${e.entity.name}`:Z} - ${n.value?R`${e.entity.display}`:Z} + ${n.name?R`${e.entity.name}`:W} + ${n.value?R`${e.entity.display}`:W}
`)} -
`:Z}function Po(e,t){return"loading"===e.status?R`
${ot(t.hass,"chart_loading")}
`:"error"===e.status?R`
- ${ot(t.hass,"chart_error",{error:e.error??""})} -
`:e.series.every(e=>e.points.every(e=>null===e.v))?R`
${ot(t.hass,"chart_no_data")}
`:void 0}const Uo=i` - ${To} +
`:W}function Ho(e,t){return"loading"===e.status?R`
${at(t.hass,"chart_loading")}
`:"error"===e.status?R`
+ ${at(t.hass,"chart_error",{error:e.error??""})} +
`:e.series.every(e=>e.points.every(e=>null===e.v))?R`
${at(t.hass,"chart_no_data")}
`:void 0}const Bo=r` + ${Fo} .series-header { display: flex; flex-wrap: wrap; @@ -239,50 +239,50 @@ const Qe=me(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de background: color-mix(in srgb, var(--card-background-color, #fff) 80%, transparent); white-space: nowrap; } -`;function Fo(e,t,n){const{locale:o,grouping:a}=function(e){const t=e?.locale?.language??"en";switch(e?.locale?.number_format){case"system":return{locale:void 0,grouping:!0};case"comma_decimal":return{locale:"en-US",grouping:!0};case"decimal_comma":return{locale:"de",grouping:!0};case"space_comma":return{locale:"fr",grouping:!0};case"none":return{locale:"en-US",grouping:!1};default:return{locale:t,grouping:!0}}}(n),i=new Intl.NumberFormat(o,{minimumFractionDigits:t,maximumFractionDigits:t,useGrouping:a});let r=i.format(0===e?0:e);return 0===Number(e.toFixed(t))&&(r=i.format(0)),r}function Ho(e,t){return t?`${e} ${t}`:e}const Do=new WeakMap;function Bo(e){Do.get(e)?.observer?.disconnect(),Do.delete(e)}function Vo(e){const t=sn(e.raw);return Math.max(0,function(e){let t=Do.get(e.host);if(!t){const n={width:e.host.clientWidth||400};t=n,Do.set(e.host,n),"undefined"!=typeof ResizeObserver&&(n.observer=new ResizeObserver(t=>{const o=Math.round(t[t.length-1].contentRect.width);Math.round(o/70)!==Math.round(n.width/70)&&(n.width=o,e.requestUpdate())}),n.observer.observe(e.host))}return t.width}(e)-32-(t.y&&"outside"===t.labels?40:0))}function Ro(e,t,n){const o=sn(e.raw),a=dn(n,o,4),i=Math.max(2,Math.min(8,Math.floor(Vo(e)/70))),{unit:r,ticks:s}=vn(t,i,gn(bn(e.hass)));return{options:o,value:a,times:s,unit:r,range:t,scales:{x:Jt([t.start,t.end],[0,ko]),y:Jt(a.domain,[Mo,0]),domain:a.domain}}}const Io=e=>Math.round(1e4*e)/100+"%";function Wo({axes:e,ctx:t,content:n,label:o,defs:a=Z,overlay:i=Z,hover:r,right:s}){const{options:l}=e,c="outside"===l.labels,d=l.y&&e.value.ticks.length>0,h=l.x&&e.times.length>0,u=d?function(e,t){const{scales:n,value:o}=e;return o.ticks.map(e=>{const a=n.y(e)/Mo;return R`.92?"bottom":""}" style="top:${Io(a)}" - >${Fo(e,o.decimals,t.hass)}`})}(e,t):[],p=d?R`
${u}
`:Z,m=h?R`
${function(e,t){const{scales:n,range:o}=e,a=gn(bn(t.hass));return e.times.map(i=>{const r=n.x(i)/ko;return R`.95?"end":""}" style="left:${Io(r)}" - >${e.label?e.label(i):An(i,e.unit,o.end-o.start,t.hass,a)}`})}(e,t)}
`:Z,f=d?R``:Z,g=s&&l.y&&s.value.ticks.length>0?s:void 0,y=g?R``:Z,_=g?R`
${c?y:Z}${function(e,t){return e.value.ticks.map(n=>{const o=e.y(n)/Mo;return R`.92?"bottom":""}" style="top:${Io(o)}" - >${Fo(n,e.value.decimals,t.hass)}`})}(g,t)}
`:Z;return R`
{const o=Math.round(t[t.length-1].contentRect.width);Math.round(o/70)!==Math.round(n.width/70)&&(n.width=o,e.requestUpdate())}),n.observer.observe(e.host))}return t.width}function Wo(e){Vo.get(e)?.observer?.disconnect(),Vo.delete(e)}function Zo(e){const t=ln(e.raw);return Math.max(0,qo(e)-32-(t.y&&"outside"===t.labels?40:0))}function Yo(e,t,n){const o=ln(e.raw),a=un(n,o,4),r=Math.max(2,Math.min(8,Math.floor(Zo(e)/70))),{unit:i,ticks:s}=xn(t,r,vn($n(e.hass)));return{options:o,value:a,times:s,unit:i,range:t,scales:{x:Kt([t.start,t.end],[0,Eo]),y:Kt(a.domain,[Co,0]),domain:a.domain}}}const Go=e=>Math.round(1e4*e)/100+"%";function Xo({axes:e,ctx:t,content:n,label:o,defs:a=W,overlay:r=W,hover:i,right:s,xLabel:l,yLabel:c}){const{options:d}=e,h="outside"===d.labels,u=d.y&&e.value.ticks.length>0,m=d.x&&e.times.length>0,p=u?function(e,t,n){const{scales:o,value:a}=e;return a.ticks.map(e=>{const r=o.y(e)/Co;return R`.92?"bottom":""}" style="top:${Go(r)}" + >${n?n(e):Io(e,a.decimals,t.hass)}`})}(e,t,c):[],f=u?R`
${p}
`:W,g=m?R`
${function(e,t,n){const{scales:o,range:a}=e,r=vn($n(t.hass));return e.times.map(i=>{const s=o.x(i)/Eo;return R`.95?"end":""}" style="left:${Go(s)}" + >${n?n(i):e.label?e.label(i):En(i,e.unit,a.end-a.start,t.hass,r)}`})}(e,t,l)}
`:W,y=u?R``:W,v=s&&d.y&&s.value.ticks.length>0?s:void 0,_=v?R``:W,b=v?R`
${h?_:W}${function(e,t){return e.value.ticks.map(n=>{const o=e.y(n)/Co;return R`.92?"bottom":""}" style="top:${Go(o)}" + >${Io(n,e.value.decimals,t.hass)}`})}(v,t)}
`:W;return R`
- ${c&&d?R`
${f}${u}
`:Z} + ${h&&u?R`
${y}${p}
`:W}
- ${function(e,t,n=Z,o=Z){return R` - + ${n} ${o} ${e} - `}(n,o,a,function(e){const{options:t,scales:n}=e;return I` - ${t.horizontal?e.value.ticks.map(e=>I``):Z} - ${t.vertical?e.times.map(e=>I``):Z} - `}(e))} ${c?Z:R`${p}${_}${m}`} - ${i} + `}(n,o,a,function(e){const{options:t,scales:n}=e;return V` + ${t.horizontal?e.value.ticks.map(e=>V``):W} + ${t.vertical?e.times.map(e=>V``):W} + `}(e))} ${h?W:R`${f}${b}${g}`} + ${r}
- ${c?R`${_}${m}`:Z} -
`}const Zo=i` + ${h?R`${b}${g}`:W} +
`}const Jo=r` .plot-wrap { display: grid; grid-template-columns: auto minmax(0, 1fr); @@ -392,9 +392,9 @@ const Qe=me(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de border-radius: 2px; background: color-mix(in srgb, var(--card-background-color, #fff) 70%, transparent); } -`,qo=["above","over","hidden"];function Yo(e){if(void 0!==e.compact&&"boolean"!=typeof e.compact)throw new Error('"compact" must be true or false');if(void 0!==e.header&&!qo.includes(e.header))throw new Error(`"header" must be one of: ${qo.join(", ")}`)}function Go(e){return{compact:!0===e.compact,header:e.header??"above"}}function Xo(e,t,n){return R`
- ${"above"===e.header?t:Z}${n} -
`}function Jo(e){return e===Z?Z:R`
e.stopPropagation()}>${e}
`}const Ko=i` +`,Ko=["above","over","hidden"];function Qo(e){if(void 0!==e.compact&&"boolean"!=typeof e.compact)throw new Error('"compact" must be true or false');if(void 0!==e.header&&!Ko.includes(e.header))throw new Error(`"header" must be one of: ${Ko.join(", ")}`)}function ea(e){return{compact:!0===e.compact,header:e.header??"above"}}function ta(e,t,n){return R`
+ ${"above"===e.header?t:W}${n} +
`}function na(e){return e===W?W:R`
e.stopPropagation()}>${e}
`}const oa=r` .header-over { position: absolute; top: 2px; @@ -447,26 +447,26 @@ const Qe=me(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de .frame.compact .axes-outside .x-axis .axis-label { top: 3px; } -`,Qo=["none","normal","percent"];function ea(e){const t=e.stack;if(void 0!==t&&!Qo.includes(t))throw new Error(`"stack" must be one of: ${Qo.join(", ")}`)}function ta(e,t){let n=0,o=e.length;for(;n>1;e[a].t<=t?n=a+1:o=a}return 0===n?null:e[n-1].v}function na(e,t){const n=e[0]?.length??0,o=e.map(()=>[]);for(let a=0;ae+Math.abs(t[a]??0),0);n=t>0?100/t:0}let i=0,r=0;e.forEach((e,t)=>{const s=e[a];if(null==s)return void o[t].push(null);const l=s*n;l>=0?(o[t].push([i,i+l]),i+=l):(o[t].push([r,r+l]),r+=l)})}return o}function oa(e){const t=e[0]?.length??0,n=[];for(let o=0;o>1;e[a].t0&&t-e[n-1].t<=e[n].t-t?n-1:n}const ra=new WeakMap;function sa(e){return ra.get(e)?.t}function la(e){const t=ra.get(e);void 0!==t?.timer&&clearTimeout(t.timer),ra.delete(e)}function ca(e){return!1!==e.tooltip}function da(e){const t=e.tooltip;if(void 0!==t&&"boolean"!=typeof t)throw new Error('"tooltip" must be true or false')}function ha(e,t,n){const o=function(e){let t=ra.get(e);return t||(t={},ra.set(e,t)),t}(e.host),a=t=>{t!==o.t&&(o.t=t,e.requestUpdate())},i=()=>{void 0!==o.timer&&clearTimeout(o.timer),o.timer=void 0};return{move(e){i();const o=e.currentTarget.getBoundingClientRect(),r=o.width>0?Math.min(1,Math.max(0,(e.clientX-o.left)/o.width)):0;a(function(e,t){let n;for(const o of e){const e=ia(o,t);if(e<0)continue;const a=o[e].t;(void 0===n||Math.abs(a-t){o.timer=void 0,a(void 0)},3e3):a(void 0)}}}const ua=e=>Math.round(1e4*e)/100+"%";function pa(e){const t=new Set(e.map(e=>e.entity.unit));return 1===t.size?[...t][0]:""}function ma(e,t,n,o){return R`
+`,aa=["none","normal","percent"];function ra(e){const t=e.stack;if(void 0!==t&&!aa.includes(t))throw new Error(`"stack" must be one of: ${aa.join(", ")}`)}function ia(e,t){let n=0,o=e.length;for(;n>1;e[a].t<=t?n=a+1:o=a}return 0===n?null:e[n-1].v}function sa(e,t){const n=e[0]?.length??0,o=e.map(()=>[]);for(let a=0;ae+Math.abs(t[a]??0),0);n=t>0?100/t:0}let r=0,i=0;e.forEach((e,t)=>{const s=e[a];if(null==s)return void o[t].push(null);const l=s*n;l>=0?(o[t].push([r,r+l]),r+=l):(o[t].push([i,i+l]),i+=l)})}return o}function la(e){const t=e[0]?.length??0,n=[];for(let o=0;o=190?"left":e>.5?"pin-left":"pin-right"}function ha(e,t){if(0===e.length)return-1;let n=0,o=e.length-1;for(;n>1;e[a].t0&&t-e[n-1].t<=e[n].t-t?n-1:n}const ua=new WeakMap;function ma(e){return ua.get(e)?.t}function pa(e){const t=ua.get(e);void 0!==t?.timer&&clearTimeout(t.timer),ua.delete(e)}function fa(e){return!1!==e.tooltip}function ga(e){const t=e.tooltip;if(void 0!==t&&"boolean"!=typeof t)throw new Error('"tooltip" must be true or false')}function ya(e,t,n){const o=function(e){let t=ua.get(e);return t||(t={},ua.set(e,t)),t}(e.host),a=t=>{t!==o.t&&(o.t=t,e.requestUpdate())},r=()=>{void 0!==o.timer&&clearTimeout(o.timer),o.timer=void 0};return{move(e){r();const o=e.currentTarget.getBoundingClientRect(),i=o.width>0?Math.min(1,Math.max(0,(e.clientX-o.left)/o.width)):0;a(function(e,t){let n;for(const o of e){const e=ha(o,t);if(e<0)continue;const a=o[e].t;(void 0===n||Math.abs(a-t){o.timer=void 0,a(void 0)},3e3):a(void 0)}}}const va=e=>Math.round(1e4*e)/100+"%";function _a(e){const t=new Set(e.map(e=>e.entity.unit));return 1===t.size?[...t][0]:""}function ba(e,t,n,o){return R`
- ${ot(e.hass,"chart_total")} - ${null===t?"—":Ho(Fo(t,o,e.hass),n)} -
`}function fa(e,t,n,o,a,i={}){if(void 0===o)return Z;const r=n.x(o)/ko;if(!(r>=0&&r<=1))return Z;const s=function(e,t){const n=e*t;return n+190<=t?"right":n>=190?"left":e>.5?"pin-left":"pin-right"}(r,a),l=t.map(e=>{const t=ia(e.points,o);return{s:e,p:t<0?void 0:e.points[t]}});return R``}function xa(e,t,n,o,a,r={}){if(void 0===o)return W;const i=n.x(o)/Eo;if(!(i>=0&&i<=1))return W;const s=da(i,a),l=t.map(e=>{const t=ha(e.points,o);return{s:e,p:t<0?void 0:e.points[t]}});return R``}nt("en",{chart_total:"Total"});const $a=r` .plot-area.hoverable { /* A finger dragging sideways scrubs the chart; up and down still scrolls the page. */ touch-action: pan-y; @@ -554,29 +554,29 @@ const Qe=me(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de .tooltip-total .swatch { background: none; } -`;function ya(e,t,n){return{select:{mode:"dropdown",options:n.map(n=>({value:n,label:ot(e,`editor_${t}_${n}`)}))}}}tt("en",{editor_period_calendar:"Period",editor_period_calendar_rolling:"The last…",editor_period_calendar_day:"Today",editor_period_calendar_week:"This week",editor_period_calendar_month:"This month",editor_period_calendar_year:"This year",editor_period_offset:"Periods back (0 is the current one)",editor_period_until:"Draw up to",editor_period_until_now:"Now",editor_period_until_end:"The end of the period",editor_period_navigate:"Show previous and next buttons",editor_group_by:"Group readings by",editor_group_by_none:"Don't group",editor_group_by_hour:"Hour",editor_group_by_day:"Day",editor_group_by_week:"Week",editor_group_by_month:"Month",editor_aggregate:"Combine with",editor_aggregate_mean:"Average",editor_aggregate_sum:"Sum",editor_aggregate_min:"Lowest",editor_aggregate_max:"Highest",editor_aggregate_first:"First",editor_aggregate_last:"Last",editor_aggregate_change:"Change (meters that count up)"});const _a=[{name:"period_calendar",path:["period","calendar"],default:"rolling"},{name:"period_last",path:["period","last"],default:24},{name:"period_unit",path:["period","unit"],default:"hour"},{name:"period_offset",path:["period","offset"],default:0},{name:"period_until",path:["period","until"],default:"now"},{name:"period_navigate",path:["period","navigate"],default:!1},{name:"source",path:["source"],default:"history"},{name:"group_by",path:["group_by"],default:"none"},{name:"aggregate",path:["aggregate"],default:"mean"}];function va(e){const t=e?.period;return`${t?.calendar?"calendar":"rolling"}${e?.group_by?"|grouped":""}`}function ba(e,t){const[n,o]=va(t).split("|"),a="calendar"===n;return[{type:"grid",name:"",schema:[{name:"period_calendar",selector:ya(e,"period_calendar",["rolling",...Vn])},...a?[{name:"period_until",selector:ya(e,"period_until",Rn)}]:[{name:"period_last",selector:{number:{min:1,mode:"box"}}},{name:"period_unit",selector:ya(e,"period_unit",Bn)}],{name:"period_offset",selector:{number:{min:0,mode:"box"}}}]},{name:"period_navigate",selector:{boolean:{}}},{name:"source",selector:ya(e,"source",Eo)},{type:"grid",name:"",schema:[{name:"group_by",selector:ya(e,"group_by",["none",...so])},...o?[{name:"aggregate",selector:ya(e,"aggregate",lo)}]:[]]}]}tt("en",{editor_x_axis:"Time labels",editor_y_axis:"Value labels",editor_axis_labels:"Axis labels",editor_axis_labels_outside:"Beside the chart",editor_axis_labels_inside:"Over the chart",editor_grid_horizontal:"Lines across",editor_grid_vertical:"Lines down",editor_y_min:"Lowest value shown",editor_y_max:"Highest value shown",editor_tooltip:"Show values on hover or touch",editor_line_width:"Line width (px)",editor_dots:"Dot at each reading",editor_on_top:"Draw above the other lines",editor_compact:"Compact",editor_header:"Names and values",editor_header_above:"Above the chart",editor_header_over:"Over the chart",editor_header_hidden:"Hidden"});const $a=[{name:"header",path:["header"],default:"above"},{name:"compact",path:["compact"],default:!1}];function xa(e){return[{type:"grid",name:"",schema:[{name:"header",selector:ya(e,"header",qo)},{name:"compact",selector:{boolean:{}}}]}]}const wa=[{name:"x_axis",path:["x_axis"],default:Qt},{name:"y_axis",path:["y_axis"],default:en},{name:"axis_labels",path:["axis_labels"],default:tn},{name:"grid_horizontal",path:["gridlines","horizontal"],default:nn},{name:"grid_vertical",path:["gridlines","vertical"],default:on},{name:"y_min",path:["y_min"]},{name:"y_max",path:["y_max"]},{name:"tooltip",path:["tooltip"],default:!0}];function ka(e){return[{type:"grid",name:"",schema:[{name:"x_axis",selector:{boolean:{}}},{name:"y_axis",selector:{boolean:{}}},{name:"grid_horizontal",selector:{boolean:{}}},{name:"grid_vertical",selector:{boolean:{}}}]},{name:"axis_labels",selector:ya(e,"axis_labels",Kt)},{type:"grid",name:"",schema:[{name:"y_min",selector:{number:{mode:"box",step:"any"}}},{name:"y_max",selector:{number:{mode:"box",step:"any"}}}]},{name:"tooltip",selector:{boolean:{}}}]}const Ma=new Map;function Aa(e,t,n){const o=`${e}|${nt(t)}`;let a=Ma.get(o);return a||(a=n(),Ma.set(o,a)),a}function Ea(e,t,n,o){return R`({value:n,label:at(e,`editor_${t}_${n}`)}))}}}nt("en",{editor_period_calendar:"Period",editor_period_calendar_rolling:"The last…",editor_period_calendar_day:"Today",editor_period_calendar_week:"This week",editor_period_calendar_month:"This month",editor_period_calendar_year:"This year",editor_period_offset:"Periods back (0 is the current one)",editor_period_until:"Draw up to",editor_period_until_now:"Now",editor_period_until_end:"The end of the period",editor_period_navigate:"Show previous and next buttons",editor_group_by:"Group readings by",editor_group_by_none:"Don't group",editor_group_by_hour:"Hour",editor_group_by_day:"Day",editor_group_by_week:"Week",editor_group_by_month:"Month",editor_aggregate:"Combine with",editor_aggregate_mean:"Average",editor_aggregate_sum:"Sum",editor_aggregate_min:"Lowest",editor_aggregate_max:"Highest",editor_aggregate_first:"First",editor_aggregate_last:"Last",editor_aggregate_change:"Change (meters that count up)"});const ka=[{name:"period_calendar",path:["period","calendar"],default:"rolling"},{name:"period_last",path:["period","last"],default:24},{name:"period_unit",path:["period","unit"],default:"hour"},{name:"period_offset",path:["period","offset"],default:0},{name:"period_until",path:["period","until"],default:"now"},{name:"period_navigate",path:["period","navigate"],default:!1},{name:"source",path:["source"],default:"history"},{name:"group_by",path:["group_by"],default:"none"},{name:"aggregate",path:["aggregate"],default:"mean"}];function Ma(e){const t=e?.period;return`${t?.calendar?"calendar":"rolling"}${e?.group_by?"|grouped":""}`}function Aa(e,t){const[n,o]=Ma(t).split("|"),a="calendar"===n;return[{type:"grid",name:"",schema:[{name:"period_calendar",selector:wa(e,"period_calendar",["rolling",...qn])},...a?[{name:"period_until",selector:wa(e,"period_until",Wn)}]:[{name:"period_last",selector:{number:{min:1,mode:"box"}}},{name:"period_unit",selector:wa(e,"period_unit",Vn)}],{name:"period_offset",selector:{number:{min:0,mode:"box"}}}]},{name:"period_navigate",selector:{boolean:{}}},{name:"source",selector:wa(e,"source",Lo)},{type:"grid",name:"",schema:[{name:"group_by",selector:wa(e,"group_by",["none",...ho])},...o?[{name:"aggregate",selector:wa(e,"aggregate",uo)}]:[]]}]}nt("en",{editor_x_axis:"Time labels",editor_y_axis:"Value labels",editor_axis_labels:"Axis labels",editor_axis_labels_outside:"Beside the chart",editor_axis_labels_inside:"Over the chart",editor_grid_horizontal:"Lines across",editor_grid_vertical:"Lines down",editor_y_min:"Lowest value shown",editor_y_max:"Highest value shown",editor_tooltip:"Show values on hover or touch",editor_line_width:"Line width (px)",editor_dots:"Dot at each reading",editor_on_top:"Draw above the other lines",editor_compact:"Compact",editor_header:"Names and values",editor_header_above:"Above the chart",editor_header_over:"Over the chart",editor_header_hidden:"Hidden"});const Sa=[{name:"header",path:["header"],default:"above"},{name:"compact",path:["compact"],default:!1}];function Ea(e){return[{type:"grid",name:"",schema:[{name:"header",selector:wa(e,"header",Ko)},{name:"compact",selector:{boolean:{}}}]}]}const Ca=[{name:"x_axis",path:["x_axis"],default:en},{name:"y_axis",path:["y_axis"],default:tn},{name:"axis_labels",path:["axis_labels"],default:nn},{name:"grid_horizontal",path:["gridlines","horizontal"],default:on},{name:"grid_vertical",path:["gridlines","vertical"],default:an},{name:"y_min",path:["y_min"]},{name:"y_max",path:["y_max"]},{name:"tooltip",path:["tooltip"],default:!0}];function za(e){return[{type:"grid",name:"",schema:[{name:"x_axis",selector:{boolean:{}}},{name:"y_axis",selector:{boolean:{}}},{name:"grid_horizontal",selector:{boolean:{}}},{name:"grid_vertical",selector:{boolean:{}}}]},{name:"axis_labels",selector:wa(e,"axis_labels",Qt)},{type:"grid",name:"",schema:[{name:"y_min",selector:{number:{mode:"box",step:"any"}}},{name:"y_max",selector:{number:{mode:"box",step:"any"}}}]},{name:"tooltip",selector:{boolean:{}}}]}const La=new Map;function Ta(e,t,n){const o=`${e}|${ot(t)}`;let a=La.get(o);return a||(a=n(),La.set(o,a)),a}function ja(e,t,n,o){return R`ot(e.hass,`editor_${t.name}`)} - @value-changed=${t=>{t.stopPropagation();const n=Vt(e.config,o,t.detail?.value??{});n!==e.config&&e.commit(n)}} - >`}const Sa=[..._a,{name:"curve",path:["curve"],default:"smooth"},{name:"color_by",path:["color_by"],default:"series"},{name:"labels",path:["labels"],default:!1},{name:"line_width",path:["line_width"],default:2.5},{name:"dots",path:["dots"],default:!1},...wa,...$a];function Ca(e,t){if(!e)return t;const[n,o]=e,a={};return void 0===t.y_min&&n>=0&&n<=o/2&&(a.y_min=0),void 0===t.y_max&&o<=0&&o>=n/2&&(a.y_max=0),Object.keys(a).length?{...t,...a}:t}function Oa(e){const t=[];return e.forEach((n,o)=>{o>0&&t.push([n[0],e[o-1][1]]),t.push(n)}),t}function La(e,t,n){if(e.length<2)return"";const[o,a,i]="step"===n?[Oa(e),Oa(t),"straight"]:[e,t,n];return`${xo(o,i)}${xo([...a].reverse(),i).replace(/^M/,"L")}Z`}function za(e,t){return"step"===t?xo(Oa(e),"straight"):xo(e,t)}function Ta(e,t,n,o,a={}){const i=lt(e.entity.config,o),r=e.entity.config.line_width??o.line_width,s=n.y(Math.min(Math.max(0,n.domain[0]),n.domain[1])),l=function(e){const t=e.entity.config.negative_color;return"string"==typeof t&&t.trim()?Oe(t.trim()):e.color}(e),c=`cd-area-${e.index}`,d=`cd-area-line-${e.index}`,h=l!==e.color,u=I`${function(e,t,n,o,a=.42,i=.05){const r=Math.min(1,Math.max(0,o/Mo));return I` + .computeLabel=${t=>at(e.hass,`editor_${t.name}`)} + @value-changed=${t=>{t.stopPropagation();const n=Rt(e.config,o,t.detail?.value??{});n!==e.config&&e.commit(n)}} + >`}const Oa=[...ka,{name:"curve",path:["curve"],default:"smooth"},{name:"color_by",path:["color_by"],default:"series"},{name:"labels",path:["labels"],default:!1},{name:"line_width",path:["line_width"],default:2.5},{name:"dots",path:["dots"],default:!1},...Ca,...Sa];function Na(e,t){if(!e)return t;const[n,o]=e,a={};return void 0===t.y_min&&n>=0&&n<=o/2&&(a.y_min=0),void 0===t.y_max&&o<=0&&o>=n/2&&(a.y_max=0),Object.keys(a).length?{...t,...a}:t}function Ua(e){const t=[];return e.forEach((n,o)=>{o>0&&t.push([n[0],e[o-1][1]]),t.push(n)}),t}function Fa(e,t,n){if(e.length<2)return"";const[o,a,r]="step"===n?[Ua(e),Ua(t),"straight"]:[e,t,n];return`${Ao(o,r)}${Ao([...a].reverse(),r).replace(/^M/,"L")}Z`}function Pa(e,t){return"step"===t?Ao(Ua(e),"straight"):Ao(e,t)}function Da(e,t,n,o,a={}){const r=ct(e.entity.config,o),i=e.entity.config.line_width??o.line_width,s=n.y(Math.min(Math.max(0,n.domain[0]),n.domain[1])),l=function(e){const t=e.entity.config.negative_color;return"string"==typeof t&&t.trim()?ze(t.trim()):e.color}(e),c=`cd-area-${e.index}`,d=`cd-area-line-${e.index}`,h=l!==e.color,u=V`${function(e,t,n,o,a=.42,r=.05){const i=Math.min(1,Math.max(0,o/Co));return V` - - + + `}(c,e.color,l,s,a.stacked?.6:.42,a.stacked?.35:.05)} - ${h?function(e,t,n,o){const a=Math.min(1,Math.max(0,o/Mo));return I` + ${h?function(e,t,n,o){const a=Math.min(1,Math.max(0,o/Co));return V` - `}(d,e.color,l,s):Z}`,p=[],m=[],f=[];if(a.layers){const o=a.layers;let i=[];const r=()=>{if(i.length){const a=i.map(e=>[n.x(t[e].t),n.y(o[e][1])]),r=i.map(e=>[n.x(t[e].t),n.y(o[e][0])]);p.push(La(a,r,e.curve)),m.push(za(a,e.curve))}i=[]};for(let e=0;e[n.x(e.t),n.y(e.v)]);p.push(La(t,t.map(([e])=>[e,s]),e.curve)),m.push(za(t,e.curve))}if(i.dots){const e=a.range;t.forEach((t,o)=>{if(null===t.v||e&&(t.t===e.start||t.t===e.end))return;const i=a.layers?a.layers[o]?.[1]:t.v;void 0!==i&&f.push([n.x(t.t),n.y(i)])})}const g=r??2,y=h?`url(#${d})`:e.color;return{defs:u,body:I` - - `}(d,e.color,l,s):W}`,m=[],p=[],f=[];if(a.layers){const o=a.layers;let r=[];const i=()=>{if(r.length){const a=r.map(e=>[n.x(t[e].t),n.y(o[e][1])]),i=r.map(e=>[n.x(t[e].t),n.y(o[e][0])]);m.push(Fa(a,i,e.curve)),p.push(Pa(a,e.curve))}r=[]};for(let e=0;e[n.x(e.t),n.y(e.v)]);m.push(Fa(t,t.map(([e])=>[e,s]),e.curve)),p.push(Pa(t,e.curve))}if(r.dots){const e=a.range;t.forEach((t,o)=>{if(null===t.v||e&&(t.t===e.start||t.t===e.end))return;const r=a.layers?a.layers[o]?.[1]:t.v;void 0!==r&&f.push([n.x(t.t),n.y(r)])})}const g=i??2,y=h?`url(#${d})`:e.color;return{defs:u,body:V` + + - ${f.length>0&&f.length<=200?I``:Z} - `}}tt("en",{editor_style_area:"Area",editor_stack:"Stack",editor_stack_none:"Don't stack",editor_stack_normal:"On top of each other",editor_stack_percent:"To 100 %",editor_negative_color:"Colour below zero"});const Na=[..._a,{name:"curve",path:["curve"],default:"smooth"},{name:"stack",path:["stack"],default:"none"},{name:"line_width",path:["line_width"],default:2},{name:"dots",path:["dots"],default:!1},...wa,...$a];const ja={name:"negative_color",path:["negative_color"]},Pa=new Map;const Ua=i` + ${f.length>0&&f.length<=200?V``:W} + `}}nt("en",{editor_style_area:"Area",editor_stack:"Stack",editor_stack_none:"Don't stack",editor_stack_normal:"On top of each other",editor_stack_percent:"To 100 %",editor_negative_color:"Colour below zero"});const Ha=[...ka,{name:"curve",path:["curve"],default:"smooth"},{name:"stack",path:["stack"],default:"none"},{name:"line_width",path:["line_width"],default:2},{name:"dots",path:["dots"],default:!1},...Ca,...Sa];const Ba={name:"negative_color",path:["negative_color"]},Ia=new Map;const Ra=r` .plot .fill { vector-effect: none; } @@ -591,13 +591,13 @@ const Qe=me(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de .plot .series .line.dashed { stroke-dasharray: 6 5; } -`,Fa={layout:"chart",group:"history",height:240,styles:i` - ${Uo} - ${Zo} - ${ga} - ${Ko} - ${Ua} - `,thumbnail:()=>I` +`,Va={layout:"chart",group:"history",height:240,styles:r` + ${Bo} + ${Jo} + ${$a} + ${oa} + ${Ra} + `,thumbnail:()=>V` @@ -606,8 +606,8 @@ const Qe=me(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de `,validate(e){So(e),rn(e),da(e),mt(e),Yo(e),ea(e),function(e){for(const t of e.entities??[]){if("object"!=typeof t)continue;const e=t.negative_color;if(void 0!==e&&"string"!=typeof e)throw new Error(`"negative_color" of ${t.entity} must be a colour`)}}(e)},editor:function(e){const t=Aa(`area|${va(e.config)}`,e.hass,()=>[...ba(e.hass,e.config),{type:"grid",name:"",schema:[{name:"curve",selector:ya(e.hass,"curve",vo)},{name:"stack",selector:ya(e.hass,"stack",Qo)}]},{type:"grid",name:"",schema:[{name:"line_width",selector:wt},{name:"dots",selector:{boolean:{}}}]},...ka(e.hass),...xa(e.hass)]);return Ea(e,"chart",t,Na)},entityFields:function(e,t){const n=!0===t.dots;let o=Pa.get(n);return o||(o=[...Mt(n),ja],Pa.set(n,o)),{schema:Aa("area-entity",e,()=>[...zt(e),{name:"negative_color",selector:{text:{}}}]),fields:o}},disconnect(e){No(e),Bo(e),la(e)},render:function(e){const t=Co(e),n=zo(t,e),o=jo(t,e),a=n===Z&&o===Z?Z:R`${n}${o}`,i=Po(t,e),r=Go(e.raw);if(i)return Xo("over"===r.header?{...r,header:"above"}:r,a,i);const s=e.raw.stack??"none",l=t.series.map(e=>({...e,points:En(e.points,500)}));let c,d,h=l;const u={};if("none"===s){const n=Xt(l.map(e=>e.points));d=Ro({...e,raw:Ca(n,e.raw)},t.range,n);const o=d.scales,a=ct(l,t=>lt(t.entity.config,e.raw).onTop);c=a.map(n=>Ta(n,n.points,o,e.raw,{range:t.range}))}else{const n=function(e){const t=[...new Set(e.flatMap(e=>e.map(e=>e.t)))].sort((e,t)=>e-t);return{times:t,values:e.map(e=>t.map(t=>ta(e,t)))}}(l.map(e=>e.points)),o=na(n.values,s),a=oa(n.values);h=l.map((e,t)=>({...e,points:n.times.map((e,o)=>({t:e,v:n.values[t][o]}))}));const i=aa(o),r="percent"===s?{y_min:i[0]<0?-100:0,y_max:i[1]>0?100:0}:{};d=Ro({...e,raw:Ca(i,{...r,...e.raw})},t.range,i);const p=d.scales;c=h.map((n,a)=>Ta(n,n.points,p,e.raw,{layers:o[a],stacked:!0,range:t.range})).reverse();const m=new Map;h.forEach(e=>e.points.forEach((e,t)=>m.set(e,t))),u.dotY=(e,t)=>{const n=o[e.index][m.get(t)??-1];return n?p.y(n[1]):null};const f=pa(h),g=Math.max(...h.map(e=>e.entity.decimals));u.footer=t=>{const n=t.length?m.get(t[0].p):void 0;return ma(e,void 0===n?null:a[n],f,g)},"percent"===s&&(u.text=(t,n)=>{const a=n?o[t.index][m.get(n)??-1]:null;if(!n||null===n.v||!a)return"—";const i=Math.round(Math.abs(a[1]-a[0]));return`${Ho(Fo(n.v,t.entity.decimals,e.hass),t.entity.unit)} · ${i} %`})}const p=ca(e.raw),m=ot(e.hass,"chart_aria",{style:ot(e.hass,"editor_style_area"),names:t.series.map(e=>e.entity.name).join(", ")});return Xo(r,a,Wo({axes:d,ctx:e,content:I`${c.map(e=>e.body)}`,defs:I`${c.map(e=>e.defs)}`,label:m,overlay:R`${"over"===r.header?Jo(a):Z} - ${p?fa(e,h,d.scales,sa(e.host),Vo(e),u):Z}`,hover:p?ha(e,t.range,h.map(e=>e.points)):void 0}))}};function Ha(e,t,n){return!Number.isFinite(e)||!Number.isFinite(t)||!Number.isFinite(n)||n<=t?0:Math.min(1,Math.max(0,(e-t)/(n-t)))}function Da(e,t,n){if(!Number.isFinite(e)||!Number.isFinite(t)||!Number.isFinite(n)||n<=t)return[0,0];const o=Ha(e,t,n);if(t<0&&n>0){const e=Ha(0,t,n);return o"number"==typeof e.from))return e;const n=[];for(const o of e){const e=t?Wa(o.from,t,"band start"):null;null!==e&&n.push({...o,from:e})}return n.length?n.sort((e,t)=>e.from-t.from):Ie}function qa(e,t){const n=e.entity,o=t.states[n],a=e.name??o?.attributes?.friendly_name??n,i=e.unit??(e.attribute?"":o?.attributes?.unit_of_measurement??""),r=function(e,t){if(t)return e.attribute?t.attributes?.[e.attribute]:t.state}(e,o),s=function(e){return"number"==typeof e?Number.isFinite(e)?e:null:"string"==typeof e?Ia(e):null}(r),l=null!==s;let c;c=Ra(e.decimals)?e.attribute?l?function(e){const t=String(e),n=t.indexOf(".");return-1===n||/e/i.test(t)?0:Math.min(2,t.length-n-1)}(r):0:function(e,t,n){const o=e?.entities?.[t]?.display_precision;return"number"==typeof o?o:"%"===n?0:1}(t,n,i):e.decimals;const d=Wa(e.min,t)??0,h=Wa(e.max,t)??100,u=Wa(e.target,t),p=Za(e.severity,t);let m,f="";return l?(f=Fo(s,c,t),m=Ho(f,i)):m=o?function(e,t,n,o){const a=e.attribute;return a&&"string"==typeof n&&""!==n.trim()?o.formatEntityAttributeValue?.(t,a)??n:o.formatEntityState?.(t)??ot(o,"unavailable")}(e,o,r,t):ot(t,"entity_not_found"),{entityId:n,config:e,stateObj:o,available:l,value:s,min:d,max:h,target:u,name:a,unit:i,decimals:c,position:l?Ha(s,d,h):0,span:l?Da(s,d,h):[0,0],targetPosition:null===u?null:Ha(u,d,h),band:Te(l?s:-1/0,p),bands:p,valueText:f,display:m,label:`${a}, ${m}`}}function Ya(e,t){if(!e.include)return[];const n=new Set(e.entities.map(e=>e.entity));return function(e,t,n){const o=Ve.get(e);if(o&&o.exclude===t&&o.states===n.states&&o.entities===n.entities&&o.devices===n.devices&&o.areas===n.areas)return o.ids;const a=[];for(const r of Object.values(n.states)){if(!Ue(r)||n.entities?.[r.entity_id]?.hidden)continue;const o=Fe(r,n);if(!De(e,o)||Be(t,o))continue;const i=r.attributes?.friendly_name;a.push({id:r.entity_id,name:"string"==typeof i&&i?i:r.entity_id})}a.sort((e,t)=>e.name.localeCompare(t.name)||(e.ide.id);return Ve.set(e,{exclude:t,states:n.states,entities:n.entities,devices:n.devices,areas:n.areas,ids:i}),i}(e.include,e.exclude,t).filter(e=>!n.has(e))}const Ga={layout:"rows",group:"bars",height:46,minColumnWidth:160,styles:i` + stroke="var(--primary-color, #03a9f4)" stroke-width="2.5" stroke-linecap="round" />`,validate(e){To(e),sn(e),ga(e),ft(e),Qo(e),ra(e),function(e){for(const t of e.entities??[]){if("object"!=typeof t)continue;const e=t.negative_color;if(void 0!==e&&"string"!=typeof e)throw new Error(`"negative_color" of ${t.entity} must be a colour`)}}(e)},editor:function(e){const t=Ta(`area|${Ma(e.config)}`,e.hass,()=>[...Aa(e.hass,e.config),{type:"grid",name:"",schema:[{name:"curve",selector:wa(e.hass,"curve",wo)},{name:"stack",selector:wa(e.hass,"stack",aa)}]},{type:"grid",name:"",schema:[{name:"line_width",selector:kt},{name:"dots",selector:{boolean:{}}}]},...za(e.hass),...Ea(e.hass)]);return ja(e,"chart",t,Ha)},entityFields:function(e,t){const n=!0===t.dots;let o=Ia.get(n);return o||(o=[...At(n),Ba],Ia.set(n,o)),{schema:Ta("area-entity",e,()=>[...jt(e),{name:"negative_color",selector:{text:{}}}]),fields:o}},disconnect(e){Po(e),Wo(e),pa(e)},render:function(e){const t=jo(e),n=Uo(t,e),o=Do(t,e),a=n===W&&o===W?W:R`${n}${o}`,r=Ho(t,e),i=ea(e.raw);if(r)return ta("over"===i.header?{...i,header:"above"}:i,a,r);const s=e.raw.stack??"none",l=t.series.map(e=>({...e,points:Cn(e.points,500)}));let c,d,h=l;const u={};if("none"===s){const n=Jt(l.map(e=>e.points));d=Yo({...e,raw:Na(n,e.raw)},t.range,n);const o=d.scales,a=dt(l,t=>ct(t.entity.config,e.raw).onTop);c=a.map(n=>Da(n,n.points,o,e.raw,{range:t.range}))}else{const n=function(e){const t=[...new Set(e.flatMap(e=>e.map(e=>e.t)))].sort((e,t)=>e-t);return{times:t,values:e.map(e=>t.map(t=>ia(e,t)))}}(l.map(e=>e.points)),o=sa(n.values,s),a=la(n.values);h=l.map((e,t)=>({...e,points:n.times.map((e,o)=>({t:e,v:n.values[t][o]}))}));const r=ca(o),i="percent"===s?{y_min:r[0]<0?-100:0,y_max:r[1]>0?100:0}:{};d=Yo({...e,raw:Na(r,{...i,...e.raw})},t.range,r);const m=d.scales;c=h.map((n,a)=>Da(n,n.points,m,e.raw,{layers:o[a],stacked:!0,range:t.range})).reverse();const p=new Map;h.forEach(e=>e.points.forEach((e,t)=>p.set(e,t))),u.dotY=(e,t)=>{const n=o[e.index][p.get(t)??-1];return n?m.y(n[1]):null};const f=_a(h),g=Math.max(...h.map(e=>e.entity.decimals));u.footer=t=>{const n=t.length?p.get(t[0].p):void 0;return ba(e,void 0===n?null:a[n],f,g)},"percent"===s&&(u.text=(t,n)=>{const a=n?o[t.index][p.get(n)??-1]:null;if(!n||null===n.v||!a)return"—";const r=Math.round(Math.abs(a[1]-a[0]));return`${Ro(Io(n.v,t.entity.decimals,e.hass),t.entity.unit)} · ${r} %`})}const m=fa(e.raw),p=at(e.hass,"chart_aria",{style:at(e.hass,"editor_style_area"),names:t.series.map(e=>e.entity.name).join(", ")});return ta(i,a,Xo({axes:d,ctx:e,content:V`${c.map(e=>e.body)}`,defs:V`${c.map(e=>e.defs)}`,label:p,overlay:R`${"over"===i.header?na(a):W} + ${m?xa(e,h,d.scales,ma(e.host),Zo(e),u):W}`,hover:m?ya(e,t.range,h.map(e=>e.points)):void 0}))}};function qa(e,t,n){return!Number.isFinite(e)||!Number.isFinite(t)||!Number.isFinite(n)||n<=t?0:Math.min(1,Math.max(0,(e-t)/(n-t)))}function Wa(e,t,n){if(!Number.isFinite(e)||!Number.isFinite(t)||!Number.isFinite(n)||n<=t)return[0,0];const o=qa(e,t,n);if(t<0&&n>0){const e=qa(0,t,n);return o"number"==typeof e.from))return e;const n=[];for(const o of e){const e=t?Ja(o.from,t,"band start"):null;null!==e&&n.push({...o,from:e})}return n.length?n.sort((e,t)=>e.from-t.from):qe}function Qa(e,t){const n=e.entity,o=t.states[n],a=e.name??o?.attributes?.friendly_name??n,r=e.unit??(e.attribute?"":o?.attributes?.unit_of_measurement??""),i=function(e,t){if(t)return e.attribute?t.attributes?.[e.attribute]:t.state}(e,o),s=function(e){return"number"==typeof e?Number.isFinite(e)?e:null:"string"==typeof e?Xa(e):null}(i),l=null!==s;let c;c=Ga(e.decimals)?e.attribute?l?function(e){const t=String(e),n=t.indexOf(".");return-1===n||/e/i.test(t)?0:Math.min(2,t.length-n-1)}(i):0:function(e,t,n){const o=e?.entities?.[t]?.display_precision;return"number"==typeof o?o:"%"===n?0:1}(t,n,r):e.decimals;const d=Ja(e.min,t)??0,h=Ja(e.max,t)??100,u=Ja(e.target,t),m=Ka(e.severity,t);let p,f="";return l?(f=Io(s,c,t),p=Ro(f,r)):p=o?function(e,t,n,o){const a=e.attribute;return a&&"string"==typeof n&&""!==n.trim()?o.formatEntityAttributeValue?.(t,a)??n:o.formatEntityState?.(t)??at(o,"unavailable")}(e,o,i,t):at(t,"entity_not_found"),{entityId:n,config:e,stateObj:o,available:l,value:s,min:d,max:h,target:u,name:a,unit:r,decimals:c,position:l?qa(s,d,h):0,span:l?Wa(s,d,h):[0,0],targetPosition:null===u?null:qa(u,d,h),band:je(l?s:-1/0,m),bands:m,valueText:f,display:p,label:`${a}, ${p}`}}function er(e,t){if(!e.include)return[];const n=new Set(e.entities.map(e=>e.entity));return function(e,t,n){const o=Re.get(e);if(o&&o.exclude===t&&o.states===n.states&&o.entities===n.entities&&o.devices===n.devices&&o.areas===n.areas)return o.ids;const a=[];for(const i of Object.values(n.states)){if(!Pe(i)||n.entities?.[i.entity_id]?.hidden)continue;const o=De(i,n);if(!Be(e,o)||Ie(t,o))continue;const r=i.attributes?.friendly_name;a.push({id:i.entity_id,name:"string"==typeof r&&r?r:i.entity_id})}a.sort((e,t)=>e.name.localeCompare(t.name)||(e.ide.id);return Re.set(e,{exclude:t,states:n.states,entities:n.entities,devices:n.devices,areas:n.areas,ids:r}),r}(e.include,e.exclude,t).filter(e=>!n.has(e))}const tr={layout:"rows",group:"bars",height:46,minColumnWidth:160,styles:r` .style-bar .entity { display: flex; align-items: center; @@ -689,31 +689,180 @@ const Qe=me(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de white-space: nowrap; margin-left: auto; } - `,render(e,{show:t,hass:n}){const o=t.value||!e.available,a=R`${t.name?R`${e.name}`:Z}${o?R`${e.display}`:Z}`;return R` + `,render(e,{show:t,hass:n}){const o=t.value||!e.available,a=R`${t.name?R`${e.name}`:W}${o?R`${e.display}`:W}`;return R` ${t.icon?R``:Z} + .icon=${Ya(e)} + >`:W}
- ${t.target&&null!==e.targetPosition&&e.available?R`
`:Z} + ${t.target&&null!==e.targetPosition&&e.available?R`
`:W}
${a}
- ${e.available?R``:Z} + ${e.available?R``:W}
- `}},Xa=["auto","none","hour","day","week","month"],Ja=["auto","mean","sum","min","max","first","last","change"],Ka={last:7,unit:"day"},Qa={hour:1/24,day:1,week:7,month:30};function ei(e){if(e.calendar)return{day:"hour",week:"day",month:"day",year:"month"}[e.calendar];const t=e.last*Qa[e.unit];return t<=2?"hour":t<=62?"day":t<=190?"week":"month"}function ti(e){if(e.config.attribute)return!1;const t=e.stateObj?.attributes?.state_class;return"total_increasing"===t||"total"===t}function ni(e,t){const n={...e,period:e.period??{...Ka}};return"none"===e.group_by?delete n.group_by:void 0!==e.group_by&&"auto"!==e.group_by||(n.group_by=ei(Yn(n.period))),void 0!==e.aggregate&&"auto"!==e.aggregate||(n.aggregate=!0!==e.range&&t.length>0&&t.every(ti)?"change":"mean"),n}function oi(e,t){const n=[...new Set(e.flatMap(e=>e.map(e=>e.t)))].sort((e,t)=>e-t),o=e.map(e=>new Map(e.map(e=>[e.t,e]))),a=new Map;for(const r of e)for(const e of r)void 0!==e.end&&a.set(e.t,e.end);const i={times:[],ends:[],values:e.map(()=>[]),lows:e.map(()=>[]),highs:e.map(()=>[])};return n.forEach((r,s)=>{const l=Math.min(a.get(r)??n[s+1]??t,t);l>r&&(i.times.push(r),i.ends.push(l),e.forEach((e,t)=>{const n=o[t].get(r);i.values[t].push(n?n.v:ta(e,r)),i.lows[t].push(n?.min??null),i.highs[t].push(n?.max??null)}))}),i}function ai(e,t,n,o){const a=t-e,i=.8*a/o,r=o>1?.12*i:0;return[e+.2*a/2+n*i+r/2,i-r]}function ii(e,t=!0){let n=t?0:1/0,o=t?0:-1/0;for(const a of e)null!==a&&Number.isFinite(a)&&(n=Math.min(n,a),o=Math.max(o,a));return n===1/0?void 0:[n,o]}const ri={hour:66,day:52,week:52,month:60};tt("en",{editor_style_column:"Column",editor_group_by_auto:"Automatic",editor_aggregate_auto:"Automatic (change for meters, else average)",editor_range:"Lowest to highest in each column",editor_rounded:"Rounded ends",editor_column_labels:"Show values on the columns",editor_column_color:"Colour"});const si={hour:"hour",day:"day",week:"day",month:"month"};function li(e,t,n,o){if("hour"===n)return`${Mn(e,o)} – ${kn(t,o)}`;const a="day"===n?{weekday:"short",day:"numeric",month:"short"}:"week"===n?{day:"numeric",month:"short"}:{month:"long",year:"numeric"};let i;try{i=new Intl.DateTimeFormat(o?.locale?.language??"en",{...a,timeZone:bn(o)})}catch{i=new Intl.DateTimeFormat("en",a)}return"week"===n?i.formatRange(e,t-1):i.format(e)}const ci=(e,t)=>Math.round(e/t*1e4)/100+"%",di=e=>Math.min(Mo,Math.max(0,e));function hi(e,t,n,o,a,i,r,s){const l=di(e.y(a)),c=di(e.y(o));return R`
e+t.w,0);let o,a=0;for(const r of e){const e=(a+r.w/2)/n;if(a+=r.w,t<=e)return o?o.v+(r.v-o.v)*(t-o.at)/(e-o.at):r.v;o={at:e,v:r.v}}return e[e.length-1].v}const or=["tukey","range"];function ar(e,t){if(e.length<=t)return[...e];const n=[];for(let o=0;o"number"==typeof e&&Number.isFinite(e);function ir(e){for(const t of["x_min","x_max"])if(void 0!==e[t]&&!rr(e[t]))throw new Error(`"${t}" must be a number`);if(rr(e.x_min)&&rr(e.x_max)&&e.x_min>=e.x_max)throw new Error('"x_min" must be below "x_max"')}nt("en",{editor_x_min:"Lowest value across",editor_x_max:"Highest value across"});const sr=[{name:"x_min",path:["x_min"]},{name:"x_max",path:["x_max"]}],lr=[{type:"grid",name:"",schema:[{name:"x_min",selector:{number:{mode:"box",step:"any"}}},{name:"x_max",selector:{number:{mode:"box",step:"any"}}}]}];function cr(e){return{min:rr(e.x_min)?e.x_min:void 0,max:rr(e.x_max)?e.x_max:void 0}}function dr(e,t,n,o={}){const a=ln(e.raw),r=Math.max(2,Math.min(8,Math.round(qo(e)/70))),i=un(t,o.x??{},r),s=un(n,{min:a.min??o.yFloor,max:a.max},4);return{options:a,value:s,times:i.ticks,unit:"minute",range:{start:i.domain[0],end:i.domain[1]},scales:{x:Kt(i.domain,[0,Eo]),y:Kt(s.domain,[Co,0]),domain:s.domain}}}function hr(e){const[t,n]=e.times;return void 0===t||void 0===n?0:Math.max(0,-Math.floor(Math.log10(Math.abs(n-t))+1e-9))}const ur=new WeakMap;function mr(e){return ur.get(e)?.i}function pr(e){const t=ur.get(e);void 0!==t?.timer&&clearTimeout(t.timer),ur.delete(e)}function fr(e,t){const n=function(e){let t=ur.get(e);return t||(t={},ur.set(e,t)),t}(e.host),o=t=>{t!==n.i&&(n.i=t,e.requestUpdate())},a=()=>{void 0!==n.timer&&clearTimeout(n.timer),n.timer=void 0};return{move(e){a();const n=e.currentTarget.getBoundingClientRect(),r=n.width>0?Math.min(1,Math.max(0,(e.clientX-n.left)/n.width)):0,i=n.height>0?Math.min(1,Math.max(0,(e.clientY-n.top)/n.height)):0;o(t(r,i,n.width,n.height))},leave(e){a(),"touch"===e.pointerType||"pen"===e.pointerType?n.timer=setTimeout(()=>{n.timer=void 0,o(void 0)},3e3):o(void 0)}}}const gr=e=>Math.round(1e4*e)/100+"%";function yr(e){const{x:t,y:n,plotWidth:o,title:a,rows:r,dot:i,band:s}=e;if(!(t>=0&&t<=1))return W;const l=da(t,o);return R``}const vr=r` + .pick-band { + position: absolute; + top: 0; + bottom: 0; + background: color-mix(in srgb, var(--primary-text-color) 8%, transparent); + } +`,_r=["vertical","horizontal"],br=.72;function xr(e,t){const n=e.bounds.length>1,o=n?e.bounds.slice(0,-1).map((t,n)=>[t,e.bounds[n+1]]):[[e.range.start,e.range.end]],a=[];return o.forEach(([o,r],i)=>{const s=Math.min(r,e.until);e.series.forEach((r,l)=>{let c,d;"statistics"===e.source?({samples:c,extremes:d}=function(e,t,n){const o=[];let a=1/0,r=-1/0;for(const i of e)i.t=n||i.carried||null===i.v||(o.push({v:i.v,w:1}),a=Math.min(a,i.v,"number"==typeof i.min?i.min:i.v),r=Math.max(r,i.v,"number"==typeof i.max?i.max:i.v));return{samples:o,extremes:o.length?[a,r]:void 0}}(r.points,o,s)):c=function(e,t,n){const o=[];if(n<=t)return o;for(let a=0;a=n)break;const i=a+1e.w>0&&Number.isFinite(e.v)).sort((e,t)=>e.v-t.v);if(0===o.length)return;const a=nr(o,.25),r=nr(o,.5),i=nr(o,.75),s=o.reduce((e,t)=>e+t.w,0),l=o.reduce((e,t)=>e+t.v*t.w,0)/s,c=Math.min(o[0].v,n?.[0]??1/0),d=Math.max(o[o.length-1].v,n?.[1]??-1/0),h={count:o.length,q1:a,median:r,q3:i,mean:l,min:c,max:d};if("range"===t)return{...h,low:c,high:d,outliers:[]};const u=1.5*(i-a),m=o.filter(e=>e.v>=a-u&&e.v<=i+u),p=Math.min(m[0]?.v??a,a),f=Math.max(m[m.length-1]?.v??i,i);let g=[...new Set(o.filter(e=>e.vf).map(e=>e.v))];return g.length>60&&(g=g.map(e=>({v:e,d:et.d-e.d).slice(0,60).map(e=>e.v).sort((e,t)=>e-t)),{...h,low:p,high:f,outliers:g}}(c,t,d);h&&a.push({category:n?i:l,slot:n?l:0,series:r,stats:h,readings:ar(c.map(e=>e.v),120)})})}),{boxes:a,categories:n?o.length:e.series.length,slots:n?e.series.length:1}}function $r(e,t){const n=Math.max(1,Math.ceil(e/Math.max(1,t))),o=[];for(let a=0;aV``,[c,d]=n(i-s,a.q1),[h,u]=n(i+s,a.q3),m=o&&e.readings.length?V`n(i+function(e){const t=43758.5453*Math.sin(12.9898*(e+1));return 2*(t-Math.floor(t))-1}(t)*s*.8,e)))}>`:W;return V` + ${l(n(i,a.low),n(i,a.q1),"whisker")} + ${l(n(i,a.q3),n(i,a.high),"whisker")} + ${l(n(i-s/2,a.low),n(i+s/2,a.low),"cap")} + ${l(n(i-s/2,a.high),n(i+s/2,a.high),"cap")} + + ${m} + ${l(n(i-s,a.median),n(i+s,a.median),"median")} + ${a.outliers.length?V`n(i,e)))}>`:W} + `}const Ar=[...ka,{name:"orientation",path:["orientation"],default:"vertical"},{name:"whiskers",path:["whiskers"],default:"tukey"},{name:"readings",path:["readings"],default:!1},...Ca,...Sa];const Sr=[{name:"box_color",selector:{text:{}}}],Er=[{name:"box_color",path:["color"]}],Cr={layout:"chart",group:"distribution",height:240,styles:r` + ${Bo} + ${Jo} + ${$a} + ${oa} + ${vr} + .style-box .box line { + stroke: var(--box-color); + stroke-width: 1.5; + } + .style-box .box .median { + stroke-width: 3; + } + .style-box .box .body { + fill: color-mix(in srgb, var(--box-color) 22%, transparent); + stroke: var(--box-color); + stroke-width: 1.5; + vector-effect: non-scaling-stroke; + } + .style-box .box .outliers { + fill: none; + stroke: var(--box-color); + stroke-width: 6px; + stroke-linecap: round; + } + .style-box .box .readings { + fill: none; + stroke: var(--box-color); + stroke-width: 4px; + stroke-linecap: round; + opacity: 0.45; + } + `,thumbnail:()=>V` + + + + + + + + + `,validate:function(e){To(e),sn(e),ga(e),Qo(e);const t=e;if(void 0!==t.orientation&&!_r.includes(t.orientation))throw new Error(`"orientation" must be one of: ${_r.join(", ")}`);if(void 0!==t.whiskers&&!or.includes(t.whiskers))throw new Error(`"whiskers" must be one of: ${or.join(", ")}`);if(void 0!==t.readings&&"boolean"!=typeof t.readings)throw new Error('"readings" must be true or false')},editor:function(e){const t={...e.config,group_by:void 0},n=Ta(`box|${Ma(t)}`,e.hass,()=>[...Aa(e.hass,t),{type:"grid",name:"",schema:[{name:"orientation",selector:wa(e.hass,"orientation",_r)},{name:"whiskers",selector:wa(e.hass,"whiskers",or)}]},{name:"readings",selector:{boolean:{}}},...za(e.hass),...Ea(e.hass)]);return ja(e,"chart",n,Ar)},entityFields:()=>({schema:Sr,fields:Er}),disconnect(e){Po(e),Wo(e),pr(e)},render(e){const t=function(e,t=Date.now()){const n=Jn(e.raw.period),o=e.raw.source??"history",a=Rn(e.hass),r=Qn(n,t,a,n.navigate?to(e.host,n):0),i=mo(e.raw),s={start:i?Dn(i.unit,r.start,a):r.start,end:r.end},l=e.items.map(e=>({entity:e.entityId,attribute:e.config.attribute})),c="statistics"===o?{period:i?"hour"===i.unit?"5minute":"hour":so(s),types:["mean","min","max"],value:"mean"}:void 0,d=$o(e.host,e.requestUpdate).get(e.hass,l,s,o,{live:r.live,statistics:c}),h=e.items.map((e,t)=>{const n=e.config;return{index:t,entity:e,points:d.data.get(co(l[t]))??[],color:ze(n.color??zo[t%zo.length]),curve:"straight",dash:!1}});return{status:d.status,error:d.error,range:s,period:n,source:o,series:h,live:r.live,back:r.back,zone:a,bounds:i?Bn(i.unit,s.start,s.end,a):[],unit:i?.unit,until:Math.min(s.end,t)}}(e),n=Uo(t,e),o=Do(t,e),a=n===W&&o===W?W:R`${n}${o}`,r=ea(e.raw),i=Ho(t,e);if(i)return ta("over"===r.header?{...r,header:"above"}:r,a,i);const s=e.raw,l=s.orientation??"vertical",c=s.whiskers??"tukey",{boxes:d,categories:h,slots:u}=xr(t,c);let m=1/0,p=-1/0;for(const j of d)m=Math.min(m,j.stats.low,...j.stats.outliers,...!0===s.readings?j.readings:[]),p=Math.max(p,j.stats.high,...j.stats.outliers,...!0===s.readings?j.readings:[]);const f=t.bounds.length>1,g="vertical"===l,y=g?Math.max(4,Math.floor(Zo(e)/Math.max(1,h)/6.5)):16,v=n=>f?function(e,t,n){const o=e.bounds[t],a=e.range.end-e.range.start;return"hour"===e.unit?En(o,"hour",a,n):"month"===e.unit?En(o,"month",a,n):En(o,"day","week"===e.unit?1/0:a,n)}(t,n,e.hass):((e,t=16)=>e.length>t?`${e.slice(0,t-1)}…`:e)(t.series[n]?.entity.name??"",y),_=Math.max(0,...Array.from({length:h},(e,t)=>v(t).length)),b=function(e,t,n,o,a,r=0){const i=ln(e.raw),s={min:i.min,max:i.max},l=[-.5,n-.5];if("vertical"===t){const t=Zo(e),r=un(o,s,4);return{options:i,value:r,times:$r(n,Math.floor(t/a)),unit:"day",range:{start:l[0],end:l[1]},scales:{x:Kt(l,[0,Eo]),y:Kt(r.domain,[Co,0]),domain:r.domain}}}const c=qo(e)-32-(i.y&&"outside"===i.labels?r:0),d=un(o,s,Math.max(2,Math.min(8,Math.round(c/70)))),h=!0===e.raw.compact?6:10;return{options:{...i,horizontal:i.vertical,vertical:i.horizontal},value:{domain:l,ticks:$r(n,h),decimals:0},times:d.ticks,unit:"day",range:{start:d.domain[0],end:d.domain[1]},scales:{x:Kt(d.domain,[0,Eo]),y:Kt(l,[0,Co]),domain:l}}}(e,l,h,d.length?[m,p]:void 0,f?56:1,6*_+12),x=g?b.scales.x:b.scales.y,$=g?b.scales.y:b.scales.x,w=(e,t)=>g?[x(e),$(t)]:[$(t),x(e)],k=g?Zo(e):r.compact?110:160,M=k>0?h/k*22:1/0,A=V`${d.map(e=>Mr(e,kr(e,u,M),w,!0===s.readings))}`,S=at(e.hass,"box_aria",{names:t.series.map(e=>e.entity.name).join(", ")}),E=fa(e.raw),C=E?mr(e.host):void 0,z=(t,n)=>Ro(Io(n,t.series.entity.decimals,e.hass),t.series.entity.unit);let L=W;const T=void 0===C?void 0:d[C];if(T){const n=t=>at(e.hass,t),{stats:o}=T,a=[{name:n("box_highest"),value:z(T,o.max)},{name:n("box_upper_quartile"),value:z(T,o.q3)},{name:n("box_median"),value:z(T,o.median),color:T.series.color},{name:n("box_lower_quartile"),value:z(T,o.q1)},{name:n("box_lowest"),value:z(T,o.min)},{name:n("box_mean"),value:z(T,o.mean)},{name:n("box_count"),value:String(o.count)}];if(o.outliers.length){const e=o.outliers.length;a.push({name:n("box_outliers"),value:e>=60?`${e}+`:String(e)})}const r=kr(T,u,M),i=f?`${function(e,t,n){const o="month"===t?{month:"long",year:"numeric"}:"hour"===t?{weekday:"short",day:"numeric",month:"short",hour:"numeric",minute:"2-digit",hour12:wn(n)}:{weekday:"day"===t?"short":void 0,day:"numeric",month:"short"},a=n?.locale?.language??"en",r=`${a}|${t}|${$n(n)}|${o.hour12}`;let i=wr.get(r);if(!i){try{i=new Intl.DateTimeFormat(a,{...o,timeZone:$n(n)})}catch{i=new Intl.DateTimeFormat("en",o)}wr.set(r,i)}return i.format(e)}(t.bounds[T.category],t.unit,e.hass)}${u>1?` · ${T.series.entity.name}`:""}`:T.series.entity.name;L=yr(g?{x:x(r.centre)/Eo,y:$(o.median)/Co,plotWidth:Zo(e),title:i,rows:a,band:[x(T.category-.5)/Eo,x(T.category+.5)/Eo]}:{x:$(o.high)/Eo,y:x(r.centre)/Co,plotWidth:Zo(e),title:i,rows:a})}return ta(r,a,Xo({axes:b,ctx:e,content:A,label:S,overlay:R`${"over"===r.header?na(a):W}${L}`,hover:E?fr(e,(e,t)=>function(e,t,n,o){const a=o*t-.5,r=Math.min(t-1,Math.max(0,Math.round(a))),i=a-r+.36;if(n>1&&(i<0||i>=br))return;const s=Math.min(n-1,Math.max(0,Math.floor(i/(br/n)))),l=e.findIndex(e=>e.category===r&&e.slot===s);return l<0?void 0:l}(d,h,u,g?e:t)):void 0,...g?{xLabel:v}:{yLabel:v,xLabel:t=>Io(t,hr(b),e.hass)}}))}},zr=["entity","time"],Lr=[2,80],Tr=[2,30],jr=[0,20];function Or(e,t){const n=new Set;for(const i of e)for(const e of i)e.t!==t&&n.add(e.t);const o=[...n].sort((e,t)=>e-t),a=e.map(()=>-1),r=[];for(const i of o){const t=[];let n=!0;e.forEach((e,o)=>{for(;a[o]+1>>16,73244475),n=Math.imul(n^n>>>16,73244475),n^=n>>>16,(n>>>0)/4294967295*2-1}function Fr(e,[t,n],o,a){const r=o*o+(a*a-o*o)*(n>t?Math.min(1,Math.max(0,(e-t)/(n-t))):.5);return Math.round(10*Math.sqrt(r))/10}nt("en",{editor_style_scatter:"Scatter",editor_style_bubble:"Bubble",editor_against:"Along the bottom",editor_against_entity:"The first entity",editor_against_time:"Time",editor_dot_size:"Dot size (px)",editor_jitter:"Jitter (px)",editor_min_size:"Smallest bubble (px)",editor_max_size:"Largest bubble (px)",editor_dot_color:"Dot colour",editor_xy_x_axis:"Labels along the bottom",scatter_needs_two:"Add a second entity. The first goes along the bottom, the others up the side.",bubble_needs_three:"Add three entities: one along the bottom, one up the side, and one for the size of each bubble.",scatter_role_x:"Across",bubble_role_size:"Size"});const Pr=e=>"number"==typeof e&&Number.isFinite(e);function Dr(e,t,[n,o]){const a=e[t];if(void 0!==a&&(!Pr(a)||ao))throw new Error(`"${t}" must be a number from ${n} to ${o}`)}function Hr(e,t){return{against:"bubble"===t?"entity":e.against??"entity",dotSize:Pr(e.dot_size)?e.dot_size:6,jitter:Pr(e.jitter)?e.jitter:0,minSize:Pr(e.min_size)?e.min_size:6,maxSize:Pr(e.max_size)?e.max_size:32}}function Br(e,t){if(To(e),sn(e),ga(e),Qo(e),ir(e),Dr(e,"jitter",jr),"scatter"===t){if(void 0!==e.against&&!zr.includes(e.against))throw new Error(`"against" must be one of: ${zr.join(", ")}`);Dr(e,"dot_size",Tr)}else{Dr(e,"min_size",Lr),Dr(e,"max_size",Lr);const n=Hr(e,t);if(n.minSize>n.maxSize)throw new Error('"min_size" must not be above "max_size"')}}function Ir(e,t){return ze(e.entity.config.color??zo[(e.index-t)%zo.length])}const Rr=(e,t,n)=>Ro(Io(e,t.entity.decimals,n),t.entity.unit);function Vr(e,t,n=Date.now()){const o=Hr(e.raw,t),{against:a}=o,r=ea(e.raw),i=jo(e,n),s=Uo(i,e),l="time"===a,c=l?0:1,d=i.series.map((e,n)=>l?e.color:"bubble"===t?1===n?Ir(e,1):void 0:0===n?void 0:Ir(e,c)),h=function(e,t,n,o){const{show:a}=n.config;return a.name||a.value?R`
+ ${e.map((e,r)=>{const i=t[r],s=void 0!==i?R``:R`${at(n.hass,"bubble"===o&&2===r?"bubble_role_size":"scatter_role_x")}`;return R`
+ ${s} ${a.name?R`${e.entity.name}`:W} + ${a.value?R`${e.entity.display}`:W} +
`})} +
`:W}(i.series,d,e,t),u=s===W&&h===W?W:R`${s}${h}`,m="over"===r.header?{...r,header:"above"}:r;if(i.series.length + ${at(e.hass,"bubble"===t?"bubble_needs_three":"scatter_needs_two")} +
`);const p=Ho(i,e);if(p)return ta(m,u,p);const f="history"!==i.source||i.group?void 0:Math.min(i.range.end,n),g=function(e,t,n,o){const{series:a}=e;if("time"===n)return a.map(e=>({s:e,samples:Nr(e.points.filter(e=>null!==e.v&&e.t!==o&&!e.carried).map(e=>({t:e.t,v:[e.t,e.v]})),800)}));if("bubble"===t){const[e,t,n]=a;return[{s:t,samples:Nr(Or([e.points,t.points,n.points],o),400)}]}const[r,...i]=a;return i.map(e=>({s:e,samples:Nr(Or([r.points,e.points],o),800)}))}(i,t,a,f),y=g.flatMap(e=>e.samples);if(0===y.length)return ta(m,u,R`
${at(e.hass,"chart_no_data")}
`);const v=y.map(e=>e.v[1]),_=[Math.min(...v),Math.max(...v)];let b,x;if(l)b=Yo(e,i.range,Jt(i.series.map(e=>e.points)));else{const t=y.map(e=>e.v[0]);b=dr(e,[Math.min(...t),Math.max(...t)],_,{x:cr(e.raw)});const n=hr(b);x=t=>Io(t,n,e.hass)}const{scales:$}=b,w=Zo(e)||1,k=o.jitter*Eo/w,M=o.jitter*Co/(r.compact?96:162);const A="bubble"===t?y.map(e=>e.v[2]):[],S=A.length?[Math.min(...A),Math.max(...A)]:[0,0],E=g.flatMap(({s:e,samples:n})=>{const a=l?e.color:Ir(e,c);return n.map(n=>({series:e,t:n.t,x:$.x(n.v[0])+(k?Ur(n.t,2*e.index)*k:0),y:$.y(n.v[1])+(M?Ur(n.t,2*e.index+1)*M:0),v:l?[n.v[1]]:n.v,color:a,d:"bubble"===t?Fr(n.v[2],S,o.minSize,o.maxSize):void 0}))}),C="bubble"===t?V`${[...E].sort((e,t)=>(t.d??0)-(e.d??0)).map(e=>V``)}`:V`${g.map(({s:e})=>{const t=E.filter(t=>t.series===e);return t.length?V`[e.x,e.y]))} + stroke=${t[0].color} style="stroke-width:${o.dotSize}px">`:W})}`,z=fa(e.raw),L=z?mr(e.host):void 0,T=void 0!==L?E[L]:void 0,j=at(e.hass,"chart_aria",{style:at(e.hass,`editor_style_${t}`),names:i.series.map(e=>e.entity.name).join(", ")}),O=Math.max(16,"bubble"===t?o.maxSize/2:o.dotSize);return ta(r,u,R`${Xo({axes:b,ctx:e,content:C,label:j,xLabel:x,overlay:R`${"over"===r.header?na(u):W} + ${T?qr(T,i,t,a,e,w):W}`,hover:z?fr(e,(e,t,n,o)=>function(e,t,n,o,a,r=24){let i,s=r*r;const l=t*o,c=n*a;return e.forEach(([e,t],n)=>{const r=e/Eo*o-l,d=t/Co*a-c,h=r*r+d*d;h<=s&&(s=h,i=n)}),i}(E.map(e=>[e.x,e.y]),e,t,n,o,O)):void 0})} + ${"bubble"===t?function(e,[t,n],o,a){const r=(t,n)=>R`${Rr(t,e,a)}`;return R``}(i.series[2],S,o,e.hass):W}`)}function qr(e,t,n,o,a,r){const i="time"===o?[e.series]:"bubble"===n?t.series.slice(0,3):[t.series[0],e.series];return yr({x:e.x/Eo,y:e.y/Co,plotWidth:r,title:Sn(e.t,a.hass),dot:e.color,rows:i.map((t,n)=>({color:t===e.series?e.color:void 0,name:t.entity.name,value:Rr(e.v[n],t,a.hass)}))})}const Wr=r` + ${Bo} + ${Jo} + ${$a} + ${oa} + ${vr} + .plot .dots, + .plot .bubble { + fill: none; + stroke-linecap: round; + } + .plot .dots { + stroke-opacity: 0.8; + } + .plot .bubble { + stroke-opacity: 0.55; + } + .role { + flex: none; + align-self: center; + font-size: 0.6875em; + line-height: 1; + padding: 2px 5px; + border-radius: 8px; + color: var(--secondary-text-color); + border: 1px solid var(--divider-color, rgba(127, 127, 127, 0.3)); + } + .size-legend { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px 12px; + padding-top: 6px; + font-size: 0.8125em; + color: var(--secondary-text-color); + } + .size-item { + display: inline-flex; + align-items: center; + gap: 6px; + } + .size-dot { + flex: none; + border-radius: 50%; + border: 1px solid var(--secondary-text-color); + background: color-mix(in srgb, var(--secondary-text-color) 25%, transparent); + box-sizing: border-box; + } + .frame.compact .size-legend { + padding-top: 2px; + } +`;function Zr(e){Po(e),Wo(e),pa(e),pr(e)}const Yr=Ca.map(e=>"x_axis"===e.name?{...e,name:"xy_x_axis"}:e);const Gr=(e,t,n=1)=>({number:{min:e,max:t,step:n,mode:"box",unit_of_measurement:"px"}}),Xr={scatter:[...ka,{name:"against",path:["against"],default:"entity"},{name:"dot_size",path:["dot_size"],default:6},{name:"jitter",path:["jitter"],default:0},...sr,...Yr,...Sa],bubble:[...ka,{name:"min_size",path:["min_size"],default:6},{name:"max_size",path:["max_size"],default:32},{name:"jitter",path:["jitter"],default:0},...sr,...Yr,...Sa]};function Jr(e){return t=>{const n="scatter"===e&&"time"===t.config.against,o=Ta(`${e}|${Ma(t.config)}|${n}`,t.hass,()=>{return[...Aa(t.hass,t.config),..."scatter"===e?[{name:"against",selector:wa(t.hass,"against",zr)},{type:"grid",name:"",schema:[{name:"dot_size",selector:Gr(Tr[0],Tr[1])},{name:"jitter",selector:Gr(jr[0],jr[1])}]}]:[{type:"grid",name:"",schema:[{name:"min_size",selector:Gr(Lr[0],Lr[1])},{name:"max_size",selector:Gr(Lr[0],Lr[1])}]},{name:"jitter",selector:Gr(jr[0],jr[1])}],...n?[]:lr,...(o=t.hass,[{type:"grid",name:"",schema:[{name:"xy_x_axis",selector:{boolean:{}}},{name:"y_axis",selector:{boolean:{}}},{name:"grid_horizontal",selector:{boolean:{}}},{name:"grid_vertical",selector:{boolean:{}}}]},{name:"axis_labels",selector:wa(o,"axis_labels",Qt)},{type:"grid",name:"",schema:[{name:"y_min",selector:{number:{mode:"box",step:"any"}}},{name:"y_max",selector:{number:{mode:"box",step:"any"}}}]},{name:"tooltip",selector:{boolean:{}}}]),...Ea(t.hass)];var o});return ja(t,"chart",o,Xr[e])}}const Kr=[{name:"dot_color",path:["color"]}],Qr=[{name:"dot_color",selector:{text:{}}}];function ei(){return{schema:Qr,fields:Kr}}const ti={layout:"chart",group:"distribution",height:270,styles:Wr,thumbnail:()=>V` + + + + `,validate:e=>Br(e,"bubble"),editor:Jr("bubble"),entityFields:()=>ei(),disconnect:Zr,render:e=>Vr(e,"bubble")},ni=["auto","none","hour","day","week","month"],oi=["auto","mean","sum","min","max","first","last","change"],ai={last:7,unit:"day"},ri={hour:1/24,day:1,week:7,month:30};function ii(e){if(e.calendar)return{day:"hour",week:"day",month:"day",year:"month"}[e.calendar];const t=e.last*ri[e.unit];return t<=2?"hour":t<=62?"day":t<=190?"week":"month"}function si(e){if(e.config.attribute)return!1;const t=e.stateObj?.attributes?.state_class;return"total_increasing"===t||"total"===t}function li(e,t){const n={...e,period:e.period??{...ai}};return"none"===e.group_by?delete n.group_by:void 0!==e.group_by&&"auto"!==e.group_by||(n.group_by=ii(Jn(n.period))),void 0!==e.aggregate&&"auto"!==e.aggregate||(n.aggregate=!0!==e.range&&t.length>0&&t.every(si)?"change":"mean"),n}function ci(e,t){const n=[...new Set(e.flatMap(e=>e.map(e=>e.t)))].sort((e,t)=>e-t),o=e.map(e=>new Map(e.map(e=>[e.t,e]))),a=new Map;for(const i of e)for(const e of i)void 0!==e.end&&a.set(e.t,e.end);const r={times:[],ends:[],values:e.map(()=>[]),lows:e.map(()=>[]),highs:e.map(()=>[])};return n.forEach((i,s)=>{const l=Math.min(a.get(i)??n[s+1]??t,t);l>i&&(r.times.push(i),r.ends.push(l),e.forEach((e,t)=>{const n=o[t].get(i);r.values[t].push(n?n.v:ia(e,i)),r.lows[t].push(n?.min??null),r.highs[t].push(n?.max??null)}))}),r}function di(e,t,n,o){const a=t-e,r=.8*a/o,i=o>1?.12*r:0;return[e+.2*a/2+n*r+i/2,r-i]}function hi(e,t=!0){let n=t?0:1/0,o=t?0:-1/0;for(const a of e)null!==a&&Number.isFinite(a)&&(n=Math.min(n,a),o=Math.max(o,a));return n===1/0?void 0:[n,o]}const ui={hour:66,day:52,week:52,month:60};nt("en",{editor_style_column:"Column",editor_group_by_auto:"Automatic",editor_aggregate_auto:"Automatic (change for meters, else average)",editor_range:"Lowest to highest in each column",editor_rounded:"Rounded ends",editor_column_labels:"Show values on the columns",editor_column_color:"Colour"});const mi={hour:"hour",day:"day",week:"day",month:"month"};function pi(e,t,n,o){if("hour"===n)return`${Sn(e,o)} – ${An(t,o)}`;const a="day"===n?{weekday:"short",day:"numeric",month:"short"}:"week"===n?{day:"numeric",month:"short"}:{month:"long",year:"numeric"};let r;try{r=new Intl.DateTimeFormat(o?.locale?.language??"en",{...a,timeZone:$n(o)})}catch{r=new Intl.DateTimeFormat("en",a)}return"week"===n?r.formatRange(e,t-1):r.format(e)}const fi=(e,t)=>Math.round(e/t*1e4)/100+"%",gi=e=>Math.min(Co,Math.max(0,e));function yi(e,t,n,o,a,r,i,s){const l=gi(e.y(a)),c=gi(e.y(o));return R`
`}function ui(e,t,n,o,a,i){return R`${Fo(o,a,e.hass)}`}function pi(e,t,n,o){const a=e.y(o);return a<0||a>Mo?R``:R`
`}function vi(e,t,n,o,a,r){return R`${Io(o,a,e.hass)}`}function _i(e,t,n,o){const a=e.y(o);return a<0||a>Co?R``:R`
`}const mi=_a.map(e=>"period_last"===e.name?{...e,default:Ka.last}:"period_unit"===e.name?{...e,default:Ka.unit}:"group_by"===e.name||"aggregate"===e.name?{...e,default:"auto"}:e),fi=[...mi,{name:"stack",path:["stack"],default:"none"},{name:"color_by",path:["color_by"],default:"series"},{name:"range",path:["range"],default:!1},{name:"column_labels",path:["labels"],default:!1},{name:"rounded",path:["rounded"],default:!0},...wa,...$a];const gi=[{name:"column_color",path:["color"]}],yi=i` + style="left:${fi(t-.1*n,Eo)};width:${fi(1.2*n,Eo)};top:${fi(a,Co)}" + >
`}const bi=ka.map(e=>"period_last"===e.name?{...e,default:ai.last}:"period_unit"===e.name?{...e,default:ai.unit}:"group_by"===e.name||"aggregate"===e.name?{...e,default:"auto"}:e),xi=[...bi,{name:"stack",path:["stack"],default:"none"},{name:"color_by",path:["color_by"],default:"series"},{name:"range",path:["range"],default:!1},{name:"column_labels",path:["labels"],default:!1},{name:"rounded",path:["rounded"],default:!0},...Ca,...Sa];const $i=[{name:"column_color",path:["color"]}],wi=r` .columns { position: absolute; inset: 0; @@ -773,53 +922,53 @@ const Qe=me(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de .axes-inside .x-axis { z-index: 1; } -`,_i={layout:"chart",group:"history",height:240,styles:i` - ${Uo} - ${Zo} - ${ga} - ${Ko} - ${yi} - `,thumbnail:()=>I` +`,ki={layout:"chart",group:"history",height:240,styles:r` + ${Bo} + ${Jo} + ${$a} + ${oa} + ${wi} + `,thumbnail:()=>V` - `,validate:function(e){if(void 0!==e.group_by&&!(e=>Xa.includes(e))(e.group_by))throw new Error(`"group_by" must be one of: ${Xa.join(", ")}`);if(void 0!==e.aggregate&&!Ja.includes(e.aggregate))throw new Error(`"aggregate" must be one of: ${Ja.join(", ")}`);const t={...e};"auto"!==e.group_by&&"none"!==e.group_by||delete t.group_by,"auto"===e.aggregate&&delete t.aggregate,So(t),rn(e),da(e),Yo(e),ea(e);for(const n of["range","rounded","labels"])if(void 0!==e[n]&&"boolean"!=typeof e[n])throw new Error(`"${n}" must be true or false`);if(void 0!==e.color_by&&"series"!==e.color_by&&"bands"!==e.color_by)throw new Error('"color_by" must be one of: series, bands')},editor:function(e){const t="none"===e.config.group_by,n={...e.config,group_by:void 0},o=Aa(`column|${va(n)}|${t}`,e.hass,()=>[...ba(e.hass,n).slice(0,-1),{type:"grid",name:"",schema:[{name:"group_by",selector:ya(e.hass,"group_by",Xa)},...t?[]:[{name:"aggregate",selector:ya(e.hass,"aggregate",Ja)}]]},{type:"grid",name:"",schema:[{name:"stack",selector:ya(e.hass,"stack",Qo)},{name:"color_by",selector:ya(e.hass,"color_by",["series","bands"])}]},{type:"grid",name:"",schema:[{name:"range",selector:{boolean:{}}},{name:"column_labels",selector:{boolean:{}}},{name:"rounded",selector:{boolean:{}}}]},...ka(e.hass),...xa(e.hass)]);return Ea({...e,commit:t=>e.commit(function(e){const t=e.period;return t&&"object"==typeof t&&void 0===t.calendar?void 0!==t.last&&void 0!==t.unit?e:{...e,period:{last:Ka.last,unit:Ka.unit,...t}}:e}(t))},"chart",o,fi)},entityFields:e=>({schema:Aa("column-entity",e,()=>[{name:"column_color",selector:{text:{}}}]),fields:gi}),disconnect(e){No(e),Bo(e),la(e)},render:function(e){const t={...e,raw:ni(e.raw,e.items)},n=t.raw,o=!0===n.range,a=Co(t,Date.now(),{range:o}),i=zo(a,t),r=jo(a,t),s=i===Z&&r===Z?Z:R`${i}${r}`,l=Po(a,t),c=Go(n);if(l)return Xo("over"===c.header?{...c,header:"above"}:c,s,l);const d=o?"none":n.stack??"none",h=a.group,u=oi(a.series.map(e=>e.points),h?1/0:a.range.end),p=h&&u.ends.length?{...a.range,end:Math.max(a.range.end,...u.ends)}:a.range,m="none"===d?void 0:na(u.values,d),f=a.series.map(e=>e.entity.target).filter(e=>null!==e);let g;g=m?aa(m):o?ii([...u.lows.flat(),...u.highs.flat(),...u.values.flat(),...f],!1):ii([...u.values.flat(),...t.config.show.target?f:[]]);let y=n;"percent"===d?y={y_min:g[0]<0?-100:0,y_max:g[1]>0?100:0,...n}:!o&&g&&(y=function(e,t){const n={...t};return void 0===t.y_min&&e[0]>=0&&(n.y_min=0),void 0===t.y_max&&e[1]<=0&&(n.y_max=0),n}(g,n));let _=Ro({...t,raw:y},p,g);const{scales:v}=_,b=u.times.map((e,t)=>(e+u.ends[t])/2),$=new Map(b.map((e,t)=>[e,t]));if(h&&b.length){const e=(x=b.length,w=Vo(t)/ri[h.unit],Math.max(1,Math.ceil(x/Math.max(1,w)))),n=si[h.unit],o="day"===h.unit&&b.length<=8?6048e5:a.range.end-a.range.start;_={..._,unit:n,times:b.filter((t,n)=>n%e===0),label:e=>An(u.times[$.get(e)],n,o,t.hass)}}var x,w;const k=function(e,t,n,o,a,i){const r=e.raw,s=t.series,l=s.length,c=!0===r.range,d="bands"===r.color_by,h=Vo(e)/ko,u=!0===r.labels&&"percent"!==a&&n.times.length*(i?1:l)<=60,p=e.config.show.target&&"percent"!==a,m=Math.min(Math.max(0,o.domain[0]),o.domain[1]),f=(e,t)=>d?Oe(Te(t,e.entity.bands).color):e.color,g={columns:[],labels:[],targets:[]},y=i?oa(n.values):[];return n.times.forEach((t,a)=>{const r=o.x(t),d=o.x(n.ends[a]);if(i){const[t,l]=ai(r,d,0,1);let c=-1,m=-1;i.forEach((e,t)=>{const n=e[a];n&&n[1]>n[0]&&(c=t),n&&n[1]{const r=e[a];if(!r||r[0]===r[1])return;const d=n.values[i][a],h=i===c?"top":i===m?"bottom":"";g.columns.push(hi(o,t,l,Math.min(...r),Math.max(...r),f(s[i],d),h,i))});const _=y[a];if(u&&null!==_&&l*h>=24){const n=_>=0?Math.max(0,...i.map(e=>e[a]?.[1]??0)):Math.min(0,...i.map(e=>e[a]?.[1]??0)),r=Math.max(...s.map(e=>e.entity.decimals));g.labels.push(ui(e,t+l/2,o.y(n),_,r,_<0))}const v=s[0].entity.target;return void(p&&null!==v&&g.targets.push(pi(o,t,l,v)))}s.forEach((t,i)=>{const s=n.values[i][a];if(null===s)return;const[y,_]=ai(r,d,i,l);if(c){const e=n.lows[i][a]??s,r=n.highs[i][a]??s;g.columns.push(hi(o,y,_,e,r,f(t,s),"both",i)),g.columns.push(R`
`,validate:function(e){if(void 0!==e.group_by&&!(e=>ni.includes(e))(e.group_by))throw new Error(`"group_by" must be one of: ${ni.join(", ")}`);if(void 0!==e.aggregate&&!oi.includes(e.aggregate))throw new Error(`"aggregate" must be one of: ${oi.join(", ")}`);const t={...e};"auto"!==e.group_by&&"none"!==e.group_by||delete t.group_by,"auto"===e.aggregate&&delete t.aggregate,To(t),sn(e),ga(e),Qo(e),ra(e);for(const n of["range","rounded","labels"])if(void 0!==e[n]&&"boolean"!=typeof e[n])throw new Error(`"${n}" must be true or false`);if(void 0!==e.color_by&&"series"!==e.color_by&&"bands"!==e.color_by)throw new Error('"color_by" must be one of: series, bands')},editor:function(e){const t="none"===e.config.group_by,n={...e.config,group_by:void 0},o=Ta(`column|${Ma(n)}|${t}`,e.hass,()=>[...Aa(e.hass,n).slice(0,-1),{type:"grid",name:"",schema:[{name:"group_by",selector:wa(e.hass,"group_by",ni)},...t?[]:[{name:"aggregate",selector:wa(e.hass,"aggregate",oi)}]]},{type:"grid",name:"",schema:[{name:"stack",selector:wa(e.hass,"stack",aa)},{name:"color_by",selector:wa(e.hass,"color_by",["series","bands"])}]},{type:"grid",name:"",schema:[{name:"range",selector:{boolean:{}}},{name:"column_labels",selector:{boolean:{}}},{name:"rounded",selector:{boolean:{}}}]},...za(e.hass),...Ea(e.hass)]);return ja({...e,commit:t=>e.commit(function(e){const t=e.period;return t&&"object"==typeof t&&void 0===t.calendar?void 0!==t.last&&void 0!==t.unit?e:{...e,period:{last:ai.last,unit:ai.unit,...t}}:e}(t))},"chart",o,xi)},entityFields:e=>({schema:Ta("column-entity",e,()=>[{name:"column_color",selector:{text:{}}}]),fields:$i}),disconnect(e){Po(e),Wo(e),pa(e)},render:function(e){const t={...e,raw:li(e.raw,e.items)},n=t.raw,o=!0===n.range,a=jo(t,Date.now(),{range:o}),r=Uo(a,t),i=Do(a,t),s=r===W&&i===W?W:R`${r}${i}`,l=Ho(a,t),c=ea(n);if(l)return ta("over"===c.header?{...c,header:"above"}:c,s,l);const d=o?"none":n.stack??"none",h=a.group,u=ci(a.series.map(e=>e.points),h?1/0:a.range.end),m=h&&u.ends.length?{...a.range,end:Math.max(a.range.end,...u.ends)}:a.range,p="none"===d?void 0:sa(u.values,d),f=a.series.map(e=>e.entity.target).filter(e=>null!==e);let g;g=p?ca(p):o?hi([...u.lows.flat(),...u.highs.flat(),...u.values.flat(),...f],!1):hi([...u.values.flat(),...t.config.show.target?f:[]]);let y=n;"percent"===d?y={y_min:g[0]<0?-100:0,y_max:g[1]>0?100:0,...n}:!o&&g&&(y=function(e,t){const n={...t};return void 0===t.y_min&&e[0]>=0&&(n.y_min=0),void 0===t.y_max&&e[1]<=0&&(n.y_max=0),n}(g,n));let v=Yo({...t,raw:y},m,g);const{scales:_}=v,b=u.times.map((e,t)=>(e+u.ends[t])/2),x=new Map(b.map((e,t)=>[e,t]));if(h&&b.length){const e=($=b.length,w=Zo(t)/ui[h.unit],Math.max(1,Math.ceil($/Math.max(1,w)))),n=mi[h.unit],o="day"===h.unit&&b.length<=8?6048e5:a.range.end-a.range.start;v={...v,unit:n,times:b.filter((t,n)=>n%e===0),label:e=>En(u.times[x.get(e)],n,o,t.hass)}}var $,w;const k=function(e,t,n,o,a,r){const i=e.raw,s=t.series,l=s.length,c=!0===i.range,d="bands"===i.color_by,h=Zo(e)/Eo,u=!0===i.labels&&"percent"!==a&&n.times.length*(r?1:l)<=60,m=e.config.show.target&&"percent"!==a,p=Math.min(Math.max(0,o.domain[0]),o.domain[1]),f=(e,t)=>d?ze(je(t,e.entity.bands).color):e.color,g={columns:[],labels:[],targets:[]},y=r?la(n.values):[];return n.times.forEach((t,a)=>{const i=o.x(t),d=o.x(n.ends[a]);if(r){const[t,l]=di(i,d,0,1);let c=-1,p=-1;r.forEach((e,t)=>{const n=e[a];n&&n[1]>n[0]&&(c=t),n&&n[1]{const i=e[a];if(!i||i[0]===i[1])return;const d=n.values[r][a],h=r===c?"top":r===p?"bottom":"";g.columns.push(yi(o,t,l,Math.min(...i),Math.max(...i),f(s[r],d),h,r))});const v=y[a];if(u&&null!==v&&l*h>=24){const n=v>=0?Math.max(0,...r.map(e=>e[a]?.[1]??0)):Math.min(0,...r.map(e=>e[a]?.[1]??0)),i=Math.max(...s.map(e=>e.entity.decimals));g.labels.push(vi(e,t+l/2,o.y(n),v,i,v<0))}const _=s[0].entity.target;return void(m&&null!==_&&g.targets.push(_i(o,t,l,_)))}s.forEach((t,r)=>{const s=n.values[r][a];if(null===s)return;const[y,v]=di(i,d,r,l);if(c){const e=n.lows[r][a]??s,i=n.highs[r][a]??s;g.columns.push(yi(o,y,v,e,i,f(t,s),"both",r)),g.columns.push(R`
`)}else g.columns.push(hi(o,y,_,Math.min(m,s),Math.max(m,s),f(t,s),s>=m?"top":"bottom",i)),u&&_*h>=24&&g.labels.push(ui(e,y+_/2,o.y(s),s,t.entity.decimals,s({...e,points:b.map((e,n)=>({t:e,v:u.values[t][n],min:u.lows[t][n],max:u.highs[t][n]}))})),A={dotY:()=>null,heading:e=>{const n=$.get(e);return void 0===n?Mn(e,t.hass):h?li(u.times[n],u.ends[n],h.unit,t.hass):Mn(u.times[n],t.hass)}},E=(e,n)=>Fo(e,n.entity.decimals,t.hass);if(o&&(A.text=(e,t)=>{if(!t||null===t.v)return"—";const n=t.min??t.v,o=t.max??t.v;return Ho(`${E(n,e)} – ${E(o,e)}`,e.entity.unit)}),m){const e=oa(u.values),n=pa(M),o=Math.max(...M.map(e=>e.entity.decimals));A.footer=a=>ma(t,e[$.get(a[0]?.p?.t??NaN)??-1]??null,n,o),"percent"===d&&(A.text=(e,t)=>{const n=t?m[e.index][$.get(t.t)??-1]:null;return t&&null!==t.v&&n?`${Ho(E(t.v,e),e.entity.unit)} · ${Math.round(Math.abs(n[1]-n[0]))} %`:"—"})}const S=ca(n),C=S?sa(t.host):void 0,O=void 0===C?void 0:$.get(C),L=void 0===O?Z:R`
`)}else g.columns.push(yi(o,y,v,Math.min(p,s),Math.max(p,s),f(t,s),s>=p?"top":"bottom",r)),u&&v*h>=24&&g.labels.push(vi(e,y+v/2,o.y(s),s,t.entity.decimals,s({...e,points:b.map((e,n)=>({t:e,v:u.values[t][n],min:u.lows[t][n],max:u.highs[t][n]}))})),A={dotY:()=>null,heading:e=>{const n=x.get(e);return void 0===n?Sn(e,t.hass):h?pi(u.times[n],u.ends[n],h.unit,t.hass):Sn(u.times[n],t.hass)}},S=(e,n)=>Io(e,n.entity.decimals,t.hass);if(o&&(A.text=(e,t)=>{if(!t||null===t.v)return"—";const n=t.min??t.v,o=t.max??t.v;return Ro(`${S(n,e)} – ${S(o,e)}`,e.entity.unit)}),p){const e=la(u.values),n=_a(M),o=Math.max(...M.map(e=>e.entity.decimals));A.footer=a=>ba(t,e[x.get(a[0]?.p?.t??NaN)??-1]??null,n,o),"percent"===d&&(A.text=(e,t)=>{const n=t?p[e.index][x.get(t.t)??-1]:null;return t&&null!==t.v&&n?`${Ro(S(t.v,e),e.entity.unit)} · ${Math.round(Math.abs(n[1]-n[0]))} %`:"—"})}const E=fa(n),C=E?ma(t.host):void 0,z=void 0===C?void 0:x.get(C),L=void 0===z?W:R`
`,z=ot(t.hass,"chart_aria",{style:ot(t.hass,"editor_style_column"),names:a.series.map(e=>e.entity.name).join(", ")}),T=!1===n.rounded?0:4;return Xo(c,s,Wo({axes:_,ctx:t,content:I``,label:z,overlay:R`
${L}${k.columns}${k.targets}
+ style="left:${fi(_.x(u.times[z]),Eo)};width:${fi(_.x(u.ends[z])-_.x(u.times[z]),Eo)}" + >
`,T=at(t.hass,"chart_aria",{style:at(t.hass,"editor_style_column"),names:a.series.map(e=>e.entity.name).join(", ")}),j=!1===n.rounded?0:4;return ta(c,s,Xo({axes:v,ctx:t,content:V``,label:T,overlay:R`
${L}${k.columns}${k.targets}
${k.labels}
- ${"over"===c.header?Jo(s):Z} - ${S?fa(t,M,v,C,Vo(t),A):Z}`,hover:S?ha(t,p,M.map(e=>e.points)):void 0}))}},vi=["series","bands"];function bi(e,t,n,o,a){const i=lt(e.entity.config,a),r=En(e.points,500),s=wo(r).map(n=>xo(n.map(e=>[t.x(e.t),t.y(e.v)]),e.curve)).join(""),l=i.dots?r.filter(e=>null!==e.v&&e.t!==o.start&&e.t!==o.end):[],c=l.length>0&&l.length<=200?I`e.points)):void 0}))}},Mi=["series","bands"];function Ai(e,t,n,o,a){const r=ct(e.entity.config,a),i=Cn(e.points,500),s=So(i).map(n=>Ao(n.map(e=>[t.x(e.t),t.y(e.v)]),e.curve)).join(""),l=r.dots?i.filter(e=>null!==e.v&&e.t!==o.start&&e.t!==o.end):[],c=l.length>0&&l.length<=200?V`[t.x(e.t),t.y(e.v)]))} + d=${ht(l.map(e=>[t.x(e.t),t.y(e.v)]))} fill="none" stroke=${n} - style="stroke-width:${ht(i.width)}px" - >`:Z;return I` + style="stroke-width:${ut(r.width)}px" + >`:W;return V` ${c} - `}function $i(e,t,n){return R`
+ `}function Si(e,t,n){return R`
${e.series.flatMap(o=>{const a=o.points.filter(t=>null!==t.v&&(void 0!==e.group||t.t!==e.range.start&&t.t!==e.range.end));return a.length>40?[]:a.map(e=>R`${Fo(e.v,o.entity.decimals,n.hass)}${Io(e.v,o.entity.decimals,n.hass)}`)})} -
`}tt("en",{editor_style_line:"Line",chart_loading:"Loading history…",chart_error:"Couldn't load history: {error}",chart_no_data:"No readings in this period.",chart_aria:"{style} chart of {names}"});const xi={layout:"chart",group:"history",height:240,styles:i` - ${Uo} - ${Zo} - ${ga} - ${Ko} +
`}nt("en",{editor_style_line:"Line",chart_loading:"Loading history…",chart_error:"Couldn't load history: {error}",chart_no_data:"No readings in this period.",chart_aria:"{style} chart of {names}"});const Ei={layout:"chart",group:"history",height:240,styles:r` + ${Bo} + ${Jo} + ${$a} + ${oa} .style-line .line { stroke-linecap: round; stroke-linejoin: round; @@ -830,21 +979,21 @@ const Qe=me(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de .style-line .line.dashed { stroke-dasharray: 6 5; } - `,thumbnail:()=>I` + `,thumbnail:()=>V` `,validate(e){if(So(e),rn(e),da(e),mt(e),Yo(e),void 0!==e.color_by&&!vi.includes(e.color_by))throw new Error(`"color_by" must be one of: ${vi.join(", ")}`)},editor:function(e){const t=Aa(`line|${va(e.config)}`,e.hass,()=>[...ba(e.hass,e.config),{type:"grid",name:"",schema:[{name:"curve",selector:ya(e.hass,"curve",vo)},{name:"color_by",selector:ya(e.hass,"color_by",["series","bands"])}]},{type:"grid",name:"",schema:[{name:"line_width",selector:wt},{name:"dots",selector:{boolean:{}}}]},{name:"labels",selector:{boolean:{}}},...ka(e.hass),...xa(e.hass)]);return Ea(e,"chart",t,Sa)},entityFields:(e,t)=>({schema:zt(e),fields:Mt(!0===t.dots)}),disconnect(e){No(e),Bo(e),la(e)},render(e){const t=Co(e),n=zo(t,e),o=jo(t,e),a=n===Z&&o===Z?Z:R`${n}${o}`,i=Po(t,e),r=Go(e.raw);if(i)return Xo("over"===r.header?{...r,header:"above"}:r,a,i);const s=Ro(e,t.range,Xt(t.series.map(e=>e.points))),{scales:l}=s,c=ca(e.raw),d="bands"===e.raw.color_by,h=d?I`${t.series.map(e=>function(e,t,n){const[o,a]=n.domain,i=e=>Math.min(1,Math.max(0,(e-o)/(a-o))),r=[];return t.forEach((e,n)=>{const o=Number.isFinite(e.from)?i(e.from):0,a=n+1`)}),I` - ${r} - `}(`cd-bands-${e.index}`,e.entity.bands,l))}`:Z,u=ct(t.series,t=>lt(t.entity.config,e.raw).onTop),p=I`${u.map(n=>bi(n,l,d?`url(#cd-bands-${n.index})`:n.color,t.range,e.raw))}`,m=ot(e.hass,"chart_aria",{style:ot(e.hass,"editor_style_line"),names:t.series.map(e=>e.entity.name).join(", ")});return Xo(r,a,Wo({axes:s,ctx:e,content:p,label:m,defs:h,overlay:R`${"over"===r.header?Jo(a):Z} - ${!0===e.raw.labels?$i(t,l,e):Z} - ${c?fa(e,t.series,l,sa(e.host),Vo(e)):Z}`,hover:c?ha(e,t.range,t.series.map(e=>e.points)):void 0}))}},wi=["line","column","area"],ki=["auto","left","right"];tt("en",{editor_style_combined:"Combined",editor_series_type:"Draw as",editor_series_type_line:"Line",editor_series_type_column:"Columns",editor_series_type_area:"Area",editor_series_axis:"Value axis",editor_series_axis_auto:"Automatic (by unit)",editor_series_axis_left:"Left",editor_series_axis_right:"Right",editor_series_aggregate:"Combine with",editor_series_aggregate_auto:"As the card",editor_series_aggregate_mean:"Average",editor_series_aggregate_sum:"Sum",editor_series_aggregate_min:"Lowest",editor_series_aggregate_max:"Highest",editor_series_aggregate_first:"First",editor_series_aggregate_last:"Last",editor_series_aggregate_change:"Change (meters that count up)"});const Mi=e=>e.config,Ai=e=>Mi(e).type??"line";function Ei(e,t){const n={...e},o=t.some(e=>"column"===Ai(e));return"none"!==e.group_by&&(void 0!==e.group_by&&"auto"!==e.group_by||o)?void 0!==e.group_by&&"auto"!==e.group_by||(n.group_by=ei(Yn(e.period))):delete n.group_by,"auto"===e.aggregate&&delete n.aggregate,n}function Si(e,t){const n=e.flatMap(e=>e.points.map(e=>e.v));return ii(n,t.includes("column"))}const Ci=[..._a.map(e=>"group_by"===e.name||"aggregate"===e.name?{...e,default:"auto"}:e),{name:"curve",path:["curve"],default:"smooth"},{name:"line_width",path:["line_width"],default:2.5},{name:"dots",path:["dots"],default:!1},{name:"rounded",path:["rounded"],default:!0},...wa,...$a];const Oi=[{name:"series_type",path:["type"],default:"line"},{name:"series_axis",path:["y_axis"],default:"auto"},{name:"series_color",path:["color"]},{name:"series_aggregate",path:["aggregate"],default:"auto"},{name:"curve",path:["curve"]},{name:"line_width",path:["line_width"]},{name:"dash",path:["dash"],default:!1},{name:"negative_color",path:["negative_color"]}],Li={layout:"chart",group:"history",height:240,styles:i` - ${Uo} - ${Zo} - ${ga} - ${Ko} - ${Ua} - ${yi} + stroke-width="2.5" stroke-linecap="round" stroke-dasharray="4 3" />`,validate(e){if(To(e),sn(e),ga(e),ft(e),Qo(e),void 0!==e.color_by&&!Mi.includes(e.color_by))throw new Error(`"color_by" must be one of: ${Mi.join(", ")}`)},editor:function(e){const t=Ta(`line|${Ma(e.config)}`,e.hass,()=>[...Aa(e.hass,e.config),{type:"grid",name:"",schema:[{name:"curve",selector:wa(e.hass,"curve",wo)},{name:"color_by",selector:wa(e.hass,"color_by",["series","bands"])}]},{type:"grid",name:"",schema:[{name:"line_width",selector:kt},{name:"dots",selector:{boolean:{}}}]},{name:"labels",selector:{boolean:{}}},...za(e.hass),...Ea(e.hass)]);return ja(e,"chart",t,Oa)},entityFields:(e,t)=>({schema:jt(e),fields:At(!0===t.dots)}),disconnect(e){Po(e),Wo(e),pa(e)},render(e){const t=jo(e),n=Uo(t,e),o=Do(t,e),a=n===W&&o===W?W:R`${n}${o}`,r=Ho(t,e),i=ea(e.raw);if(r)return ta("over"===i.header?{...i,header:"above"}:i,a,r);const s=Yo(e,t.range,Jt(t.series.map(e=>e.points))),{scales:l}=s,c=fa(e.raw),d="bands"===e.raw.color_by,h=d?V`${t.series.map(e=>function(e,t,n){const[o,a]=n.domain,r=e=>Math.min(1,Math.max(0,(e-o)/(a-o))),i=[];return t.forEach((e,n)=>{const o=Number.isFinite(e.from)?r(e.from):0,a=n+1`)}),V` + ${i} + `}(`cd-bands-${e.index}`,e.entity.bands,l))}`:W,u=dt(t.series,t=>ct(t.entity.config,e.raw).onTop),m=V`${u.map(n=>Ai(n,l,d?`url(#cd-bands-${n.index})`:n.color,t.range,e.raw))}`,p=at(e.hass,"chart_aria",{style:at(e.hass,"editor_style_line"),names:t.series.map(e=>e.entity.name).join(", ")});return ta(i,a,Xo({axes:s,ctx:e,content:m,label:p,defs:h,overlay:R`${"over"===i.header?na(a):W} + ${!0===e.raw.labels?Si(t,l,e):W} + ${c?xa(e,t.series,l,ma(e.host),Zo(e)):W}`,hover:c?ya(e,t.range,t.series.map(e=>e.points)):void 0}))}},Ci=["line","column","area"],zi=["auto","left","right"];nt("en",{editor_style_combined:"Combined",editor_series_type:"Draw as",editor_series_type_line:"Line",editor_series_type_column:"Columns",editor_series_type_area:"Area",editor_series_axis:"Value axis",editor_series_axis_auto:"Automatic (by unit)",editor_series_axis_left:"Left",editor_series_axis_right:"Right",editor_series_aggregate:"Combine with",editor_series_aggregate_auto:"As the card",editor_series_aggregate_mean:"Average",editor_series_aggregate_sum:"Sum",editor_series_aggregate_min:"Lowest",editor_series_aggregate_max:"Highest",editor_series_aggregate_first:"First",editor_series_aggregate_last:"Last",editor_series_aggregate_change:"Change (meters that count up)"});const Li=e=>e.config,Ti=e=>Li(e).type??"line";function ji(e,t){const n={...e},o=t.some(e=>"column"===Ti(e));return"none"!==e.group_by&&(void 0!==e.group_by&&"auto"!==e.group_by||o)?void 0!==e.group_by&&"auto"!==e.group_by||(n.group_by=ii(Jn(e.period))):delete n.group_by,"auto"===e.aggregate&&delete n.aggregate,n}function Oi(e,t){const n=e.flatMap(e=>e.points.map(e=>e.v));return hi(n,t.includes("column"))}const Ni=[...ka.map(e=>"group_by"===e.name||"aggregate"===e.name?{...e,default:"auto"}:e),{name:"curve",path:["curve"],default:"smooth"},{name:"line_width",path:["line_width"],default:2.5},{name:"dots",path:["dots"],default:!1},{name:"rounded",path:["rounded"],default:!0},...Ca,...Sa];const Ui=[{name:"series_type",path:["type"],default:"line"},{name:"series_axis",path:["y_axis"],default:"auto"},{name:"series_color",path:["color"]},{name:"series_aggregate",path:["aggregate"],default:"auto"},{name:"curve",path:["curve"]},{name:"line_width",path:["line_width"]},{name:"dash",path:["dash"],default:!1},{name:"negative_color",path:["negative_color"]}],Fi={layout:"chart",group:"history",height:240,styles:r` + ${Bo} + ${Jo} + ${$a} + ${oa} + ${Ra} + ${wi} .lines-over { pointer-events: none; } @@ -858,17 +1007,182 @@ const Qe=me(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de .lines-over .line.dashed { stroke-dasharray: 6 5; } - `,thumbnail:()=>I` + `,thumbnail:()=>V` `,validate:function(e){if(void 0!==e.group_by&&!Xa.includes(e.group_by))throw new Error(`"group_by" must be one of: ${Xa.join(", ")}`);if(void 0!==e.aggregate&&!Ja.includes(e.aggregate))throw new Error(`"aggregate" must be one of: ${Ja.join(", ")}`);const t={...e};if("auto"!==e.group_by&&"none"!==e.group_by||delete t.group_by,"auto"===e.aggregate&&delete t.aggregate,So(t),co(t),rn(e),da(e),mt(e),Yo(e),void 0!==e.rounded&&"boolean"!=typeof e.rounded)throw new Error('"rounded" must be true or false');for(const n of e.entities??[]){if("object"!=typeof n)continue;const e=n;if(void 0!==e.type&&!wi.includes(e.type))throw new Error(`"type" of ${n.entity} must be one of: ${wi.join(", ")}`);if(void 0!==e.y_axis&&"left"!==e.y_axis&&"right"!==e.y_axis)throw new Error(`"y_axis" of ${n.entity} must be one of: left, right`);if(void 0!==e.aggregate&&!lo.includes(e.aggregate))throw new Error(`"aggregate" of ${n.entity} must be one of: ${lo.join(", ")}`)}},editor:function(e){const t="none"===e.config.group_by,n={...e.config,group_by:void 0},o=Aa(`combined|${va(n)}|${t}`,e.hass,()=>[...ba(e.hass,n).slice(0,-1),{type:"grid",name:"",schema:[{name:"group_by",selector:ya(e.hass,"group_by",Xa)},...t?[]:[{name:"aggregate",selector:ya(e.hass,"aggregate",Ja)}]]},{name:"curve",selector:ya(e.hass,"curve",vo)},{type:"grid",name:"",schema:[{name:"line_width",selector:wt},{name:"dots",selector:{boolean:{}}},{name:"rounded",selector:{boolean:{}}}]},...ka(e.hass),...xa(e.hass)]);return Ea(e,"chart",o,Ci)},entityFields:e=>({schema:Aa("combined-entity",e,()=>[{type:"grid",name:"",schema:[{name:"series_type",selector:ya(e,"series_type",wi)},{name:"series_axis",selector:ya(e,"series_axis",ki)},{name:"series_color",selector:{text:{}}},{name:"series_aggregate",selector:ya(e,"series_aggregate",["auto",...lo])}]},{type:"grid",name:"",schema:[{name:"curve",selector:ya(e,"curve",vo)},{name:"line_width",selector:wt},{name:"dash",selector:{boolean:{}}},{name:"negative_color",selector:{text:{}}}]}]),fields:Oi}),disconnect(e){No(e),Bo(e),la(e)},render:function(e){const t={...e,raw:Ei(e.raw,e.items)},n=t.raw,o=Co(t,Date.now(),{aggregate:t=>function(e,t){const n=t.aggregate;return Mi(e).aggregate??(n&&"auto"!==n?n:ti(e)?"change":"mean")}(t,e.raw)}),a=zo(o,t),i=jo(o,t),r=a===Z&&i===Z?Z:R`${a}${i}`,s=Po(o,t),l=Go(n);if(s)return Xo("over"===l.header?{...l,header:"above"}:l,r,s);const c=o.series.map(e=>Ai(e.entity)),d=function(e){const t=e[0]?.unit,n=e.map(e=>Mi(e).y_axis??(e.unit===t?"left":"right"));return n.includes("left")?n:n.map(()=>"left")}(o.series.map(e=>e.entity)),h=o.group,u=o.series.map(e=>h?{...e,points:e.points.map(e=>({...e,t:void 0!==e.end?(e.t+e.end)/2:e.t}))}:e),p=h?Math.max(o.range.end,...o.series.flatMap(e=>e.points.map(e=>e.end??0))):0,m=h?{...o.range,end:p}:o.range,f=e=>u.filter((t,n)=>d[n]===e),g=e=>c.filter((t,n)=>d[n]===e),y=Si(f("left"),g("left"));let _=n;y&&g("left").includes("column")?_={...n,y_min:n.y_min??(y[0]>=0?0:void 0),y_max:n.y_max??(y[1]<=0?0:void 0)}:g("left").includes("area")&&(_=Ca(y,n));const v=Ro({...t,raw:_},m,y),b=v.scales,$=f("right"),x=Si($,g("right")),w={};x&&(g("right").includes("column")||g("right").includes("area"))&&(x[0]>=0&&x[0]<=x[1]/2&&(w.min=0),x[1]<=0&&x[1]>=x[0]/2&&(w.max=0));const k=$.length?dn(x,w,4):void 0,M=k?{x:b.x,y:Jt(k.domain,[Mo,0]),domain:k.domain}:void 0,A=e=>"right"===d[e.index]&&M?M:b,E=u.filter(e=>"area"===c[e.index]).map(e=>Ta(e,e.points,A(e),n,{range:m})),S=u.filter(e=>"line"===c[e.index]).map(e=>bi({...e,points:e.points},A(e),e.color,m,n)),C=u.filter(e=>"column"===c[e.index]),O=oi(o.series.filter(e=>"column"===c[e.index]).map(e=>e.points),h?1/0:o.range.end),L=!1===n.rounded?0:4,z=[];O.times.forEach((e,t)=>{const n=b.x(e),o=b.x(O.ends[t]);C.forEach((e,a)=>{const i=O.values[a][t];if(null===i)return;const r=A(e),s=Math.min(Math.max(0,r.domain[0]),r.domain[1]),[l,c]=ai(n,o,a,C.length);z.push(hi(r,l,c,Math.min(s,i),Math.max(s,i),e.color,i>=s?"top":"bottom",e.index))})});const T=O.times.map((e,t)=>(e+O.ends[t])/2),N=u.map(e=>{if("column"!==c[e.index]||h)return e;const t=C.indexOf(e);return{...e,points:T.map((e,n)=>({t:e,v:O.values[t][n]}))}}),j=new Map;for(const F of o.series)for(const e of F.points)void 0!==e.end&&j.set((e.t+e.end)/2,[e.t,e.end]);const P=ca(n),U=ot(t.hass,"chart_aria",{style:ot(t.hass,"editor_style_combined"),names:o.series.map(e=>e.entity.name).join(", ")});return Xo(l,r,Wo({axes:v,ctx:t,right:k&&M?{value:k,y:M.y}:void 0,content:I`${E.map(e=>e.body)}`,defs:I`${E.map(e=>e.defs)}`,label:U,overlay:R`
${z}
-
${T}
+ + ${"over"===l.header?na(i):W} + ${U?xa(t,O,b,ma(t.host),Zo(t),{dotY:(e,t)=>"column"===c[e.index]||null===t.v?null:A(e).y(t.v),heading:e=>{const n=N.get(e);return n&&h?pi(n[0],n[1],h.unit,t.hass):Sn(e,t.hass)}}):W}`,hover:U?ya(t,p,O.map(e=>e.points)):void 0}))}};function Pi(e,t){const n=Math.round(12+88*t);return n>=100?e:`color-mix(in srgb, ${e} ${n}%, var(--card-background-color, #fff))`}const Di=Math.sqrt(3)/2,Hi=["hours","calendar"],Bi=["scale","bands"],Ii=["square","rounded","hexagon"],Ri=10,Vi=1e4,qi={hours:{last:4,unit:"week",source:"history"},calendar:{last:12,unit:"month",source:"statistics"}};nt("en",{editor_style_heatmap:"Heatmap",heatmap_aria:"Heatmap of {name}",heatmap_hours:"Days averaged",heatmap_empty:"No readings",editor_grid:"Layout",editor_grid_hours:"Hour by weekday",editor_grid_calendar:"Calendar (a day per cell)",editor_color_by_scale:"Smooth scale",editor_cell_shape:"Cells",editor_cell_shape_square:"Square",editor_cell_shape_rounded:"Rounded",editor_cell_shape_hexagon:"Hexagon",editor_color_min:"Lowest colour at",editor_color_max:"Highest colour at",editor_legend:"Legend"});const Wi=e=>"number"==typeof e&&Number.isFinite(e);function Zi(e){return e.grid??"hours"}function Yi(e){const t=Zi(e),n=qi[t],o=e.period??{};return{...e,group_by:"hours"===t?"hour":"day",source:e.source??n.source,period:void 0!==o.calendar?o:{...o,last:o.last??n.last,unit:o.unit??n.unit}}}const Gi=new Map;function Xi(e,t,n){const o=e?.locale?.language??e?.language??"en",a=`${o}|${t}|${JSON.stringify(n)}`;let r=Gi.get(a);if(!r){try{r=new Intl.DateTimeFormat(o,{...n,timeZone:t})}catch{r=new Intl.DateTimeFormat("en",{...n,timeZone:"UTC"})}Gi.set(a,r)}return r}function Ji(e,t){return Xi(t,"UTC",{weekday:"short"}).format(Date.UTC(2024,0,e))}function Ki(e,t,n=!1){return Xi(t,"UTC",{hour:"numeric",...n?{minute:"2-digit"}:{},hour12:wn(t)}).format(Date.UTC(2024,0,1,e))}function Qi(e,t,n,o,a="cell"){const[r,i]=e.centre(t.col,t.row),s=o?`fill:${o}`:"",l=o?"":"empty";if("hexagon"===n)return V`{const a=(60*n-90)*Math.PI/180;return`${Math.round(100*(e+o*Math.cos(a)))/100},${Math.round(100*(t+o*Math.sin(a)))/100}`});return`M${a.join("L")}Z`}(r,i,8.8)} style=${s}>`;const c=8.8;return V``}const es=e=>Math.round(1e4*e)/100+"%";function ts(e,t,n,o){const{zone:a}=t,r=e=>(a.firstDay-1+e)%7+1,i=function(e,t){const n=new Array(168).fill(0),o=new Array(168).fill(0);for(const a of e){if(null===a.v)continue;const e=Nn(a.t,t.timeZone),r=(e.weekday-t.firstDay+7)%7*24+e.hour;n[r]+=a.v,o[r]+=1}return n.map((e,t)=>({row:Math.floor(t/24),hour:t%24,v:o[t]?e/o[t]:null,count:o[t]}))}(e.points,a).map(e=>({col:e.hour,row:e.row,v:e.v,count:e.count,title:()=>`${Ji(r(e.row),n)} ${Ki(e.hour,n,!0)} – ${Ki((e.hour+1)%24,n,!0)}`})),s=o?3:6;return{cells:i,cols:24,rows:7,rowLabels:[0,1,2,3,4,5,6].map(e=>({row:e,text:Ji(r(e),n)})),colLabels:Array.from({length:24/s},(e,t)=>({col:t*s,text:Ki(t*s,n)}))}}function ns(e,t,n){const{zone:o}=t,a=function(e,t){if(0===e.length)return{cells:[],weeks:0,months:[]};const n=e=>{const n=Nn(e,t.timeZone);return Math.round(Date.UTC(n.year,n.month-1,n.day)/864e5)},o=n(Dn("week",e[0].t,t)),a=[],r=[];let i=-1;for(const s of e){const e=Nn(s.t,t.timeZone),l=Math.floor((n(s.t)-o)/7);a.push({t:s.t,col:l,row:(e.weekday-t.firstDay+7)%7,v:s.v});const c=12*e.year+e.month;c!==i&&(-1!==i&&1!==e.day||r.push({col:l,t:s.t}),i=c)}return r.length>1&&r[1].col-r[0].col<3&&r.shift(),{cells:a,weeks:a[a.length-1].col+1,months:r}}(e.points,o),r=Xi(n,o.timeZone,{weekday:"short",day:"numeric",month:"short",year:"numeric"}),i=Xi(n,o.timeZone,{month:"short"}),s=e=>(o.firstDay-1+e)%7+1;return{cells:a.cells.map(e=>({col:e.col,row:e.row,v:e.v,title:()=>r.format(e.t)})),cols:Math.max(1,a.weeks),rows:7,rowLabels:[0,1,2,3,4,5,6].map(e=>({row:e,text:Ji(s(e),n)})),colLabels:a.months.map(e=>({col:e.col,text:i.format(e.t)}))}}function os(e,t,n,o){const a=n.raw,r=Zi(n.raw),i=a.cell_shape??"rounded",s="bands"===a.color_by,l=qo(n),c="hours"===r?ts(e,t,n.hass,l>=400):ns(e,t,n.hass),d=function(e,t,n){if("hexagon"===n){const n=Ri/Math.sqrt(3),o=Ri*Di,a=e*Ri+5,r=(t-1)*o+2*n;return{width:a,height:r,centre:(e,t)=>[e*Ri+5+t%2*5,n+t*o],at(i,s){const l=Math.min(t-1,Math.max(0,Math.round((s*r-n)/o))),c=Math.floor((i*a-l%2*5)/Ri);return[Math.min(e-1,Math.max(0,c)),l]}}}return{width:e*Ri,height:t*Ri,centre:(e,t)=>[e*Ri+5,t*Ri+5],at:(n,o)=>[Math.min(e-1,Math.floor(n*e)),Math.min(t-1,Math.floor(o*t))]}}(c.cols,c.rows,i),h=Math.max(0,l-72)/d.width*Ri,u=Math.ceil(13/Math.max(1,h*("hexagon"===i?Di:1))),m=c.rowLabels.filter(e=>e.row%u===0);let p=-1/0;const f=c.colLabels.filter(e=>!((e.col-p)*h<32)&&(p=e.col,!0)),g=function(e,t={}){const n=e.filter(e=>null!==e);if(0===n.length&&(void 0===t.min||void 0===t.max))return;let o=t.min??Math.min(...n),a=t.max??Math.max(...n);return a<=o&&(void 0===t.max?a=o+1:o=a-1),[o,a]}(c.cells.map(e=>e.v),{min:Wi(a.color_min)?a.color_min:void 0,max:Wi(a.color_max)?a.color_max:void 0}),y=t=>{if(null!==t)return s?ze(je(t,e.entity.bands).color):g?Pi(e.color,function(e,[t,n]){return Math.min(1,Math.max(0,(e-t)/(n-t)))}(t,g)):e.color},v=mr(n.host),_=void 0!==v&&Math.floor(v/Vi)===e.index?v%Vi:void 0,b=new Map(c.cells.map((e,t)=>[8*e.col+e.row,t])),x=fa(n.raw)?fr(n,(t,n)=>{const[o,a]=d.at(t,n),r=b.get(8*o+a);return void 0===r?void 0:e.index*Vi+r}):void 0,$=void 0===_?void 0:c.cells[_];let w=W;if($){const[t,o]=d.centre($.col,$.row),a=null===$.v?at(n.hass,"heatmap_empty"):Ro(Io($.v,e.entity.decimals,n.hass),e.entity.unit);w=yr({x:t/d.width,y:o/d.height,plotWidth:Math.max(0,l-80),title:$.title(),rows:[{color:y($.v)??"var(--cd-track)",name:e.entity.name,value:a},...void 0!==$.count&&null!==$.v?[{name:at(n.hass,"heatmap_hours"),value:String($.count)}]:[]]})}const{show:k}=n.config,M=k.name||k.value||o?R`
+
+ ${s?W:R``} + ${k.name||o?R`${e.entity.name}`:W} + ${k.value?R`${e.entity.display}`:W} +
+
`:W,A=at(n.hass,"heatmap_aria",{name:e.entity.name});return R`
+ ${M} +
+ +
+ + ${c.cells.map(e=>Qi(d,e,i,y(e.v)))} + ${$?Qi(d,$,i,"none","cell picked"):W} - ${"over"===l.header?Jo(r):Z} - ${P?fa(t,N,b,sa(t.host),Vo(t),{dotY:(e,t)=>"column"===c[e.index]||null===t.v?null:A(e).y(t.v),heading:e=>{const n=j.get(e);return n&&h?li(n[0],n[1],h.unit,t.hass):Mn(e,t.hass)}}):Z}`,hover:P?ha(t,m,N.map(e=>e.points)):void 0}))}},zi=e=>(Math.abs(e)<.005?0:e).toFixed(2);function Ti(e,t,n,o){const a=o*Math.PI/180;return[e+n*Math.sin(a),t-n*Math.cos(a)]}function Ni(e,t,n,o,a){const i=String(Number(n.toFixed(2))),r=a-o,[s,l]=Ti(e,t,n,o);if(r>=359.99){const[a,r]=Ti(e,t,n,o+180);return`M${zi(s)} ${zi(l)} A${i} ${i} 0 1 1 ${zi(a)} ${zi(r)} A${i} ${i} 0 1 1 ${zi(s)} ${zi(l)}`}const[c,d]=Ti(e,t,n,a),h=r>180?1:0;return`M${zi(s)} ${zi(l)} A${i} ${i} 0 ${h} 1 ${zi(c)} ${zi(d)}`}function ji(e,t){const n=e/2;return[-n+t[0]*e,-n+t[1]*e]}function Pi(e){let t=0;for(const n of e)t+=n.codePointAt(0)>=11904?1/.58:1;return t}function Ui(e,t=76,n=12,o=8,a=11){const i=e.trim();let r=[i];if(Pi(i)>a){let e=Pi(i);for(let t=i.indexOf(" ");t>0;t=i.indexOf(" ",t+1)){const n=Math.max(Pi(i.slice(0,t)),Pi(i.slice(t+1)));nString(Math.round(1e3*e)/1e3+0);function Bi(e,t,n,o,a,i=11){const{lines:r,size:s}=Ui(e,t,n,o,i),l=a-1.2*(r.length-1)*s;return I`${r.map((e,t)=>I`${e}`)}`}const Vi={layout:"tiles",group:"gauges",height:140,minColumnWidth:134,styles:i` + ${w} +
+ +
+ ${!1===a.legend?W:function(e,t,n,o){const a=e.entity,r=e=>Io(e,a.decimals,o.hass);return n?R`
+ ${Oe(a.bands,r,a.unit).map(e=>R`${e.text}`)} +
`:t?R`
+ ${r(t[0])} + + ${Ro(r(t[1]),a.unit)} +
`:W}(e,g,s,n)} +
`}const as=new Map;const rs={layout:"chart",group:"distribution",height:220,styles:r` + ${Bo} + ${$a} + .hm + .hm { + margin-top: 12px; + } + .hm-body { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + grid-template-rows: auto auto; + column-gap: 6px; + } + .hm-rows { + position: relative; + grid-column: 1; + grid-row: 1; + min-width: 2em; + } + .hm-plot { + position: relative; + grid-column: 2; + grid-row: 1; + } + .hm-plot.hoverable { + cursor: crosshair; + } + .hm-svg { + display: block; + width: 100%; + height: auto; + opacity: 0; + transition: opacity var(--cd-duration) var(--cd-easing); + } + :host([ready]) .hm-svg { + opacity: 1; + } + :host([static]) .hm-svg { + transition: none; + } + .hm-svg .cell.empty { + fill: var(--cd-track); + } + .hm-svg .cell.picked { + fill: none; + stroke: var(--primary-text-color); + stroke-width: 1px; + vector-effect: non-scaling-stroke; + } + .hm-cols { + position: relative; + grid-column: 2; + grid-row: 2; + height: 16px; + } + .hm-label { + position: absolute; + font-size: 11px; + line-height: 1; + color: var(--secondary-text-color); + white-space: nowrap; + pointer-events: none; + } + .hm-rows .hm-label { + right: 0; + transform: translateY(-50%); + } + .hm-cols .hm-label { + top: 4px; + } + .grid-calendar .hm-label { + font-size: 10px; + } + .hm-legend { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 4px 10px; + padding-top: 6px; + font-size: 11px; + color: var(--secondary-text-color); + font-variant-numeric: tabular-nums; + } + .hm-legend.scale { + justify-content: flex-end; + gap: 6px; + } + .hm-gradient { + width: min(160px, 40%); + height: 8px; + border-radius: 4px; + } + .hm-key { + display: inline-flex; + align-items: center; + gap: 4px; + } + .hm-swatch { + width: 10px; + height: 10px; + border-radius: 2px; + } + `,thumbnail:()=>V` + ${[0,1,2,3].map(e=>[0,1,2,3,4,5,6].map(t=>V``))}`,validate:function(e){const t=e,n=(e,n)=>{const o=t[e];if(void 0!==o&&!n.includes(o))throw new Error(`"${e}" must be one of: ${n.join(", ")}`)};n("grid",Hi),n("color_by",Bi),n("cell_shape",Ii);for(const o of["color_min","color_max"])if(void 0!==t[o]&&!Wi(t[o]))throw new Error(`"${o}" must be a number`);if(Wi(t.color_min)&&Wi(t.color_max)&&t.color_min>=t.color_max)throw new Error('"color_min" must be below "color_max"');if(void 0!==t.legend&&"boolean"!=typeof t.legend)throw new Error('"legend" must be true or false');To(Yi(e)),ga(e)},editor:function(e){const t=Zi(e.config),n=Ta(`heatmap|${Ma({...e.config,group_by:void 0})}`,e.hass,()=>[{name:"grid",selector:wa(e.hass,"grid",Hi)},...Aa(e.hass,{...e.config,group_by:void 0}).filter(e=>!JSON.stringify(e).includes('"group_by"')),{name:"aggregate",selector:wa(e.hass,"aggregate",uo)},{type:"grid",name:"",schema:[{name:"color_by",selector:wa(e.hass,"color_by",Bi)},{name:"cell_shape",selector:wa(e.hass,"cell_shape",Ii)}]},{type:"grid",name:"",schema:[{name:"color_min",selector:{number:{mode:"box",step:"any"}}},{name:"color_max",selector:{number:{mode:"box",step:"any"}}}]},{type:"grid",name:"",schema:[{name:"legend",selector:{boolean:{}}},{name:"tooltip",selector:{boolean:{}}}]}]);return ja(e,"chart",n,function(e){let t=as.get(e);if(!t){const n=qi[e],o={period_last:n.last,period_unit:n.unit,source:n.source};t=[{name:"grid",path:["grid"],default:"hours"},...ka.filter(e=>"group_by"!==e.name).map(e=>e.name in o?{...e,default:o[e.name]}:e),{name:"color_by",path:["color_by"],default:"scale"},{name:"cell_shape",path:["cell_shape"],default:"rounded"},{name:"color_min",path:["color_min"]},{name:"color_max",path:["color_max"]},{name:"legend",path:["legend"],default:!0},{name:"tooltip",path:["tooltip"],default:!0}],as.set(e,t)}return t}(t))},entityFields:()=>({schema:[{name:"series_color",selector:{text:{}}}],fields:[{name:"series_color",path:["color"]}]}),disconnect(e){Po(e),Wo(e),pr(e)},render(e){const t=jo({...e,raw:Yi(e.raw)}),n=Uo(t,e),o=Ho(t,e);if(o)return R`
${n}${o}
`;const a=t.series.length>1;return R`
${n}${t.series.map(n=>os(n,t,e,a))}
`}},is=["time","readings"],ss=100,ls=e=>Number(e.toPrecision(12));const cs=6e4,ds=36e5;const hs=["percent","total"],us=["side","overlap"],ms=["series","bands"];nt("en",{editor_style_histogram:"Histogram",editor_count:"Count",editor_count_time:"Time spent in each range",editor_count_readings:"Readings in each range",editor_show_as:"Show as",editor_show_as_percent:"Share of the whole (%)",editor_show_as_total:"Hours or readings",editor_bins:"Number of bins (empty: automatic)",editor_bin_width:"Bin width (wins over the number)",editor_bars:"Two or more entities",editor_bars_side:"Bars side by side",editor_bars_overlap:"Bars over each other",editor_hist_x_axis:"Value labels along the bottom",editor_bar_color:"Bar colour",histogram_readings:"{n} readings",histogram_reading:"1 reading"});const ps=e=>"number"==typeof e&&Number.isFinite(e);function fs(e,t,n){if(void 0!==e[t]&&!n.includes(e[t]))throw new Error(`"${t}" must be one of: ${n.join(", ")}`)}function gs(e,t){const n=jo(e,t),o={count:(a=e.raw).count??"time",showAs:a.show_as??"percent",bars:a.bars??"side",bands:"bands"===a.color_by,bins:ps(a.bins)?a.bins:void 0,width:ps(a.bin_width)?a.bin_width:void 0};var a;const r=Math.min(n.range.end,t),i=n.series.map(e=>function(e,t){const{mode:n,until:o,statistics:a}=t;if(0===e.length)return[];if(void 0!==e[0].end)return e.map(e=>null===e.v?0:"readings"===n?1:Math.max(0,Math.min(e.end,o)-e.t));if(a){if("readings"===n)return e.map(e=>null===e.v||e.carried?0:1);const t=e.slice(1).map((t,n)=>t.t-e[n].t).filter(e=>e>0).sort((e,t)=>e-t),o=t.length?t[Math.floor(t.length/2)]:36e5;return e.map(e=>null===e.v||e.carried?0:o)}const r=e.length-1;return e.map((t,a)=>{if(null===t.v)return 0;if("readings"===n){const n=a===r&&a>0&&t.t>=o&&e[a-1].v===t.v;return t.carried||n?0:1}const i=ae.points.forEach((e,n)=>{null!==e.v&&i[t][n]>0&&(s=Math.min(s,e.v),l=Math.max(l,e.v))}));const c=cr(e.raw),d="side"===o.bars&&n.series.length>1,h=Math.max(5,Math.min(24,Math.round(Zo(e)/(d?40:28)))),u=s===1/0?[]:function(e,t,n={},o=12){const a=n.min??e,r=n.max??t;if(void 0!==n.bins&&void 0===n.width){let[e,t]=[a,r];t>e||([e,t]=[e-.5,e+.5]);const o=(t-e)/n.bins;return Array.from({length:n.bins+1},(a,r)=>r===n.bins?t:ls(e+r*o))}const i=r-a;let s=n.width??(i>0?cn(i,o):cn(Math.max(Math.abs(a),1),10));for(;i/s>ss;)s=cn(i,50);const l=n.min??ls(Math.floor(a/s+1e-9)*s);let c=n.max??ls(Math.ceil(r/s-1e-9)*s);c>l||(c=ls(l+s));const d=[l];for(let h=1;d[d.length-1]{const n=function(e,t,n){const o=new Array(Math.max(0,n.length-1)).fill(0);return e.forEach((e,a)=>{if(null===e.v||!(t[a]>0))return;const r=function(e,t){const n=t.length-1;if(n<1||et[n])return-1;if(e===t[n])return n-1;let o=0,a=n-1;for(;o>1;t[n]<=e?o=n:a=n-1}return o}(e.v,n);r>=0&&(o[r]+=t[a])}),o}(e.points,i[t],u),a=n.reduce((e,t)=>e+t,0),r=n.map(e=>"percent"===o.showAs?a>0?e/a*100:0:"time"===o.count?e/36e5:e);return{series:e,totals:n,heights:r,whole:a}});return{frame:n,opts:o,edges:u,data:m,side:d}}function ys(e,t,n){return"time"===t?function(e){if(e0?"< 1 min":"0 min";const t=Math.round(e/cs);if(t<60)return`${t} min`;if(e<864e5){const e=Math.floor(t/60),n=t%60;return n?`${e} h ${n} min`:`${e} h`}const n=Math.round(e/ds),o=Math.floor(n/24),a=n%24;return a?`${o} d ${a} h`:`${o} d`}(e):1===e?at(n.hass,"histogram_reading"):at(n.hass,"histogram_readings",{n:Io(e,0,n.hass)})}const vs={layout:"chart",group:"distribution",height:240,styles:r` + ${Bo} + ${Jo} + ${$a} + ${oa} + ${vr} + .style-histogram .bin { + shape-rendering: crispEdges; + } + .style-histogram .overlap .bin { + fill-opacity: 0.35; + stroke-width: 1.5px; + vector-effect: non-scaling-stroke; + } + `,thumbnail:()=>V` + + + + + + + + + `,validate(e){if(To(e),sn(e),ga(e),Qo(e),ir(e),fs(e,"count",is),fs(e,"show_as",hs),fs(e,"bars",us),fs(e,"color_by",ms),void 0!==e.bins&&!(Number.isInteger(e.bins)&&e.bins>=1&&e.bins<=ss))throw new Error('"bins" must be a whole number from 1 to 100');if(void 0!==e.bin_width&&!(ps(e.bin_width)&&e.bin_width>0))throw new Error('"bin_width" must be a number above 0')},editor:function(e){const t=e.hass,n=Ta(`histogram|${Ma(e.config)}`,t,()=>[...Aa(t,e.config),{type:"grid",name:"",schema:[{name:"count",selector:wa(t,"count",is)},{name:"show_as",selector:wa(t,"show_as",hs)}]},{type:"grid",name:"",schema:[{name:"bins",selector:{number:{min:1,max:ss,mode:"box"}}},{name:"bin_width",selector:{number:{min:0,mode:"box",step:"any"}}}]},...lr,{type:"grid",name:"",schema:[{name:"bars",selector:wa(t,"bars",us)},{name:"color_by",selector:wa(t,"color_by",ms)}]},...za(t).map(e=>"grid"===e.type?{...e,schema:e.schema.map(e=>"x_axis"===e.name?{...e,name:"hist_x_axis"}:e)}:e),...Ea(t)]);return ja(e,"chart",n,$s)},entityFields:()=>({schema:bs,fields:_s}),disconnect(e){Po(e),Wo(e),pr(e)},render(e){const t=Date.now(),{frame:n,opts:o,edges:a,data:r,side:i}=gs(e,t),s=Uo(n,e),l=Do(n,e),c=s===W&&l===W?W:R`${s}${l}`,d=ea(e.raw),h=Ho(n,e)??(a.length<2?Ho({...n,series:[]},e):void 0);if(h)return ta("over"===d.header?{...d,header:"above"}:d,c,h);const u=Math.max(0,...r.flatMap(e=>e.heights)),m=dr(e,[a[0],a[a.length-1]],[0,u],{x:{min:a[0],max:a[a.length-1]},yFloor:0}),{scales:p}=m,f=p.y(Math.max(0,m.value.domain[0])),g=r.length,y="overlap"===o.bars&&g>1,v=V`${r.map((e,t)=>{const n=e.heights.map((n,r)=>{if(!(n>0))return W;const s=p.x(a[r]),l=p.x(a[r+1]),c=Math.min(4,.08*(l-s)),d=(l-s-2*c)/(i?g:1),h=s+c+(i?d*t:0),u=p.y(n),m=o.bands&&e.series.entity.bands.length?ze(je((a[r]+a[r+1])/2,e.series.entity.bands).color):e.series.color;return V``});return V`${n}`})}`,_=function(e){if(e.length<2)return 0;const t=e[1]-e[0],n=e.every(e=>Math.abs(e-Number(e.toFixed(dn(t))))<1e-6*t);return n?dn(t):Math.min(4,dn(t)+1)}(a),b=fa(e.raw),x=b?mr(e.host):void 0;let $=W;if(void 0!==x&&x{const n=t.whole>0?t.totals[x]/t.whole*100:0;return{color:t.series.color,name:t.series.entity.name,value:`${Io(n,n>0&&n<1?1:0,e.hass)}% · ${ys(t.totals[x],o.count,e)}`}})})}const w=at(e.hass,"chart_aria",{style:at(e.hass,"editor_style_histogram"),names:n.series.map(e=>e.entity.name).join(", ")});return ta(d,c,Xo({axes:m,ctx:e,content:V`${v}`,label:w,xLabel:t=>Io(t,hr(m),e.hass),yLabel:t=>"percent"===o.showAs?`${Io(t,m.value.decimals,e.hass)}%`:"time"===o.count?`${Io(t,m.value.decimals,e.hass)} h`:Io(t,m.value.decimals,e.hass),overlay:R`${"over"===d.header?na(c):W}${$}`,hover:b?fr(e,e=>{const t=a[0]+e*(a[a.length-1]-a[0]),n=a.findIndex((e,n)=>n=e&&t<=a[n+1]);return n<0?void 0:n}):void 0}))}},_s=[{name:"bar_color",path:["color"]}],bs=[{name:"bar_color",selector:{text:{}}}],xs=Ca.map(e=>"x_axis"===e.name?{...e,name:"hist_x_axis"}:e),$s=[...ka,{name:"count",path:["count"],default:"time"},{name:"show_as",path:["show_as"],default:"percent"},{name:"bins",path:["bins"]},{name:"bin_width",path:["bin_width"]},...sr,{name:"bars",path:["bars"],default:"side"},{name:"color_by",path:["color_by"],default:"series"},...xs,...Sa];const ws=e=>(Math.abs(e)<.005?0:e).toFixed(2);function ks(e,t,n,o){const a=o*Math.PI/180;return[e+n*Math.sin(a),t-n*Math.cos(a)]}function Ms(e,t,n,o,a){const r=String(Number(n.toFixed(2))),i=a-o,[s,l]=ks(e,t,n,o);if(i>=359.99){const[a,i]=ks(e,t,n,o+180);return`M${ws(s)} ${ws(l)} A${r} ${r} 0 1 1 ${ws(a)} ${ws(i)} A${r} ${r} 0 1 1 ${ws(s)} ${ws(l)}`}const[c,d]=ks(e,t,n,a),h=i>180?1:0;return`M${ws(s)} ${ws(l)} A${r} ${r} 0 ${h} 1 ${ws(c)} ${ws(d)}`}function As(e,t){const n=e/2;return[-n+t[0]*e,-n+t[1]*e]}function Ss(e){let t=0;for(const n of e)t+=n.codePointAt(0)>=11904?1/.58:1;return t}function Es(e,t=76,n=12,o=8,a=11){const r=e.trim();let i=[r];if(Ss(r)>a){let e=Ss(r);for(let t=r.indexOf(" ");t>0;t=r.indexOf(" ",t+1)){const n=Math.max(Ss(r.slice(0,t)),Ss(r.slice(t+1)));nString(Math.round(1e3*e)/1e3+0);function Ts(e,t,n,o,a,r=11){const{lines:i,size:s}=Es(e,t,n,o,r),l=a-1.2*(i.length-1)*s;return V`${i.map((e,t)=>V`${e}`)}`}const js={layout:"tiles",group:"gauges",height:140,minColumnWidth:134,styles:r` /* A definite width that shrinks with a narrow tile (the svg inside is sized in %). */ .needle-gauge { display: flex; @@ -944,39 +1258,39 @@ const Qe=me(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de text-overflow: ellipsis; white-space: nowrap; } - `,render(e,{show:t,hass:n}){const o=function(e,t,n){const o=n-t,a=[];return o>0?(e.forEach((i,r)=>{const s=0===r?t:Math.max(i.from,t),l=Math.min(r+1s&&a.push({from:(s-t)/o,to:(l-t)/o,color:i.color})}),a):a}(e.bands,e.min,e.max),a=o.map((e,t)=>{const n=180*e.from-90+(t>0?1:0),a=180*e.to-90-(tn?I``:Z}),i=Array.from({length:11},(e,t)=>{const[n,o]=Ti(Fi,Hi,36,18*t-90),[a,i]=Ti(Fi,Hi,40,18*t-90);return I``});let r=Z;if(t.target&&null!==e.targetPosition&&e.available){const t=180*e.targetPosition-90,[n,o]=Ti(Fi,Hi,57,t),[a,i]=Ti(Fi,Hi,64,t-3),[s,l]=Ti(Fi,Hi,64,t+3);r=I``}const s=!t.value;return R`
+ `,render(e,{show:t,hass:n}){const o=function(e,t,n){const o=n-t,a=[];return o>0?(e.forEach((r,i)=>{const s=0===i?t:Math.max(r.from,t),l=Math.min(i+1s&&a.push({from:(s-t)/o,to:(l-t)/o,color:r.color})}),a):a}(e.bands,e.min,e.max),a=o.map((e,t)=>{const n=180*e.from-90+(t>0?1:0),a=180*e.to-90-(tn?V``:W}),r=Array.from({length:11},(e,t)=>{const[n,o]=ks(Cs,zs,36,18*t-90),[a,r]=ks(Cs,zs,40,18*t-90);return V``});let i=W;if(t.target&&null!==e.targetPosition&&e.available){const t=180*e.targetPosition-90,[n,o]=ks(Cs,zs,57,t),[a,r]=ks(Cs,zs,64,t-3),[s,l]=ks(Cs,zs,64,t+3);i=V``}const s=!t.value;return R`
${t.name||t.icon?R`
${t.icon?R``:Z} - ${t.name?R`${e.name}`:Z} -
`:Z} -
`;var l,c,d,h,u}};function Ri(e){return e.map(e=>e.available&&e.value>0?e.value:0)}function Ii(e,t=100){const n=e.reduce((e,t)=>e+Math.max(0,t),0);if(n<=0)return e.map(()=>0);const o=e.map(e=>Math.max(0,e)/n*t),a=o.map(Math.floor);let i=t-a.reduce((e,t)=>e+t,0);const r=o.map((e,t)=>({i:t,rest:e-a[t]})).sort((e,t)=>t.rest-e.rest||e.i-t.i);for(const{i:s}of r){if(i<=0)break;a[s]+=1,i-=1}return a}function Wi(e,t){return t>0?e/t*(180/Math.PI):0}function Zi(e){const t=Math.max(0,e.end-e.start);return`--a:${(e.start-90).toFixed(3)}deg;--len:${t.toFixed(3)}`}function qi(e,t,n,o,a="primary"){return"bands"===o?Oe(e.band.color):"shades"===o?function(e,t,n){return n<=1||t<=0?e:`color-mix(in srgb, ${e} ${Math.round(100-Math.min(t,n-1)/(n-1)*65)}%, var(--card-background-color, #fff))`}(Oe(a),t,n):Oe(function(e){const t=e.config.color;return"string"==typeof t&&""!==t.trim()?t:void 0}(e)??Ao[t%Ao.length])}function Yi(e){const t=new Set(e.filter(e=>e.available).map(e=>e.unit));return 1===t.size?[...t][0]:""}function Gi(e,t,n,o=e.map(e=>e.available?e.value:0)){const a=e.map((e,t)=>({e:e,v:o[t]})).filter(({e:e})=>e.available);if(!a.length)return;const i=a.reduce((e,{v:t})=>e+t,0),r="total"===t?i:i/a.length,s=Math.max(...a.map(({e:e})=>e.decimals));return{value:r,text:Fo(r,s,n),unit:Yi(e)}}function Xi(e){return Ee(e.config)?0:Z}function Ji(e,t){const{show:n}=t.config;return!e.length||!n.name&&!n.value&&e.every(e=>void 0===e.share)?Z:R`
+ .icon=${Ya(e)} + >`:W} + ${t.name?R`${e.name}`:W} +
`:W} +
`;var l,c,d,h,u}};function Os(e){return e.map(e=>e.available&&e.value>0?e.value:0)}function Ns(e,t=100){const n=e.reduce((e,t)=>e+Math.max(0,t),0);if(n<=0)return e.map(()=>0);const o=e.map(e=>Math.max(0,e)/n*t),a=o.map(Math.floor);let r=t-a.reduce((e,t)=>e+t,0);const i=o.map((e,t)=>({i:t,rest:e-a[t]})).sort((e,t)=>t.rest-e.rest||e.i-t.i);for(const{i:s}of i){if(r<=0)break;a[s]+=1,r-=1}return a}function Us(e,t){return t>0?e/t*(180/Math.PI):0}function Fs(e){const t=Math.max(0,e.end-e.start);return`--a:${(e.start-90).toFixed(3)}deg;--len:${t.toFixed(3)}`}function Ps(e,t,n,o,a="primary"){return"bands"===o?ze(e.band.color):"shades"===o?function(e,t,n){return n<=1||t<=0?e:`color-mix(in srgb, ${e} ${Math.round(100-Math.min(t,n-1)/(n-1)*65)}%, var(--card-background-color, #fff))`}(ze(a),t,n):ze(function(e){const t=e.config.color;return"string"==typeof t&&""!==t.trim()?t:void 0}(e)??zo[t%zo.length])}function Ds(e){const t=new Set(e.filter(e=>e.available).map(e=>e.unit));return 1===t.size?[...t][0]:""}function Hs(e,t,n,o=e.map(e=>e.available?e.value:0)){const a=e.map((e,t)=>({e:e,v:o[t]})).filter(({e:e})=>e.available);if(!a.length)return;const r=a.reduce((e,{v:t})=>e+t,0),i="total"===t?r:r/a.length,s=Math.max(...a.map(({e:e})=>e.decimals));return{value:i,text:Io(i,s,n),unit:Ds(e)}}function Bs(e){return Se(e.config)?0:W}function Is(e,t){const{show:n}=t.config;return!e.length||!n.name&&!n.value&&e.every(e=>void 0===e.share)?W:R`
${e.map(e=>R`
- ${n.name?R`${e.entity.name}`:Z} - ${n.value?R`${e.entity.display}`:Z} - ${void 0===e.share?Z:R`${e.share}`} + ${n.name?R`${e.entity.name}`:W} + ${n.value?R`${e.entity.display}`:W} + ${void 0===e.share?W:R`${e.share}`}
`)} -
`}function Ki(e,t){return Ho(Fo(e,0,t),"%")}function Qi(e,t){return ot(e.hass,"parts_aria",{style:ot(e.hass,t),names:e.items.map(e=>e.name).join(", ")})}function er(e,t,n){return R``}function tr(e,t,n){const o=e[t];if(void 0!==o&&!n.includes(o))throw new Error(`"${t}" must be one of: ${n.join(", ")}`)}function nr(e,t,n,o){const a=e[t];if(void 0!==a&&("number"!=typeof a||!Number.isFinite(a)||ao))throw new Error(`"${t}" must be a number from ${n} to ${o}`)}function or(e,t){const n=e[t];if(void 0!==n&&"boolean"!=typeof n)throw new Error(`"${t}" must be true or false`)}function ar(e,t){const n=e[t];if(void 0!==n&&"string"!=typeof n)throw new Error(`"${t}" must be text`)}tt("en",{editor_color_by:"Colour",editor_color_by_series:"One colour per entity",editor_color_by_bands:"By colour band",editor_color_by_shades:"Shades of one colour",editor_shade_color:"Shade colour (palette name or CSS colour)",editor_show_percent:"Show each share as a percentage",editor_center:"In the middle",editor_center_total:"Total",editor_center_average:"Average",editor_center_none:"Nothing",parts_total:"Total",parts_average:"Average",parts_aria:"{style} of {names}"});const ir=i` + `}function Rs(e,t){return Ro(Io(e,0,t),"%")}function Vs(e,t){return at(e.hass,"parts_aria",{style:at(e.hass,t),names:e.items.map(e=>e.name).join(", ")})}function qs(e,t,n){return R``}function Ws(e,t,n){const o=e[t];if(void 0!==o&&!n.includes(o))throw new Error(`"${t}" must be one of: ${n.join(", ")}`)}function Zs(e,t,n,o){const a=e[t];if(void 0!==a&&("number"!=typeof a||!Number.isFinite(a)||ao))throw new Error(`"${t}" must be a number from ${n} to ${o}`)}function Ys(e,t){const n=e[t];if(void 0!==n&&"boolean"!=typeof n)throw new Error(`"${t}" must be true or false`)}function Gs(e,t){const n=e[t];if(void 0!==n&&"string"!=typeof n)throw new Error(`"${t}" must be text`)}nt("en",{editor_color_by:"Colour",editor_color_by_series:"One colour per entity",editor_color_by_bands:"By colour band",editor_color_by_shades:"Shades of one colour",editor_shade_color:"Shade colour (palette name or CSS colour)",editor_show_percent:"Show each share as a percentage",editor_center:"In the middle",editor_center_total:"Total",editor_center_average:"Average",editor_center_none:"Nothing",parts_total:"Total",parts_average:"Average",parts_aria:"{style} of {names}"});const Xs=r` .parts { display: flex; flex-direction: column; @@ -1100,26 +1414,26 @@ const Qe=me(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de font-weight: 500; white-space: nowrap; } -`;tt("en",{editor_style_pictograph:"Pictograph",editor_unit_icon:"Icon (each entity can have its own)",editor_per_icon:"Each icon stands for (empty: automatic)",editor_group_size:"Icons per group (0: no groups)",editor_partial:"Part-fill an icon for fractions"});const rr=["series","bands"];function sr(e,t,n,o){const{show:a}=n.config,i=function(e,t,n){const o=t-e;if(!(o>0))return{count:0,per:1};const a=void 0!==n&&n>0?n:o<=20?1:o/10,i=Math.ceil(o/a-1e-9);return i>100?{count:100,per:o/100}:{count:i,per:a}}(e.min,e.max,o.perIcon),r=e.available?function(e,t,n,o){const a=Math.min(n.count,Math.max(0,(e-t)/n.per)),i=o?a:Math.floor(a+1e-9);return Array.from({length:n.count},(e,t)=>Math.min(1,Math.max(0,i-t)))}(e.value,e.min,i,o.partial):i.count?Array(i.count).fill(0):[],s=qi(e,t,n.items.length,o.colorBy),l=e.config.icon??o.unitIcon??(e.stateObj?void 0:"mdi:checkbox-blank-circle"),c=i.count>20?20:28,d=e.available?R`${e.valueText}0))return{count:0,per:1};const a=void 0!==n&&n>0?n:o<=20?1:o/10,r=Math.ceil(o/a-1e-9);return r>100?{count:100,per:o/100}:{count:r,per:a}}(e.min,e.max,o.perIcon),i=e.available?function(e,t,n,o){const a=Math.min(n.count,Math.max(0,(e-t)/n.per)),r=o?a:Math.floor(a+1e-9);return Array.from({length:n.count},(e,t)=>Math.min(1,Math.max(0,r-t)))}(e.value,e.min,r,o.partial):r.count?Array(r.count).fill(0):[],s=Ps(e,t,n.items.length,o.colorBy),l=e.config.icon??o.unitIcon??(e.stateObj?void 0:"mdi:checkbox-blank-circle"),c=r.count>20?20:28,d=e.available?R`${e.valueText}/ ${Ho(Fo(e.max,Number.isInteger(e.max)?0:e.decimals,n.hass),e.unit)}/ ${Ro(Io(e.max,Number.isInteger(e.max)?0:e.decimals,n.hass),e.unit)}`:e.display;return R`
- ${a.name?R`${e.name}`:Z} + ${a.name?R`${e.name}`:W} - ${a.value?R`${d}`:Z} -
`}const lr=[{name:"unit_icon",path:["unit_icon"]},{name:"per_icon",path:["per_icon"]},{name:"group_size",path:["group_size"],default:5},{name:"color_by",path:["color_by"],default:"series"},{name:"partial",path:["partial"],default:!0}];const cr={layout:"chart",group:"totals",height:180,styles:i` + ${a.value?R`${d}`:W} + `}const Qs=[{name:"unit_icon",path:["unit_icon"]},{name:"per_icon",path:["per_icon"]},{name:"group_size",path:["group_size"],default:5},{name:"color_by",path:["color_by"],default:"series"},{name:"partial",path:["partial"],default:!0}];const el={layout:"chart",group:"totals",height:180,styles:r` .pictograph { display: flex; flex-direction: column; @@ -1205,12 +1519,12 @@ const Qe=me(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de color: var(--divider-color, rgba(127, 127, 127, 0.3)); } } - `,thumbnail:()=>I` - ${[0,1,2,3,4].map(e=>I` - `)}`,validate(e){ar(e,"unit_icon");const t=e.per_icon;if(void 0!==t&&("number"!=typeof t||!Number.isFinite(t)||t<=0))throw new Error('"per_icon" must be a number more than 0');if(nr(e,"group_size",0,50),void 0!==e.group_size&&!Number.isInteger(e.group_size))throw new Error('"group_size" must be a whole number');tr(e,"color_by",rr),or(e,"partial")},editor:function(e){const t=Aa("pictograph",e.hass,()=>[{name:"unit_icon",selector:{icon:{}}},{type:"grid",name:"",schema:[{name:"per_icon",selector:{number:{min:0,step:"any",mode:"box"}}},{name:"group_size",selector:{number:{min:0,max:50,mode:"box"}}},{name:"color_by",selector:ya(e.hass,"color_by",rr)}]},{name:"partial",selector:{boolean:{}}}]);return Ea(e,"chart",t,lr)},render:function(e){const t={perIcon:(n=e.raw).per_icon,unitIcon:n.unit_icon||void 0,groupSize:n.group_size??5,partial:!1!==n.partial,colorBy:n.color_by??"series"};var n;return R`
- ${e.items.map((n,o)=>sr(n,o,e,t))} -
`}};tt("en",{editor_style_pie:"Pie / donut",editor_shape:"Shape",editor_shape_donut:"Donut",editor_shape_pie:"Pie",editor_center_label:"Line under the total",editor_gap:"Space between slices",editor_rounded:"Rounded ends",editor_gradient:"Gradient fills"});const dr=["donut","pie"],hr=["total","none"],ur=["series","bands","shades"],pr=48;function mr(e,t){const n=function(e){const t=e.reduce((e,t)=>e+Math.max(0,t),0);return e.map(e=>t>0?Math.max(0,e)/t:0)}(e),o=n.filter(e=>e>0).length,a="donut"===t.shape?41.5:24;let i=0;return"donut"===t.shape&&(o>1&&(i+=Wi(t.gap,a)),t.rounded&&(i+=Wi(13,a))),function(e,t=0,n=360){const o=n-t;let a=t;return e.map(e=>{const t=a;return a+=e*o,{start:t,end:a}})}(n).map((e,t)=>n[t]>0?function(e,t){const n=e.end-e.start,o=Math.min(Math.max(0,t),n)/2;return{start:e.start+o,end:e.end-o}}(e,i):void 0)}const fr=[{name:"shape",path:["shape"],default:"donut"},{name:"color_by",path:["color_by"],default:"series"},{name:"center",path:["center"],default:"total"},{name:"center_label",path:["center_label"],default:""},{name:"gap",path:["gap"],default:2},{name:"shade_color",path:["shade_color"],default:""},{name:"rounded",path:["rounded"],default:!1},{name:"gradient",path:["gradient"],default:!1},{name:"show_percent",path:["show_percent"],default:!0}];const gr={layout:"chart",group:"totals",height:200,styles:i` - ${ir} + `,thumbnail:()=>V` + ${[0,1,2,3,4].map(e=>V` + `)}`,validate(e){Gs(e,"unit_icon");const t=e.per_icon;if(void 0!==t&&("number"!=typeof t||!Number.isFinite(t)||t<=0))throw new Error('"per_icon" must be a number more than 0');if(Zs(e,"group_size",0,50),void 0!==e.group_size&&!Number.isInteger(e.group_size))throw new Error('"group_size" must be a whole number');Ws(e,"color_by",Js),Ys(e,"partial")},editor:function(e){const t=Ta("pictograph",e.hass,()=>[{name:"unit_icon",selector:{icon:{}}},{type:"grid",name:"",schema:[{name:"per_icon",selector:{number:{min:0,step:"any",mode:"box"}}},{name:"group_size",selector:{number:{min:0,max:50,mode:"box"}}},{name:"color_by",selector:wa(e.hass,"color_by",Js)}]},{name:"partial",selector:{boolean:{}}}]);return ja(e,"chart",t,Qs)},render:function(e){const t={perIcon:(n=e.raw).per_icon,unitIcon:n.unit_icon||void 0,groupSize:n.group_size??5,partial:!1!==n.partial,colorBy:n.color_by??"series"};var n;return R`
+ ${e.items.map((n,o)=>Ks(n,o,e,t))} +
`}};nt("en",{editor_style_pie:"Pie / donut",editor_shape:"Shape",editor_shape_donut:"Donut",editor_shape_pie:"Pie",editor_center_label:"Line under the total",editor_gap:"Space between slices",editor_rounded:"Rounded ends",editor_gradient:"Gradient fills"});const tl=["donut","pie"],nl=["total","none"],ol=["series","bands","shades"],al=48;function rl(e,t){const n=function(e){const t=e.reduce((e,t)=>e+Math.max(0,t),0);return e.map(e=>t>0?Math.max(0,e)/t:0)}(e),o=n.filter(e=>e>0).length,a="donut"===t.shape?41.5:24;let r=0;return"donut"===t.shape&&(o>1&&(r+=Us(t.gap,a)),t.rounded&&(r+=Us(13,a))),function(e,t=0,n=360){const o=n-t;let a=t;return e.map(e=>{const t=a;return a+=e*o,{start:t,end:a}})}(n).map((e,t)=>n[t]>0?function(e,t){const n=e.end-e.start,o=Math.min(Math.max(0,t),n)/2;return{start:e.start+o,end:e.end-o}}(e,r):void 0)}const il=[{name:"shape",path:["shape"],default:"donut"},{name:"color_by",path:["color_by"],default:"series"},{name:"center",path:["center"],default:"total"},{name:"center_label",path:["center_label"],default:""},{name:"gap",path:["gap"],default:2},{name:"shade_color",path:["shade_color"],default:""},{name:"rounded",path:["rounded"],default:!1},{name:"gradient",path:["gradient"],default:!1},{name:"show_percent",path:["show_percent"],default:!0}];const sl={layout:"chart",group:"totals",height:200,styles:r` + ${Xs} .style-pie .track { stroke: var(--cd-track); } @@ -1227,17 +1541,17 @@ const Qe=me(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de :host([static]) .style-pie .separator { transition: none; } - `,thumbnail:()=>I` + `,thumbnail:()=>V` `,validate(e){tr(e,"shape",dr),tr(e,"center",hr),tr(e,"color_by",ur),nr(e,"gap",0,10),or(e,"rounded"),or(e,"gradient"),or(e,"show_percent"),ar(e,"shade_color"),ar(e,"center_label")},editor:function(e){const t=Aa("pie",e.hass,()=>[{type:"grid",name:"",schema:[{name:"shape",selector:ya(e.hass,"shape",dr)},{name:"color_by",selector:ya(e.hass,"color_by",ur)},{name:"center",selector:ya(e.hass,"center",hr)},{name:"center_label",selector:{text:{}}},{name:"gap",selector:{number:{min:0,max:10,step:.5,mode:"slider"}}},{name:"shade_color",selector:{text:{}}}]},{name:"rounded",selector:{boolean:{}}},{name:"gradient",selector:{boolean:{}}},{name:"show_percent",selector:{boolean:{}}}]);return Ea(e,"chart",t,fr)},render:function(e){const t={shape:(n=e.raw).shape??"donut",center:n.center??"total",colorBy:n.color_by??"series",shadeColor:n.shade_color||"primary",gap:n.gap??2,rounded:!0===n.rounded,gradient:!0===n.gradient,percent:!1!==n.show_percent,centerLabel:n.center_label??""};var n;const o=e.items,a=Ri(o),i=o.map((e,n)=>qi(e,n,o.length,t.colorBy,t.shadeColor)),r=mr(a,t),s="donut"===t.shape,l=s?41.5:24,c=s?13:pr,d=s?35/pr:0,h=r.filter(Boolean).length,u=t.gradient?I`${o.map((e,t)=>r[t]?function(e,t,n){return I` + pathLength="100" stroke-dasharray="22 78" stroke-dashoffset="-76" transform="rotate(-90 32 20)" />`,validate(e){Ws(e,"shape",tl),Ws(e,"center",nl),Ws(e,"color_by",ol),Zs(e,"gap",0,10),Ys(e,"rounded"),Ys(e,"gradient"),Ys(e,"show_percent"),Gs(e,"shade_color"),Gs(e,"center_label")},editor:function(e){const t=Ta("pie",e.hass,()=>[{type:"grid",name:"",schema:[{name:"shape",selector:wa(e.hass,"shape",tl)},{name:"color_by",selector:wa(e.hass,"color_by",ol)},{name:"center",selector:wa(e.hass,"center",nl)},{name:"center_label",selector:{text:{}}},{name:"gap",selector:{number:{min:0,max:10,step:.5,mode:"slider"}}},{name:"shade_color",selector:{text:{}}}]},{name:"rounded",selector:{boolean:{}}},{name:"gradient",selector:{boolean:{}}},{name:"show_percent",selector:{boolean:{}}}]);return ja(e,"chart",t,il)},render:function(e){const t={shape:(n=e.raw).shape??"donut",center:n.center??"total",colorBy:n.color_by??"series",shadeColor:n.shade_color||"primary",gap:n.gap??2,rounded:!0===n.rounded,gradient:!0===n.gradient,percent:!1!==n.show_percent,centerLabel:n.center_label??""};var n;const o=e.items,a=Os(o),r=o.map((e,n)=>Ps(e,n,o.length,t.colorBy,t.shadeColor)),i=rl(a,t),s="donut"===t.shape,l=s?41.5:24,c=s?13:al,d=s?35/al:0,h=i.filter(Boolean).length,u=t.gradient?V`${o.map((e,t)=>i[t]?function(e,t,n){return V` - `}(`cd-pie-${t}`,i[t],d):Z)}`:Z,p=o.map((e,n)=>{const o=r[n];if(!o)return Z;const a=t.gradient?`url(#cd-pie-${n})`:i[n];return I``}(`cd-pie-${t}`,r[t],d):W)}`:W,m=o.map((e,n)=>{const o=i[n];if(!o)return W;const a=t.gradient?`url(#cd-pie-${n})`:r[n];return V` @@ -1249,18 +1563,18 @@ const Qe=me(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de pathLength="360" stroke-width=${c} stroke-linecap=${s&&t.rounded?"round":"butt"} - style="${Zi(o)};stroke:${a}" + style="${Fs(o)};stroke:${a}" > - `}),m=!s&&t.gap>0&&h>1?r.map(e=>e?I``:Z):Z,f=0===h?I``:Z,g=Ii(a),y=o.map((n,o)=>({index:o,entity:n,color:i[o],share:t.percent&&a[o]>0?Ki(g[o],e.hass):void 0})),_=Gi(o,"total",e.hass,a),v=s&&"total"===t.center&&_?er(_.text,"",t.centerLabel||Yi(o)):Z;return R`
+ `}),p=!s&&t.gap>0&&h>1?i.map(e=>e?V``:W):W,f=0===h?V``:W,g=Ns(a),y=o.map((n,o)=>({index:o,entity:n,color:r[o],share:t.percent&&a[o]>0?Rs(g[o],e.hass):void 0})),v=Hs(o,"total",e.hass,a),_=s&&"total"===t.center&&v?qs(v.text,"",t.centerLabel||Ds(o)):W;return R`
- - ${u}${f}${p}${m} + + ${u}${f}${m}${p} - ${v} + ${_}
- ${Ji(y,e)} -
`}};let yr=0;const _r=new WeakMap;const vr=60,br=60,$r=e=>String(Math.round(1e3*e)/1e3+0);function xr(e){const[t,n]=ji(e,[0,1]),o=Ni(vr,br,46,t,n),a=e<360;return{layout:"tiles",group:"gauges",height:a?135:155,minColumnWidth:128,styles:i` + ${Is(y,e)} +
`}};let ll=0;const cl=new WeakMap;const dl=60,hl=60,ul=e=>String(Math.round(1e3*e)/1e3+0);function ml(e){const[t,n]=As(e,[0,1]),o=Ms(dl,hl,46,t,n),a=e<360;return{layout:"tiles",group:"gauges",height:a?135:155,minColumnWidth:128,styles:r` /* A definite width: the dial is a size container and can't size the tile itself. */ .radial { display: flex; @@ -1360,44 +1674,44 @@ const Qe=me(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de text-overflow: ellipsis; white-space: nowrap; } - `,render(t,{show:n,hass:i}){const r=function(e){let t=_r.get(e);return t||(t="cd-g-"+ ++yr,_r.set(e,t)),t}(t.config),s=t.span[1]-t.span[0]<=0,l=`stroke-dasharray: ${$r(100*(t.span[1]-t.span[0]))} 100; stroke-dashoffset: ${$r(100*-t.span[0])}`,c=(d=t.band.color,Object.prototype.hasOwnProperty.call(Ce,d)?Ce[d].fallback:d);var d;const h=function(e){return!e.includes("var(")&&("undefined"==typeof CSS||"function"!=typeof CSS.supports||CSS.supports("color",e))}(c)?`url(#${r}) ${c}`:`url(#${r})`,u=n.icon?6:0,p=(m=t.valueText,Math.min(22,Math.floor(124/Math.max(1,m.length))));var m;let f=Z;if(n.target&&null!==t.targetPosition&&t.available){const n=ji(e,[t.targetPosition,t.targetPosition])[0],[o,a]=Ti(vr,br,39.5,n),[i,r]=Ti(vr,br,52.5,n);f=I``}return R`
+ `,render(t,{show:n,hass:r}){const i=function(e){let t=cl.get(e);return t||(t="cd-g-"+ ++ll,cl.set(e,t)),t}(t.config),s=t.span[1]-t.span[0]<=0,l=`stroke-dasharray: ${ul(100*(t.span[1]-t.span[0]))} 100; stroke-dashoffset: ${ul(100*-t.span[0])}`,c=(d=t.band.color,Object.prototype.hasOwnProperty.call(Ce,d)?Ce[d].fallback:d);var d;const h=function(e){return!e.includes("var(")&&("undefined"==typeof CSS||"function"!=typeof CSS.supports||CSS.supports("color",e))}(c)?`url(#${i}) ${c}`:`url(#${i})`,u=n.icon?6:0,m=(p=t.valueText,Math.min(22,Math.floor(124/Math.max(1,p.length))));var p;let f=W;if(n.target&&null!==t.targetPosition&&t.available){const n=As(e,[t.targetPosition,t.targetPosition])[0],[o,a]=ks(dl,hl,39.5,n),[r,i]=ks(dl,hl,52.5,n);f=V``}return R`
${n.icon&&t.available?R``:Z} + .icon=${Ya(t)} + >`:W}
- ${n.name?R`${t.name}`:Z} -
`}}}const wr=xr(360),kr=xr(270);tt("en",{editor_style_rings:"Rings",editor_start_angle:"Start angle (0 is the top)",editor_end_angle:"End angle",editor_rounded_ends:"Rounded ends"});const Mr=["average","total","none"],Ar=["series","bands"];function Er(e){return{start:e.start_angle??0,end:e.end_angle??270,center:e.center??"average",colorBy:e.color_by??"series",rounded:!1!==e.rounded_ends}}const Sr=[{name:"start_angle",path:["start_angle"],default:0},{name:"end_angle",path:["end_angle"],default:270},{name:"center",path:["center"],default:"average"},{name:"color_by",path:["color_by"],default:"series"},{name:"rounded_ends",path:["rounded_ends"],default:!0}];const Cr={layout:"chart",group:"totals",height:200,styles:i` - ${ir} + ${n.name?R`${t.name}`:W} +
`}}}const pl=ml(360),fl=ml(270);nt("en",{editor_style_rings:"Rings",editor_start_angle:"Start angle (0 is the top)",editor_end_angle:"End angle",editor_rounded_ends:"Rounded ends"});const gl=["average","total","none"],yl=["series","bands"];function vl(e){return{start:e.start_angle??0,end:e.end_angle??270,center:e.center??"average",colorBy:e.color_by??"series",rounded:!1!==e.rounded_ends}}const _l=[{name:"start_angle",path:["start_angle"],default:0},{name:"end_angle",path:["end_angle"],default:270},{name:"center",path:["center"],default:"average"},{name:"color_by",path:["color_by"],default:"series"},{name:"rounded_ends",path:["rounded_ends"],default:!0}];const bl={layout:"chart",group:"totals",height:200,styles:r` + ${Xs} .style-rings .centre-box { position: absolute; inset: 0 0 auto 0; @@ -1415,7 +1729,7 @@ const Qe=me(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de transform-box: view-box; transform-origin: 50px 50px; } - `,thumbnail:()=>I` + `,thumbnail:()=>V` `,validate(e){nr(e,"start_angle",-360,360),nr(e,"end_angle",-360,360);const{start:t,end:n}=Er(e);if(n<=t||n-t>360)throw new Error('"end_angle" must be after "start_angle", and at most 360 degrees after it');tr(e,"center",Mr),tr(e,"color_by",Ar),or(e,"rounded_ends")},editor:function(e){const t=Aa("rings",e.hass,()=>[{type:"grid",name:"",schema:[{name:"start_angle",selector:{number:{min:-360,max:360,step:5,mode:"box"}}},{name:"end_angle",selector:{number:{min:-360,max:360,step:5,mode:"box"}}},{name:"center",selector:ya(e.hass,"center",Mr)},{name:"color_by",selector:ya(e.hass,"color_by",Ar)}]},{name:"rounded_ends",selector:{boolean:{}}}]);return Ea(e,"chart",t,Sr)},render:function(e){const t=Er(e.raw),n=e.items,{radii:o,width:a}=function(e){if(e<=0)return{radii:[],width:0};const t=Math.min(9,28/(1.33*e)),n=1.33*t;return{radii:Array.from({length:e},(e,o)=>48-t/2-o*n),width:t}}(n.length),i=n.map((e,o)=>qi(e,o,n.length,t.colorBy)),r=t.rounded?"round":"butt",s=Zi({start:t.start,end:t.end}),l=n.map((e,n)=>{const l=e.available?I``:Z;return I``,validate(e){Zs(e,"start_angle",-360,360),Zs(e,"end_angle",-360,360);const{start:t,end:n}=vl(e);if(n<=t||n-t>360)throw new Error('"end_angle" must be after "start_angle", and at most 360 degrees after it');Ws(e,"center",gl),Ws(e,"color_by",yl),Ys(e,"rounded_ends")},editor:function(e){const t=Ta("rings",e.hass,()=>[{type:"grid",name:"",schema:[{name:"start_angle",selector:{number:{min:-360,max:360,step:5,mode:"box"}}},{name:"end_angle",selector:{number:{min:-360,max:360,step:5,mode:"box"}}},{name:"center",selector:wa(e.hass,"center",gl)},{name:"color_by",selector:wa(e.hass,"color_by",yl)}]},{name:"rounded_ends",selector:{boolean:{}}}]);return ja(e,"chart",t,_l)},render:function(e){const t=vl(e.raw),n=e.items,{radii:o,width:a}=function(e){if(e<=0)return{radii:[],width:0};const t=Math.min(9,28/(1.33*e)),n=1.33*t;return{radii:Array.from({length:e},(e,o)=>48-t/2-o*n),width:t}}(n.length),r=n.map((e,o)=>Ps(e,o,n.length,t.colorBy)),i=t.rounded?"round":"butt",s=Fs({start:t.start,end:t.end}),l=n.map((e,n)=>{const l=e.available?V``:W;return V` + stroke-linecap=${i} style=${s}> ${l} - `}),c="none"===t.center?void 0:Gi(n,t.center,e.hass),d=c?er(c.text,c.unit,ot(e.hass,`parts_${t.center}`)):Z,h=n.map((e,t)=>({index:t,entity:e,color:i[t]})),u=function(e,t){const n=e=>-Math.cos(e*Math.PI/180),o=360*Math.ceil((e-180)/360)+180<=t?1:Math.max(n(e),n(t));return Math.min(100,Math.max(66,50+48*o+6))}(t.start,t.end);return R`
+ `}),c="none"===t.center?void 0:Hs(n,t.center,e.hass),d=c?qs(c.text,c.unit,at(e.hass,`parts_${t.center}`)):W,h=n.map((e,t)=>({index:t,entity:e,color:r[t]})),u=function(e,t){const n=e=>-Math.cos(e*Math.PI/180),o=360*Math.ceil((e-180)/360)+180<=t?1:Math.max(n(e),n(t));return Math.min(100,Math.max(66,50+48*o+6))}(t.start,t.end);return R`
- + ${l}
${d}
- ${Ji(h,e)} -
`}};function Or(e){const[t,n]=e;return Array.from({length:10},(e,o)=>{const a=(o+.5)/10;return a>t&&a<=n})}const Lr={layout:"rows",group:"bars",height:50,minColumnWidth:180,styles:i` + ${Is(h,e)} +
`}},xl={layout:"chart",group:"distribution",height:240,styles:Wr,thumbnail:()=>V` + + + + + + + + `,validate:e=>Br(e,"scatter"),editor:Jr("scatter"),entityFields:()=>ei(),disconnect:Zr,render:e=>Vr(e,"scatter")};function $l(e){const[t,n]=e;return Array.from({length:10},(e,o)=>{const a=(o+.5)/10;return a>t&&a<=n})}const wl={layout:"rows",group:"bars",height:50,minColumnWidth:180,styles:r` /* Each row is measured on its own, so a narrow column (columns: 2 or 3) switches layout even when the card as a whole is wide. A container can't restyle itself, hence .seg. */ .style-segmented .entity { @@ -1556,24 +1878,24 @@ const Qe=me(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de padding-bottom: 0; } } - `,render(e,{show:t,hass:n}){const o=e.available?Or(e.span):Or([0,0]),a=t.target&&e.available&&null!==e.targetPosition?Math.min(9,Math.max(0,Math.floor(10*e.targetPosition))):-1,i=!e.available;return R`
+ `,render(e,{show:t,hass:n}){const o=e.available?$l(e.span):$l([0,0]),a=t.target&&e.available&&null!==e.targetPosition?Math.min(9,Math.max(0,Math.floor(10*e.targetPosition))):-1,r=!e.available;return R`
${t.icon?R``:Z} + .icon=${Ya(e)} + >`:W}
- ${t.name||i?R`
- ${t.name?R`${e.name}`:Z} - ${i?R`${e.display}`:Z} -
`:Z} + ${t.name||r?R`
+ ${t.name?R`${e.name}`:W} + ${r?R`${e.display}`:W} +
`:W}
${o.map((e,t)=>R``)}
- ${t.value?R`${e.available?e.display:""}`:Z} -
`}},zr={layout:"tiles",group:"bars",height:230,tilesPerRow:4,minColumnWidth:80,styles:i` + ${t.value?R`${e.available?e.display:""}`:W} +
`}},kl={layout:"tiles",group:"bars",height:230,tilesPerRow:4,minColumnWidth:80,styles:r` .style-tank .entity { display: flex; flex-direction: column; @@ -1640,15 +1962,15 @@ const Qe=me(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de class="icon" .hass=${n} .stateObj=${e.stateObj} - .icon=${Va(e)} - >`:Z} - ${t.value||!e.available?R`${e.display}`:Z} + .icon=${Ya(e)} + >`:W} + ${t.value||!e.available?R`${e.display}`:W}
- ${t.target&&null!==e.targetPosition&&e.available?R`
`:Z} + ${t.target&&null!==e.targetPosition&&e.available?R`
`:W}
- ${t.name?R`${e.name}`:Z} - `},Tr={layout:"rows",group:"bars",height:44,minColumnWidth:140,styles:i` + ${t.name?R`${e.name}`:W} + `},Ml={layout:"rows",group:"bars",height:44,minColumnWidth:140,styles:r` .style-thin .entity { display: flex; align-items: center; @@ -1719,19 +2041,19 @@ const Qe=me(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de class="icon" .hass=${n} .stateObj=${e.stateObj} - .icon=${Va(e)} - >`:Z} + .icon=${Ya(e)} + >`:W}
${t.name||o?R`
- ${t.name?R`${e.name}`:Z} - ${o?R`${e.display}`:Z} -
`:Z} + ${t.name?R`${e.name}`:W} + ${o?R`${e.display}`:W} +
`:W}
- ${t.target&&null!==e.targetPosition&&e.available?R`
`:Z} + ${t.target&&null!==e.targetPosition&&e.available?R`
`:W}
- `}};tt("en",{editor_style_waffle:"Waffle",editor_waffles:"Waffles",editor_waffles_one:"One, split between the entities",editor_waffles_each:"One per entity, side by side",editor_headline:"Show the first share in large type"});const Nr=["one","each"],jr=["series","bands"];function Pr(e){if(1===e.length){const t=e[0];return t.available?[Ii([t.position,1-t.position])[0]]:[0]}return Ii(Ri(e),100)}function Ur(e,t){const{col:n,row:o}=function(e){return{col:e%10,row:9-Math.floor(e/10)}}(e);return I``}function Fr(e,t,n,o){let a=0;const i=e.map((e,o)=>{const i=a;if(a+=e,!e)return Z;const r=n[o];return I` - ${Array.from({length:e},(e,n)=>Ur(i+n,t[o]))} - `}),r=Array.from({length:100-a},(e,t)=>Ur(a+t,void 0));return R` - ${i} - ${r} - `}const Hr=[{name:"waffles",path:["waffles"],default:"one"},{name:"color_by",path:["color_by"],default:"series"},{name:"headline",path:["headline"],default:!0},{name:"show_percent",path:["show_percent"],default:!0}];const Dr={layout:"chart",group:"totals",height:210,styles:i` - ${ir} + >`}function zl(e,t,n,o){let a=0;const r=e.map((e,o)=>{const r=a;if(a+=e,!e)return W;const i=n[o];return V` + ${Array.from({length:e},(e,n)=>Cl(r+n,t[o]))} + `}),i=Array.from({length:100-a},(e,t)=>Cl(a+t,void 0));return R` + ${r} + ${i} + `}const Ll=[{name:"waffles",path:["waffles"],default:"one"},{name:"color_by",path:["color_by"],default:"series"},{name:"headline",path:["headline"],default:!0},{name:"show_percent",path:["show_percent"],default:!0}];const Tl={layout:"chart",group:"totals",height:210,styles:r` + ${Xs} .waffle-box { flex: none; width: 180px; @@ -1832,31 +2154,31 @@ const Qe=me(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de color: var(--secondary-text-color); font-size: 0.75em; } - `,thumbnail:()=>I` - ${Array.from({length:25},(e,t)=>{const n=t%5,o=4-Math.floor(t/5);return I``})}`,validate(e){tr(e,"waffles",Nr),tr(e,"color_by",jr),or(e,"headline"),or(e,"show_percent")},editor:function(e){const t=Aa("waffle",e.hass,()=>[{type:"grid",name:"",schema:[{name:"waffles",selector:ya(e.hass,"waffles",Nr)},{name:"color_by",selector:ya(e.hass,"color_by",jr)}]},{name:"headline",selector:{boolean:{}}},{name:"show_percent",selector:{boolean:{}}}]);return Ea(e,"chart",t,Hr)},render(e){const t={waffles:(n=e.raw).waffles??"one",colorBy:n.color_by??"series",headline:!1!==n.headline,percent:!1!==n.show_percent};var n;return"each"===t.waffles?function(e,t){const{show:n}=e.config;return R`
- ${e.items.map((o,a)=>{const i=qi(o,a,e.items.length,t.colorBy),[r]=Pr([o]),s=Ki(r,e.hass),l=n.value&&t.percent&&o.available&&o.display!==s;return R`
V` + ${Array.from({length:25},(e,t)=>{const n=t%5,o=4-Math.floor(t/5);return V``})}`,validate(e){Ws(e,"waffles",Al),Ws(e,"color_by",Sl),Ys(e,"headline"),Ys(e,"show_percent")},editor:function(e){const t=Ta("waffle",e.hass,()=>[{type:"grid",name:"",schema:[{name:"waffles",selector:wa(e.hass,"waffles",Al)},{name:"color_by",selector:wa(e.hass,"color_by",Sl)}]},{name:"headline",selector:{boolean:{}}},{name:"show_percent",selector:{boolean:{}}}]);return ja(e,"chart",t,Ll)},render(e){const t={waffles:(n=e.raw).waffles??"one",colorBy:n.color_by??"series",headline:!1!==n.headline,percent:!1!==n.show_percent};var n;return"each"===t.waffles?function(e,t){const{show:n}=e.config;return R`
+ ${e.items.map((o,a)=>{const r=Ps(o,a,e.items.length,t.colorBy),[i]=El([o]),s=Rs(i,e.hass),l=n.value&&t.percent&&o.available&&o.display!==s;return R`
- ${Fr([r],[i],[o],o.label)} - ${n.name?R`${o.name}`:Z} + ${zl([i],[r],[o],o.label)} + ${n.name?R`${o.name}`:W} ${o.available&&t.percent?s:o.display} - ${l?R`${o.display}`:Z} + ${l?R`${o.display}`:W}
`})} -
`}(e,t):function(e,t){const n=e.items,o=n.map((e,o)=>qi(e,o,n.length,t.colorBy)),a=Pr(n),i=e=>a[e]>0||1===n.length&&n[e].available,r=n.map((r,s)=>({index:s,entity:r,color:o[s],share:!t.percent||!i(s)||t.headline&&1===n.length?void 0:Ki(a[s],e.hass)})),s=n[0],l=t.headline&&s&&i(0)?R`
- ${Ki(a[0],e.hass)} +
`}(e,t):function(e,t){const n=e.items,o=n.map((e,o)=>Ps(e,o,n.length,t.colorBy)),a=El(n),r=e=>a[e]>0||1===n.length&&n[e].available,i=n.map((i,s)=>({index:s,entity:i,color:o[s],share:!t.percent||!r(s)||t.headline&&1===n.length?void 0:Rs(a[s],e.hass)})),s=n[0],l=t.headline&&s&&r(0)?R`
+ ${Rs(a[0],e.hass)} ${s.name} -
`:Z;return R`
-
${Fr(a,o,n,Qi(e,"editor_style_waffle"))}
-
${l}${Ji(r,e)}
-
`}(e,t)}},Br=["bars","gauges","history","totals","compare","distribution"];function Vr(e){return"chart"===e.layout}const Rr={bar:Ga,thin:Tr,segmented:Lr,tank:zr,ring:wr,needle:Vi,arc:kr,line:xi,column:_i,area:Fa,combined:Li,pie:gr,rings:Cr,pictograph:cr,waffle:Dr},Ir="important",Wr=" !"+Ir,Zr=me(class extends fe{constructor(e){if(super(e),e.type!==de||"style"!==e.name||e.strings?.length>2)throw Error("The `styleMap` directive must be used in the `style` attribute and must be the only part in the attribute.")}render(e){return Object.keys(e).reduce((t,n)=>{const o=e[n];return null==o?t:t+`${n=n.includes("-")?n:n.replace(/(?:^(webkit|moz|ms|o)|)(?=[A-Z])/g,"-$&").toLowerCase()}:${o};`},"")}update(e,[t]){const{style:n}=e.element;if(void 0===this.ft)return this.ft=new Set(Object.keys(t)),this.render(t);for(const o of this.ft)null==t[o]&&(this.ft.delete(o),o.includes("-")?n.removeProperty(o):n[o]=null);for(const o in t){const e=t[o];if(null!=e){this.ft.add(o);const t="string"==typeof e&&e.endsWith(Wr);o.includes("-")||t?n.setProperty(o,t?e.slice(0,-11):e,t?Ir:""):n[o]=e}}return W}}),qr=Object.keys(Ne),Yr=Object.keys(Ce),Gr="custom",Xr={text:{}},Jr={entity:{domain:["sensor","input_number","number"]}}; +
`:W;return R`
+
${zl(a,o,n,Vs(e,"editor_style_waffle"))}
+
${l}${Is(i,e)}
+
`}(e,t)}},jl=["bars","gauges","history","totals","compare","distribution"];function Ol(e){return"chart"===e.layout}const Nl={bar:tr,thin:Ml,segmented:wl,tank:kl,ring:pl,needle:js,arc:fl,line:Ei,column:ki,area:Va,combined:Fi,pie:sl,rings:bl,pictograph:el,waffle:Tl,histogram:vs,scatter:xl,bubble:ti,heatmap:rs,box:Cr},Ul="important",Fl=" !"+Ul,Pl=pe(class extends fe{constructor(e){if(super(e),e.type!==de||"style"!==e.name||e.strings?.length>2)throw Error("The `styleMap` directive must be used in the `style` attribute and must be the only part in the attribute.")}render(e){return Object.keys(e).reduce((t,n)=>{const o=e[n];return null==o?t:t+`${n=n.includes("-")?n:n.replace(/(?:^(webkit|moz|ms|o)|)(?=[A-Z])/g,"-$&").toLowerCase()}:${o};`},"")}update(e,[t]){const{style:n}=e.element;if(void 0===this.ft)return this.ft=new Set(Object.keys(t)),this.render(t);for(const o of this.ft)null==t[o]&&(this.ft.delete(o),o.includes("-")?n.removeProperty(o):n[o]=null);for(const o in t){const e=t[o];if(null!=e){this.ft.add(o);const t="string"==typeof e&&e.endsWith(Fl);o.includes("-")||t?n.setProperty(o,t?e.slice(0,-11):e,t?Ul:""):n[o]=e}}return q}}),Dl=Object.keys(Ne),Hl=Object.keys(Ce),Bl="custom",Il={text:{}},Rl={entity:{domain:["sensor","input_number","number"]}}; /** * @license * Copyright 2018 Google LLC * SPDX-License-Identifier: BSD-3-Clause - */function Kr(e){return"string"==typeof e&&""!==e.trim()&&!Number.isFinite(Number(e))}const Qr={icon:{}};function es(e){return void 0===e?"none":qr.find(t=>Ne[t]&&Dt(Ne[t],e))??"custom"}function ts(e){return Yr.includes(e)}class ns extends le{constructor(){super(),this._customOpen=new Set,this._entityOpen=new Set}willUpdate(e){e.has("bands")&&(this._customOpen.size&&(this._customOpen=new Set),this._entityOpen.size&&(this._entityOpen=new Set))}_entityValue(e){const t=this.hass?.states[e]?.state,n=void 0===t||""===t.trim()?NaN:Number(t);return Number.isFinite(n)?n:void 0}_t(e){return ot(this.hass,e)}_getSelectors(){const e=`${nt(this.hass)}|${this.unit??""}`;if(this._selectors?.key===e)return this._selectors;const t=qr.map(e=>({value:e,label:this._t(`editor_preset_${e}`)})),n=e=>({select:{mode:"dropdown",options:e}});return this._selectors={key:e,preset:n(t),presetCustom:n([...t,{value:Gr,label:this._t("editor_preset_custom")}]),color:n([...Yr,Gr].map(e=>({value:e,label:this._t(`editor_color_${e}`)}))),from:{number:{mode:"box",step:"any",...this.unit?{unit_of_measurement:this.unit}:{}}}},this._selectors}_emit(e){this.dispatchEvent(new CustomEvent("value-changed",{detail:{value:e&&e.length?e:void 0}}))}_presetChanged(e){e.stopPropagation();const t=e.detail?.value;if(!t||t===Gr||!qr.includes(t))return;if(t===es(this.bands))return;const n=Ne[t];this._emit(n?structuredClone(n):void 0)}_updateBand(e,t,n){const o=[...this.bands??[]],a={...o[e]};null==n||""===n?delete a[t]:a[t]=n,o[e]=a,this._emit(o)}_fromChanged(e,t){t.stopPropagation();const n=Number(t.detail?.value);""!==t.detail?.value&&null!=t.detail?.value&&Number.isFinite(n)&&this._updateBand(e,"from",n)}_fromEntityChanged(e,t){t.stopPropagation();const n=t.detail?.value;"string"==typeof n&&""!==n&&this._updateBand(e,"from",n)}_toggleFromMode(e){const t=this.bands?.[e]?.from,n=new Set(this._entityOpen);Kr(t)?this._updateBand(e,"from",this._entityValue(t)??0):n.delete(e)||n.add(e),this._entityOpen=n}_colorChanged(e,t){t.stopPropagation();const n=t.detail?.value,o=this.bands?.[e]?.color??"primary";if(!n)return;const a=new Set(this._customOpen);n!==Gr?(a.delete(e),this._customOpen=a,n!==o&&this._updateBand(e,"color",n)):ts(o)&&(this._customOpen=a.add(e))}_customColorChanged(e,t){t.stopPropagation();const n="string"==typeof t.detail?.value?t.detail.value.trim():"";if(!n)return;const o=new Set(this._customOpen);o.delete(e),this._customOpen=o,this._updateBand(e,"color",n)}_iconChanged(e,t){t.stopPropagation(),this._updateBand(e,"icon",t.detail?.value)}_labelChanged(e,t){t.stopPropagation(),this._updateBand(e,"label",t.detail?.value)}_removeBand(e){const t=[...this.bands??[]];t.splice(e,1),this._emit(t)}_addBand(){const e=[...this.bands??[]],t=e[e.length-1]?.from,n="number"==typeof t?t:Kr(t)?this._entityValue(t)??NaN:"string"==typeof t&&""!==t.trim()?Number(t):NaN;e.push({from:Number.isFinite(n)?n+10:0,color:"primary"}),this._emit(e)}_renderBand(e,t,n){const o=!ts(e.color)||this._customOpen.has(t),a=Kr(e.from)?e.from:void 0,i=void 0!==a||this._entityOpen.has(t);return R`
- + */function Vl(e){return"string"==typeof e&&""!==e.trim()&&!Number.isFinite(Number(e))}const ql={icon:{}};function Wl(e){return void 0===e?"none":Dl.find(t=>Ne[t]&&Bt(Ne[t],e))??"custom"}function Zl(e){return Hl.includes(e)}class Yl extends le{constructor(){super(),this._customOpen=new Set,this._entityOpen=new Set}willUpdate(e){e.has("bands")&&(this._customOpen.size&&(this._customOpen=new Set),this._entityOpen.size&&(this._entityOpen=new Set))}_entityValue(e){const t=this.hass?.states[e]?.state,n=void 0===t||""===t.trim()?NaN:Number(t);return Number.isFinite(n)?n:void 0}_t(e){return at(this.hass,e)}_getSelectors(){const e=`${ot(this.hass)}|${this.unit??""}`;if(this._selectors?.key===e)return this._selectors;const t=Dl.map(e=>({value:e,label:this._t(`editor_preset_${e}`)})),n=e=>({select:{mode:"dropdown",options:e}});return this._selectors={key:e,preset:n(t),presetCustom:n([...t,{value:Bl,label:this._t("editor_preset_custom")}]),color:n([...Hl,Bl].map(e=>({value:e,label:this._t(`editor_color_${e}`)}))),from:{number:{mode:"box",step:"any",...this.unit?{unit_of_measurement:this.unit}:{}}}},this._selectors}_emit(e){this.dispatchEvent(new CustomEvent("value-changed",{detail:{value:e&&e.length?e:void 0}}))}_presetChanged(e){e.stopPropagation();const t=e.detail?.value;if(!t||t===Bl||!Dl.includes(t))return;if(t===Wl(this.bands))return;const n=Ne[t];this._emit(n?structuredClone(n):void 0)}_updateBand(e,t,n){const o=[...this.bands??[]],a={...o[e]};null==n||""===n?delete a[t]:a[t]=n,o[e]=a,this._emit(o)}_fromChanged(e,t){t.stopPropagation();const n=Number(t.detail?.value);""!==t.detail?.value&&null!=t.detail?.value&&Number.isFinite(n)&&this._updateBand(e,"from",n)}_fromEntityChanged(e,t){t.stopPropagation();const n=t.detail?.value;"string"==typeof n&&""!==n&&this._updateBand(e,"from",n)}_toggleFromMode(e){const t=this.bands?.[e]?.from,n=new Set(this._entityOpen);Vl(t)?this._updateBand(e,"from",this._entityValue(t)??0):n.delete(e)||n.add(e),this._entityOpen=n}_colorChanged(e,t){t.stopPropagation();const n=t.detail?.value,o=this.bands?.[e]?.color??"primary";if(!n)return;const a=new Set(this._customOpen);n!==Bl?(a.delete(e),this._customOpen=a,n!==o&&this._updateBand(e,"color",n)):Zl(o)&&(this._customOpen=a.add(e))}_customColorChanged(e,t){t.stopPropagation();const n="string"==typeof t.detail?.value?t.detail.value.trim():"";if(!n)return;const o=new Set(this._customOpen);o.delete(e),this._customOpen=o,this._updateBand(e,"color",n)}_iconChanged(e,t){t.stopPropagation(),this._updateBand(e,"icon",t.detail?.value)}_labelChanged(e,t){t.stopPropagation(),this._updateBand(e,"label",t.detail?.value)}_removeBand(e){const t=[...this.bands??[]];t.splice(e,1),this._emit(t)}_addBand(){const e=[...this.bands??[]],t=e[e.length-1]?.from,n="number"==typeof t?t:Vl(t)?this._entityValue(t)??NaN:"string"==typeof t&&""!==t.trim()?Number(t):NaN;e.push({from:Number.isFinite(n)?n+10:0,color:"primary"}),this._emit(e)}_renderBand(e,t,n){const o=!Zl(e.color)||this._customOpen.has(t),a=Vl(e.from)?e.from:void 0,r=void 0!==a||this._entityOpen.has(t);return R`
+ this._fromChanged(t,e)} > this._colorChanged(t,e)} > this._labelChanged(t,e)} > @@ -1890,15 +2212,15 @@ const Qe=me(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de data-role="icon" .hass=${this.hass} .label=${this._t("editor_band_icon")} - .selector=${Qr} + .selector=${ql} .value=${e.icon??""} @value-changed=${e=>this._iconChanged(t,e)} > this._toggleFromMode(t)} > this._removeBand(t)} > - ${i?R`this._fromEntityChanged(t,e)} - >`:Z} + >`:W} ${o?R`this._customColorChanged(t,e)} - >`:Z} -
`}render(){const e=this._getSelectors(),t=es(this.bands);return R``:W} +
`}render(){const e=this._getSelectors(),t=Wl(this.bands);return R` ${(this.bands??[]).map((t,n)=>this._renderBand(t,n,e))} - `}}ns.properties={hass:{attribute:!1},bands:{attribute:!1},unit:{attribute:!1},_customOpen:{state:!0},_entityOpen:{state:!0}},ns.styles=i` + `}}Yl.properties={hass:{attribute:!1},bands:{attribute:!1},unit:{attribute:!1},_customOpen:{state:!0},_entityOpen:{state:!0}},Yl.styles=r` :host { display: block; } @@ -2004,7 +2326,7 @@ const Qe=me(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de outline: 2px solid var(--primary-color, #03a9f4); outline-offset: 2px; } - `,customElements.get("cd-bands-editor")||customElements.define("cd-bands-editor",ns);const os={number:{mode:"box",step:"any"}},as={entity:{domain:["sensor","input_number","number"]}};function is(e){return null==e||""===e?"default":"number"==typeof e||""!==e.trim()&&Number.isFinite(Number(e))?"number":"entity"}class rs extends le{willUpdate(e){e.has("value")&&"default"!==is(this.value)&&(this._mode=void 0)}_modeSelectorFor(){const e=(e,t)=>ot(this.hass,e,t),t=[{value:"default",label:e("editor_limit_default",{value:this.defaultLabel??""})},{value:"number",label:e("editor_limit_number")},{value:"entity",label:e("editor_limit_entity")}],n=t.map(e=>e.label).join("|");return this._modeSelector?.key!==n&&(this._modeSelector={key:n,selector:{select:{mode:"dropdown",options:t}}}),this._modeSelector.selector}get _currentMode(){return this._mode??is(this.value)}_emit(e){this.dispatchEvent(new CustomEvent("value-changed",{detail:{value:e}}))}_modeChanged(e){e.stopPropagation();const t=e.detail?.value;t&&t!==this._currentMode&&(this._mode=t,void 0!==this.value&&this._emit(void 0))}_numberChanged(e){e.stopPropagation();const t=e.detail?.value,n="number"==typeof t?t:""===t||null==t?NaN:Number(t);this._mode="number",this._emit(Number.isFinite(n)?n:void 0)}_entityChanged(e){e.stopPropagation();const t=e.detail?.value;this._mode="entity",this._emit("string"==typeof t&&""!==t?t:void 0)}render(){const e=e=>ot(this.hass,e),t=this._currentMode;return R`at(this.hass,e,t),t=[{value:"default",label:e("editor_limit_default",{value:this.defaultLabel??""})},{value:"number",label:e("editor_limit_number")},{value:"entity",label:e("editor_limit_entity")}],n=t.map(e=>e.label).join("|");return this._modeSelector?.key!==n&&(this._modeSelector={key:n,selector:{select:{mode:"dropdown",options:t}}}),this._modeSelector.selector}get _currentMode(){return this._mode??Jl(this.value)}_emit(e){this.dispatchEvent(new CustomEvent("value-changed",{detail:{value:e}}))}_modeChanged(e){e.stopPropagation();const t=e.detail?.value;t&&t!==this._currentMode&&(this._mode=t,void 0!==this.value&&this._emit(void 0))}_numberChanged(e){e.stopPropagation();const t=e.detail?.value,n="number"==typeof t?t:""===t||null==t?NaN:Number(t);this._mode="number",this._emit(Number.isFinite(n)?n:void 0)}_entityChanged(e){e.stopPropagation();const t=e.detail?.value;this._mode="entity",this._emit("string"==typeof t&&""!==t?t:void 0)}render(){const e=e=>at(this.hass,e),t=this._currentMode;return R``:"entity"===t?R``:Z}`}}rs.properties={hass:{attribute:!1},label:{attribute:!1},value:{attribute:!1},defaultLabel:{attribute:!1},_mode:{state:!0}},rs.styles=i` + >`:W}`}}Kl.properties={hass:{attribute:!1},label:{attribute:!1},value:{attribute:!1},defaultLabel:{attribute:!1},_mode:{state:!0}},Kl.styles=r` :host { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 8px; align-items: start; } - `,customElements.get("cd-limit-field")||customElements.define("cd-limit-field",rs);const ss=["min","max","target"];function ls(e,t,n){const o=t&&!n?e?.states[t]?.attributes?.unit_of_measurement:void 0;return"string"==typeof o&&o?o:void 0}class cs extends le{constructor(){super(),this._computeLabel=e=>this._t(`editor_${e.name}`),this._expanded=new Set}_t(e,t){return ot(this.hass,e,t)}get _asConfig(){return{type:"",entities:this.entities??[]}}_emit(e){e.entities!==this.entities&&this.dispatchEvent(new CustomEvent("value-changed",{detail:{value:e.entities??[]}}))}_set(e,t,n){this._emit(function(e,t,n,o){if("entity"===n&&(at(o)||"string"!=typeof o))return e;const a=[...e.entities??[]],i=a[t];if(void 0===i)return e;const r="string"==typeof i?{entity:i}:{...i};at(o)?delete r[n]:r[n]=o;const s=Object.keys(r);return a[t]=1===s.length&&"entity"===s[0]&&"string"==typeof r.entity?r.entity:r,{...e,entities:a}}(this._asConfig,e,t,n))}_move(e,t){const n=new Set;for(const o of this._expanded)n.add(o===e?t:o===t?e:o);this._expanded=n,this._emit(function(e,t,n){const o=[...e.entities??[]];if(t<0||t>=o.length||t===n)return e;const[a]=o.splice(t,1);return o.splice(Math.max(0,Math.min(n,o.length)),0,a),{...e,entities:o}}(this._asConfig,e,t))}_remove(e){const t=new Set;for(const n of this._expanded)n!==e&&t.add(n>e?n-1:n);this._expanded=t,this._emit(function(e,t){const n=[...e.entities??[]];return t<0||t>=n.length?e:(n.splice(t,1),{...e,entities:n})}(this._asConfig,e))}_toggle(e){const t=new Set(this._expanded);t.delete(e)||t.add(e),this._expanded=t}_add(e){e.stopPropagation();const t=e.detail?.value;"string"==typeof t&&""!==t&&(e.target.value="",this._emit(function(e,t){return{...e,entities:[...e.entities??[],t]}}(this._asConfig,t)))}_formChanged(e,t,n){n.stopPropagation();const o=this.entities?.[e];if(void 0===o)return;const a="string"==typeof o?{entity:o}:o,i=Vt(a,t,n.detail?.value??{});if(i===a)return;const r=[...this.entities??[]],s=Object.keys(i);r[e]=1===s.length&&"entity"===s[0]?i.entity:i,this._emit({type:"",entities:r})}_rowLabel(e){const t=this.hass?.states[e];return t?{text:t.attributes.friendly_name||e,missing:!1}:this.hass?{text:this._t("editor_not_found",{entity:e}),missing:!0}:{text:e,missing:!1}}_form(e,t,n,o,a){return R`this._t(`editor_${e.name}`),this._expanded=new Set}_t(e,t){return at(this.hass,e,t)}get _asConfig(){return{type:"",entities:this.entities??[]}}_emit(e){e.entities!==this.entities&&this.dispatchEvent(new CustomEvent("value-changed",{detail:{value:e.entities??[]}}))}_set(e,t,n){this._emit(function(e,t,n,o){if("entity"===n&&(rt(o)||"string"!=typeof o))return e;const a=[...e.entities??[]],r=a[t];if(void 0===r)return e;const i="string"==typeof r?{entity:r}:{...r};rt(o)?delete i[n]:i[n]=o;const s=Object.keys(i);return a[t]=1===s.length&&"entity"===s[0]&&"string"==typeof i.entity?i.entity:i,{...e,entities:a}}(this._asConfig,e,t,n))}_move(e,t){const n=new Set;for(const o of this._expanded)n.add(o===e?t:o===t?e:o);this._expanded=n,this._emit(function(e,t,n){const o=[...e.entities??[]];if(t<0||t>=o.length||t===n)return e;const[a]=o.splice(t,1);return o.splice(Math.max(0,Math.min(n,o.length)),0,a),{...e,entities:o}}(this._asConfig,e,t))}_remove(e){const t=new Set;for(const n of this._expanded)n!==e&&t.add(n>e?n-1:n);this._expanded=t,this._emit(function(e,t){const n=[...e.entities??[]];return t<0||t>=n.length?e:(n.splice(t,1),{...e,entities:n})}(this._asConfig,e))}_toggle(e){const t=new Set(this._expanded);t.delete(e)||t.add(e),this._expanded=t}_add(e){e.stopPropagation();const t=e.detail?.value;"string"==typeof t&&""!==t&&(e.target.value="",this._emit(function(e,t){return{...e,entities:[...e.entities??[],t]}}(this._asConfig,t)))}_formChanged(e,t,n){n.stopPropagation();const o=this.entities?.[e];if(void 0===o)return;const a="string"==typeof o?{entity:o}:o,r=Rt(a,t,n.detail?.value??{});if(r===a)return;const i=[...this.entities??[]],s=Object.keys(r);i[e]=1===s.length&&"entity"===s[0]?r.entity:r,this._emit({type:"",entities:i})}_rowLabel(e){const t=this.hass?.states[e];return t?{text:t.attributes.friendly_name||e,missing:!1}:this.hass?{text:this._t("editor_not_found",{entity:e}),missing:!0}:{text:e,missing:!1}}_form(e,t,n,o,a){return R`this._formChanged(t,a,e)} >`}_renderOverrides(e,t){const n=e=>t=>{t.stopPropagation(),e(t.detail?.value)};return R`
- ${this._form("entity-attribute",t,e,function(e){let t=Ot.get(e);return t||(t=[{name:"attribute",selector:{attribute:{entity_id:e}}}],Ot.set(e,t)),t}(e.entity),$t)} - ${this._form("entity-text",t,e,Ct,bt)} - ${this.chartFields?this._form("entity-chart",t,e,this.chartFields.schema,this.chartFields.fields):Z} + ${this._form("entity-attribute",t,e,function(e){let t=Lt.get(e);return t||(t=[{name:"attribute",selector:{attribute:{entity_id:e}}}],Lt.set(e,t)),t}(e.entity),$t)} + ${this._form("entity-text",t,e,zt,xt)} + ${this.chartFields?this._form("entity-chart",t,e,this.chartFields.schema,this.chartFields.fields):W} this._set(t,"icon",e))} > - ${ss.map(o=>R`R`this._set(t,"severity",e))} > - ${this._form("entity-actions",t,e,St,At)} -
`}_renderRow(e,t,n){const o=function(e){return"string"==typeof e?e:e.entity}(e),a="string"==typeof e?{entity:e}:e,i=this._rowLabel(o),r=this._expanded.has(t);return R`
- ${i.text} + ${this._form("entity-actions",t,e,Ct,St)} +
`}_renderRow(e,t,n){const o=function(e){return"string"==typeof e?e:e.entity}(e),a="string"==typeof e?{entity:e}:e,r=this._rowLabel(o),i=this._expanded.has(t);return R`
+ ${r.text}
{e.stopPropagation(),this._set(t,"entity",e.detail?.value),this.requestUpdate()}} > @@ -2100,12 +2422,12 @@ const Qe=me(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de this._toggle(t)} >
- ${r?this._renderOverrides(a,t):Z} + ${i?this._renderOverrides(a,t):W}
`}render(){const e=this.entities??[];return R`${e.map((t,n)=>this._renderRow(t,n,e.length))} `}}cs.properties={hass:{attribute:!1},entities:{attribute:!1},unit:{attribute:!1},chartFields:{attribute:!1},_expanded:{state:!0}},cs.styles=i` + >`}}tc.properties={hass:{attribute:!1},entities:{attribute:!1},unit:{attribute:!1},chartFields:{attribute:!1},_expanded:{state:!0}},tc.styles=r` :host { display: block; } @@ -2166,29 +2488,29 @@ const Qe=me(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de display: block; margin-top: 8px; } - `,customElements.get("cd-entities-editor")||customElements.define("cd-entities-editor",cs);class ds extends le{constructor(){super(...arguments),this._label=e=>ot(this.hass,e),this._computeLabel=e=>ot(this.hass,`editor_${e.name}`),this._computeRuleLabel=e=>ot(this.hass,`editor_rule_${e.name.slice(e.name.indexOf("_")+1)}`)}setConfig(e){this._config=e}connectedCallback(){super.connectedCallback(),async function(){if(!customElements.get("ha-entity-picker")&&"function"==typeof window.loadCardHelpers)try{const e=await window.loadCardHelpers(),t=await e.createCardElement({type:"entities",entities:[]}),n=t?.constructor;await(n?.getConfigElement?.())}catch{}}()}_commit(e){this._config=e,function(e,t){e.dispatchEvent(new CustomEvent("config-changed",{bubbles:!0,composed:!0,detail:{config:t}}))}(this,e)}_formChanged(e,t){if(t.stopPropagation(),!this._config)return;const n=t.detail?.value??{};this._commit(Vt(this._config,e,n))}_form(e,t,n){return R`at(this.hass,e),this._computeLabel=e=>at(this.hass,`editor_${e.name}`),this._computeRuleLabel=e=>at(this.hass,`editor_rule_${e.name.slice(e.name.indexOf("_")+1)}`)}setConfig(e){this._config=e}connectedCallback(){super.connectedCallback(),async function(){if(!customElements.get("ha-entity-picker")&&"function"==typeof window.loadCardHelpers)try{const e=await window.loadCardHelpers(),t=await e.createCardElement({type:"entities",entities:[]}),n=t?.constructor;await(n?.getConfigElement?.())}catch{}}()}_commit(e){this._config=e,function(e,t){e.dispatchEvent(new CustomEvent("config-changed",{bubbles:!0,composed:!0,detail:{config:t}}))}(this,e)}_formChanged(e,t){if(t.stopPropagation(),!this._config)return;const n=t.detail?.value??{};this._commit(Rt(this._config,e,n))}_form(e,t,n){return R`this._formChanged(n,e)} >`}_panel(e,t){return R`
${t}
-
`}_subChanged(e,t){t.stopPropagation(),this._config&&this._commit(st(this._config,e,t.detail?.value))}_chartFields(){const e=Rr[this._config?.style??"bar"];return e&&Vr(e)&&this._config?e.entityFields?.(this.hass,this._config):void 0}renderEntities(){return R``}_subChanged(e,t){t.stopPropagation(),this._config&&this._commit(lt(this._config,e,t.detail?.value))}_chartFields(){const e=Nl[this._config?.style??"bar"];return e&&Ol(e)&&this._config?e.entityFields?.(this.hass,this._config):void 0}renderEntities(){return R`this._subChanged(["entities"],e)} > - ${this._ruleForm("include")} ${this._ruleForm("exclude")}`}_ruleData(e){const t=this._config?.[e],n={};if(!t||"object"!=typeof t)return n;for(const[o,a]of Object.entries(t))n[`${e}_${o}`]=Array.isArray(a)?a:[a];return n}_ruleChanged(e,t){if(t.stopPropagation(),!this._config)return;const n=t.detail?.value??{},o=this._ruleData(e);let a=this._config;for(const i of Nt[e]){const t=`${e}_${i}`,r=Array.isArray(n[t])?n[t]:[];Dt(o[t]??[],r)||(a=st(a,[e,i],r.length?r:void 0))}a!==this._config&&this._commit(a)}_ruleForm(e){return R`
${this._label(`editor_${e}_heading`)}
+ ${this._ruleForm("include")} ${this._ruleForm("exclude")}`}_ruleData(e){const t=this._config?.[e],n={};if(!t||"object"!=typeof t)return n;for(const[o,a]of Object.entries(t))n[`${e}_${o}`]=Array.isArray(a)?a:[a];return n}_ruleChanged(e,t){if(t.stopPropagation(),!this._config)return;const n=t.detail?.value??{},o=this._ruleData(e);let a=this._config;for(const r of Nt[e]){const t=`${e}_${r}`,i=Array.isArray(n[t])?n[t]:[];Bt(o[t]??[],i)||(a=lt(a,[e,r],i.length?i:void 0))}a!==this._config&&this._commit(a)}_ruleForm(e){return R`
${this._label(`editor_${e}_heading`)}

${this._label(`editor_${e}_help`)}

this._ruleChanged(e,t)} >`}renderLimits(){return[["min","0"],["max","100"],["target",this._label("editor_none")]].map(([e,t])=>R`this._subChanged([e],t)} - >`)}renderColours(){return R`${this._toggles(yt)}`)}renderColours(){return R`${this._toggles(vt)}this._subChanged(["severity"],e)} - >`}_firstEntityUnit(){const e=this._config?.entities?.[0];return"string"==typeof e?ls(this.hass,e):e?.unit??ls(this.hass,e?.entity,e?.attribute)}_selectStyle(e){this._config&&this._commit(st(this._config,["style"],"bar"===e?void 0:e))}renderStyle(){const e=this._config?.style??"bar";return R`${Br.map(t=>{const n=Re.filter(e=>Rr[e].group===t);return n.length?R`
${this._label(`editor_group_${t}`)}
+ >
`}_firstEntityUnit(){const e=this._config?.entities?.[0];return"string"==typeof e?ec(this.hass,e):e?.unit??ec(this.hass,e?.entity,e?.attribute)}_selectStyle(e){this._config&&this._commit(lt(this._config,["style"],"bar"===e?void 0:e))}renderStyle(){const e=this._config?.style??"bar";return R`${jl.map(t=>{const n=Ve.filter(e=>Nl[e].group===t);return n.length?R`
${this._label(`editor_group_${t}`)}
- ${n.map(t=>{const n=Rr[t],o=Vr(n)?R`${this._label(`editor_style_${t}`)} `})} -
`:Z})}`}renderChart(){const e=Rr[this._config?.style??"bar"];return e&&Vr(e)&&e.editor&&this._config?this._panel("chart",e.editor({hass:this.hass,config:this._config,commit:e=>this._commit(e)})):Z}renderDisplay(){return R`${this._toggles(gt)} - ${this._form("display",function(e){const t=nt(e);return Ft?.language!==t&&(Ft={language:t,schema:Tt(e)}),Ft.schema}(this.hass),_t)}`}_toggles(e){const t=Bt(this._config,e);return R`
+
`:W})}`}renderChart(){const e=Nl[this._config?.style??"bar"];return e&&Ol(e)&&e.editor&&this._config?this._panel("chart",e.editor({hass:this.hass,config:this._config,commit:e=>this._commit(e)})):W}renderDisplay(){return R`${this._toggles(yt)} + ${this._form("display",function(e){const t=ot(e);return Dt?.language!==t&&(Dt={language:t,schema:Ot(e)}),Dt.schema}(this.hass),_t)}`}_toggles(e){const t=It(this._config,e);return R`
${e.map(({name:n})=>{const o=this._label(`editor_${n}`),a=!0===t[n];return R`
this._toggle(e,n,!a)}>${o}this._toggle(e,n,t.target.checked)} >
`})} -
`}_toggle(e,t,n){if(!this._config)return;const o=Bt(this._config,e);this._commit(Vt(this._config,e,{...o,[t]:n}))}renderActions(){return this._form("actions",St,vt)}render(){return this._config?R`
${this._form("title",Et,ft)}
+
`}_toggle(e,t,n){if(!this._config)return;const o=It(this._config,e);this._commit(Rt(this._config,e,{...o,[t]:n}))}renderActions(){return this._form("actions",Ct,bt)}render(){return this._config?R`
${this._form("title",Et,gt)}
${this._panel("entities",this.renderEntities())} ${this._panel("style",this.renderStyle())} ${this.renderChart()} ${this._panel("limits",this.renderLimits())} ${this._panel("colours",this.renderColours())} ${this._panel("display",this.renderDisplay())} - ${this._panel("actions",this.renderActions())}`:Z}}ds.properties={hass:{attribute:!1},_config:{state:!0}},ds.styles=i` + ${this._panel("actions",this.renderActions())}`:W}}nc.properties={hass:{attribute:!1},_config:{state:!0}},nc.styles=r` :host { display: block; } @@ -2311,7 +2633,7 @@ const Qe=me(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de outline-offset: -1px; border-color: transparent; } - `,customElements.get("chartdeck-card-editor")||customElements.define("chartdeck-card-editor",ds);const hs=i` + `,customElements.get("chartdeck-card-editor")||customElements.define("chartdeck-card-editor",nc);const oc=r` :host { display: block; --cd-duration: 900ms; @@ -2414,29 +2736,29 @@ const Qe=me(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de border-radius: 50%; flex: none; } -`;console.info("%c CHARTDECK %c 1.4.0 ","color:#fff;background:#3b8bff;font-weight:700","color:#3b8bff");const us={rows:16,tiles:8};function ps(e){const t=us[e.layout];return Math.max(1,Math.floor((368+t)/((e.minColumnWidth??0)+t)))}class ms extends le{constructor(){super(...arguments),this._watched=new Set,this._matched=[],this._items=[],this._detachers=new Map,this._readyScheduled=!1,this._readyAt=-1/0}static getStubConfig(e){const t=Object.values(e?.states??{}).filter(e=>e.entity_id.startsWith("sensor.")&&""!==e.state.trim()&&Number.isFinite(Number(e.state))),n=e=>"battery"===e.attributes?.device_class;return t.sort((e,t)=>n(e)!==n(t)?n(e)?-1:1:e.entity_idt.entity_id?1:0),{type:"custom:chartdeck-card",style:"bar",entities:t.slice(0,3).map(e=>e.entity_id)}}static getConfigElement(){return document.createElement("chartdeck-card-editor")}setConfig(e){const t=function(e){if(!e||"object"!=typeof e)throw new Error("Invalid configuration");const t=e.style??"bar";if(!Re.includes(t))throw new Error(`Unknown style "${String(t)}". Use one of: ${Re.join(", ")}`);const n=e.sort??"none";if(!We.includes(n))throw new Error(`Unknown sort "${String(n)}". Use one of: ${We.join(", ")}`);if(void 0!==e.entities&&!Array.isArray(e.entities))throw new Error('"entities" must be a list');const o=(e.entities??[]).map((t,n)=>Ke(t,e,n)),a={name:!0,value:!0,icon:!0,target:!0,legend:!1,...e.show},i=Ye(e.columns,"columns",1),r=Ye(e.limit,"limit",1),s=Pe(e.include,"include"),l=Pe(e.exclude,"exclude");s&&Ke("sensor.chartdeck_check",e,0);const c={style:t,entities:o,hideUnavailable:!0===e.hide_unavailable,cardOptions:e,show:a,sort:n,animate:!1!==e.animate,colorIcon:!0===e.color_icon,legendBands:Ge(e.severity)??Ie};return void 0!==e.title&&(c.title=e.title),void 0!==i&&(c.columns=i),void 0!==r&&(c.limit=r),s&&(c.include=s),l&&(c.exclude=l),c}(e),n=Rr[t.style];if(Vr(n)&&n.validate?.(e),this._watched=function(e){const t=new Set;for(const n of e.entities){t.add(n.entity);for(const e of[n.min,n.max,n.target,...n.severity.map(e=>e.from)])"string"==typeof e&&""!==e&&t.add(e)}for(const n of e.legendBands)"string"==typeof n.from&&t.add(n.from);return t}(t),Vr(n))for(const o of n.watch?.(e)??[])this._watched.add(o);this._config&&this._config.style!==t.style&&this._disconnectStyle(),this._config=t}get _styleName(){return this._config?.style??"bar"}get _style(){return Rr[this._styleName]}_legend(e){const t=this._config;if(!t?.show.legend||0===this._count())return[];const n=e?.decimals??1;return function(e,t,n){const o=n?` ${n}`:"",a=[];return e.forEach((n,i)=>{const r=e[i+1],s=Oe(n.color);n.label?a.push({color:s,text:n.label}):Number.isFinite(n.from)?r?a.push({color:s,text:`${t(n.from)}–${t(r.from)}${o}`}):a.push({color:s,text:`${t(n.from)}${o}+`}):r&&a.push({color:s,text:`< ${t(r.from)}${o}`})}),a}(Za(t.legendBands,this.hass),e=>Fo(e,n,this.hass),e?.unit??"")}_count(){return this.hasUpdated&&this.hass?this._items.length:this._config?.entities.length??0}_contentHeight(){const e=this._config;if(!e)return 0;const t=this._style,n=16+(e.title?73:0)+(this._legend().length>0?32:0);if(Vr(t))return n+t.height;const o=this._count(),a=void 0!==e.columns?Math.min(e.columns,ps(t)):"rows"===t.layout?1:Math.min(o,t.tilesPerRow??3,ps(t));return n+(0===o?0:Math.ceil(o/Math.max(1,a)))*t.height}getCardSize(){return Math.max(1,Math.ceil(this._contentHeight()/50))}getGridOptions(){const e=this._count();if(Vr(this._style))return{columns:12,rows:"auto",min_columns:6,min_rows:2};const t="rows"===this._style.layout;return{columns:t||e<=1?6:12,rows:"auto",min_columns:t?6:3,min_rows:1}}shouldUpdate(e){if(0===e.size)return!0;if([...e.keys()].some(e=>"hass"!==e))return!0;if(!e.has("hass"))return!1;const t=e.get("hass"),n=this.hass;if(!t||!n)return!0;for(const o of this._watched)if(t.states[o]!==n.states[o])return!0;if(this._config?.include){const e=Ya(this._config,n);if(e.length!==this._matched.length||e.some((e,t)=>e!==this._matched[t]))return!0;for(const o of e)if(t.states[o]!==n.states[o])return!0}return t.themes?.darkMode!==n.themes?.darkMode||t.themes?.theme!==n.themes?.theme||t.locale!==n.locale}willUpdate(){const e=!1===this._config?.animate||"function"==typeof window.matchMedia&&!!window.matchMedia("(prefers-reduced-motion: reduce)")?.matches;this.toggleAttribute("static",e),(e||this.preview)&&this.setAttribute("ready","")}_scheduleReady(){if(this._readyScheduled||this.hasAttribute("ready")||0===this._items.length)return;this._readyScheduled=!0;const e=()=>{this.shadowRoot?.querySelector(".entity")?.getBoundingClientRect(),this.setAttribute("ready",""),this._readyAt=performance.now()};"function"==typeof requestAnimationFrame?requestAnimationFrame(e):setTimeout(e,16)}updated(){this._scheduleReady(),this._syncActions()}connectedCallback(){if(super.connectedCallback(),!this.hasUpdated)return;const e=this._config?Rr[this._config.style]:void 0;e&&Vr(e)&&this.requestUpdate(),this._syncActions(),this.hasAttribute("ready")&&!this.hasAttribute("static")&&performance.now()-this._readyAt<1e3&&(this.removeAttribute("ready"),this._readyScheduled=!1,this._scheduleReady())}_disconnectStyle(){const e=this._config?Rr[this._config.style]:void 0;e&&Vr(e)&&e.disconnect?.(this)}disconnectedCallback(){super.disconnectedCallback(),this._disconnectStyle();for(const e of this._detachers.values())e();this._detachers.clear()}_syncActions(){const e=this.shadowRoot;if(!e)return;const t=new Set(e.querySelectorAll(".entity"));for(const[n,o]of this._detachers)t.has(n)||(o(),this._detachers.delete(n));for(const n of t)this._detachers.has(n)||this._detachers.set(n,Se(n,()=>this._items[Number(n.dataset.index)].config))}_renderEntity(e,t,n,o){const a=this._config,i=Ee(e.config),r=function(e){const t=Oe(e);return{start:t,end:`color-mix(in srgb, ${t} 80%, #fff)`}}(e.band.color),s=`--start:${e.span[0]};--end:${e.span[1]};--pos:${e.position};--target:${e.targetPosition??0};--c-start:${r.start};--c-end:${r.end}`;return R`
e.entity_id.startsWith("sensor.")&&""!==e.state.trim()&&Number.isFinite(Number(e.state))),n=e=>"battery"===e.attributes?.device_class;return t.sort((e,t)=>n(e)!==n(t)?n(e)?-1:1:e.entity_idt.entity_id?1:0),{type:"custom:chartdeck-card",style:"bar",entities:t.slice(0,3).map(e=>e.entity_id)}}static getConfigElement(){return document.createElement("chartdeck-card-editor")}setConfig(e){const t=function(e){if(!e||"object"!=typeof e)throw new Error("Invalid configuration");const t=e.style??"bar";if(!Ve.includes(t))throw new Error(`Unknown style "${String(t)}". Use one of: ${Ve.join(", ")}`);const n=e.sort??"none";if(!We.includes(n))throw new Error(`Unknown sort "${String(n)}". Use one of: ${We.join(", ")}`);if(void 0!==e.entities&&!Array.isArray(e.entities))throw new Error('"entities" must be a list');const o=(e.entities??[]).map((t,n)=>Qe(t,e,n)),a={name:!0,value:!0,icon:!0,target:!0,legend:!1,...e.show},r=Ge(e.columns,"columns",1),i=Ge(e.limit,"limit",1),s=Fe(e.include,"include"),l=Fe(e.exclude,"exclude");s&&Qe("sensor.chartdeck_check",e,0);const c={style:t,entities:o,hideUnavailable:!0===e.hide_unavailable,cardOptions:e,show:a,sort:n,animate:!1!==e.animate,colorIcon:!0===e.color_icon,legendBands:Xe(e.severity)??qe};return void 0!==e.title&&(c.title=e.title),void 0!==r&&(c.columns=r),void 0!==i&&(c.limit=i),s&&(c.include=s),l&&(c.exclude=l),c}(e),n=Nl[t.style];if(Ol(n)&&n.validate?.(e),this._watched=function(e){const t=new Set;for(const n of e.entities){t.add(n.entity);for(const e of[n.min,n.max,n.target,...n.severity.map(e=>e.from)])"string"==typeof e&&""!==e&&t.add(e)}for(const n of e.legendBands)"string"==typeof n.from&&t.add(n.from);return t}(t),Ol(n))for(const o of n.watch?.(e)??[])this._watched.add(o);this._config&&this._config.style!==t.style&&this._disconnectStyle(),this._config=t}get _styleName(){return this._config?.style??"bar"}get _style(){return Nl[this._styleName]}_legend(e){const t=this._config;if(!t?.show.legend||0===this._count())return[];const n=e?.decimals??1;return Oe(Ka(t.legendBands,this.hass),e=>Io(e,n,this.hass),e?.unit??"")}_count(){return this.hasUpdated&&this.hass?this._items.length:this._config?.entities.length??0}_contentHeight(){const e=this._config;if(!e)return 0;const t=this._style,n=16+(e.title?73:0)+(this._legend().length>0?32:0);if(Ol(t))return n+t.height;const o=this._count(),a=void 0!==e.columns?Math.min(e.columns,rc(t)):"rows"===t.layout?1:Math.min(o,t.tilesPerRow??3,rc(t));return n+(0===o?0:Math.ceil(o/Math.max(1,a)))*t.height}getCardSize(){return Math.max(1,Math.ceil(this._contentHeight()/50))}getGridOptions(){const e=this._count();if(Ol(this._style))return{columns:12,rows:"auto",min_columns:6,min_rows:2};const t="rows"===this._style.layout;return{columns:t||e<=1?6:12,rows:"auto",min_columns:t?6:3,min_rows:1}}shouldUpdate(e){if(0===e.size)return!0;if([...e.keys()].some(e=>"hass"!==e))return!0;if(!e.has("hass"))return!1;const t=e.get("hass"),n=this.hass;if(!t||!n)return!0;for(const o of this._watched)if(t.states[o]!==n.states[o])return!0;if(this._config?.include){const e=er(this._config,n);if(e.length!==this._matched.length||e.some((e,t)=>e!==this._matched[t]))return!0;for(const o of e)if(t.states[o]!==n.states[o])return!0}return t.themes?.darkMode!==n.themes?.darkMode||t.themes?.theme!==n.themes?.theme||t.locale!==n.locale}willUpdate(){const e=!1===this._config?.animate||"function"==typeof window.matchMedia&&!!window.matchMedia("(prefers-reduced-motion: reduce)")?.matches;this.toggleAttribute("static",e),(e||this.preview)&&this.setAttribute("ready","")}_scheduleReady(){if(this._readyScheduled||this.hasAttribute("ready")||0===this._items.length)return;this._readyScheduled=!0;const e=()=>{this.shadowRoot?.querySelector(".entity")?.getBoundingClientRect(),this.setAttribute("ready",""),this._readyAt=performance.now()};"function"==typeof requestAnimationFrame?requestAnimationFrame(e):setTimeout(e,16)}updated(){this._scheduleReady(),this._syncActions()}connectedCallback(){if(super.connectedCallback(),!this.hasUpdated)return;const e=this._config?Nl[this._config.style]:void 0;e&&Ol(e)&&this.requestUpdate(),this._syncActions(),this.hasAttribute("ready")&&!this.hasAttribute("static")&&performance.now()-this._readyAt<1e3&&(this.removeAttribute("ready"),this._readyScheduled=!1,this._scheduleReady())}_disconnectStyle(){const e=this._config?Nl[this._config.style]:void 0;e&&Ol(e)&&e.disconnect?.(this)}disconnectedCallback(){super.disconnectedCallback(),this._disconnectStyle();for(const e of this._detachers.values())e();this._detachers.clear()}_syncActions(){const e=this.shadowRoot;if(!e)return;const t=new Set(e.querySelectorAll(".entity"));for(const[n,o]of this._detachers)t.has(n)||(o(),this._detachers.delete(n));for(const n of t)this._detachers.has(n)||this._detachers.set(n,Ee(n,()=>this._items[Number(n.dataset.index)].config))}_renderEntity(e,t,n,o){const a=this._config,r=Se(e.config),i=function(e){const t=ze(e);return{start:t,end:`color-mix(in srgb, ${t} 80%, #fff)`}}(e.band.color),s=`--start:${e.span[0]};--end:${e.span[1]};--pos:${e.position};--target:${e.targetPosition??0};--c-start:${i.start};--c-end:${i.end}`;return R`
${n.render(e,{show:a.show,hass:o})} -
`}render(){const e=this._config;if(!e)return Z;const t=this.hass,n=this._style;let o,a,i;const r=t?function(e,t){const n=Ya(e,t).map((t,n)=>Ke(t,e.cardOptions,n)),o=[...e.entities,...n].map(e=>qa(e,t));return e.hideUnavailable?o.filter(e=>e.available):o}(e,t):[];if(this._matched=t?Ya(e,t):[],0!==e.entities.length||e.include)if(t)if(0===r.length)this._items=[],o=R`
${ot(t,e.include?"no_matches":"none_available")}
`;else{a=r[0];const s=new Map,l=new Set;r.forEach((e,t)=>{s.set(e,l.has(e.entityId)?`${e.entityId}#${t}`:e.entityId),l.add(e.entityId)}),this._items=function(e,t){if("none"===t)return[...e];const n="ascending"===t?1:-1;return e.map((e,t)=>({item:e,index:t})).sort((e,t)=>{if(e.item.available!==t.item.available)return e.item.available?-1:1;if(e.item.available){const o=(e.item.position-t.item.position)*n;if(0!==o)return o}return e.index-t.index}).map(e=>e.item)}(r,e.sort).slice(0,e.limit);const c=this._items.filter(e=>e.available).map(e=>e.display.length);if(i=c.length?Math.max(...c):void 0,Vr(n))o=R`
+
`}render(){const e=this._config;if(!e)return W;const t=this.hass,n=this._style;let o,a,r;const i=t?function(e,t){const n=er(e,t).map((t,n)=>Qe(t,e.cardOptions,n)),o=[...e.entities,...n].map(e=>Qa(e,t));return e.hideUnavailable?o.filter(e=>e.available):o}(e,t):[];if(this._matched=t?er(e,t):[],0!==e.entities.length||e.include)if(t)if(0===i.length)this._items=[],o=R`
${at(t,e.include?"no_matches":"none_available")}
`;else{a=i[0];const s=new Map,l=new Set;i.forEach((e,t)=>{s.set(e,l.has(e.entityId)?`${e.entityId}#${t}`:e.entityId),l.add(e.entityId)}),this._items=function(e,t){if("none"===t)return[...e];const n="ascending"===t?1:-1;return e.map((e,t)=>({item:e,index:t})).sort((e,t)=>{if(e.item.available!==t.item.available)return e.item.available?-1:1;if(e.item.available){const o=(e.item.position-t.item.position)*n;if(0!==o)return o}return e.index-t.index}).map(e=>e.item)}(i,e.sort).slice(0,e.limit);const c=this._items.filter(e=>e.available).map(e=>e.display.length);if(r=c.length?Math.max(...c):void 0,Ol(n))o=R`
${n.render({items:this._items,config:e,raw:e.cardOptions,hass:t,requestUpdate:()=>this.requestUpdate(),host:this})} -
`;else{const a="tiles"===n.layout,i=function(e,t){if(void 0===t)return"rows"===e.layout?"grid-template-columns: repeat(1, minmax(0, 1fr))":"";const n=(t-1)*us[e.layout];return`grid-template-columns: repeat(auto-fill, minmax(max(min(${e.minColumnWidth??0}px, 100%), calc((100% - ${n}px) / ${t} - 0.5px)), 1fr))`}(n,e.columns);o=R`
`;else{const a="tiles"===n.layout,r=function(e,t){if(void 0===t)return"rows"===e.layout?"grid-template-columns: repeat(1, minmax(0, 1fr))":"";const n=(t-1)*ac[e.layout];return`grid-template-columns: repeat(auto-fill, minmax(max(min(${e.minColumnWidth??0}px, 100%), calc((100% - ${n}px) / ${t} - 0.5px)), 1fr))`}(n,e.columns);o=R`
${Me(this._items,e=>s.get(e),(e,o)=>this._renderEntity(e,o,n,t))} -
`}}else this._items=[],o=Z;else this._items=[],o=R`
${ot(t,"no_entities")}
`;const s=this._legend(a);return R` +
`}}else this._items=[],o=W;else this._items=[],o=R`
${at(t,"no_entities")}
`;const s=this._legend(a);return R`
${o}
@@ -2444,5 +2766,5 @@ const Qe=me(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de ${s.map(e=>R`${e.text}`)} -
`:Z} - `}}ms.properties={hass:{attribute:!1},preview:{type:Boolean},_config:{state:!0}},ms.styles=[hs,...Object.values(Rr).map(e=>e.styles)],customElements.get("chartdeck-card")||customElements.define("chartdeck-card",ms);const fs=window.customCards??=[];fs.some(e=>"chartdeck-card"===e.type)||fs.push({type:"chartdeck-card",name:"Chartdeck",description:"Bars, gauges and charts, set up entirely from the visual editor.",preview:!0,documentationURL:"https://tempus2016.github.io/chartdeck/"});export{ms as ChartdeckCard}; + `:W} + `}}ic.properties={hass:{attribute:!1},preview:{type:Boolean},_config:{state:!0}},ic.styles=[oc,...Object.values(Nl).map(e=>e.styles)],customElements.get("chartdeck-card")||customElements.define("chartdeck-card",ic);const sc=window.customCards??=[];sc.some(e=>"chartdeck-card"===e.type)||sc.push({type:"chartdeck-card",name:"Chartdeck",description:"Bars, gauges and charts, set up entirely from the visual editor.",preview:!0,documentationURL:"https://tempus2016.github.io/chartdeck/"});export{ic as ChartdeckCard}; diff --git a/src/chart/box.ts b/src/chart/box.ts new file mode 100644 index 0000000..1ffabd0 --- /dev/null +++ b/src/chart/box.ts @@ -0,0 +1,152 @@ +import type { Point } from '../history/types'; + +/** + * Box plot maths: the spread of a sensor's readings over a span, as quartiles, whiskers and + * outliers. Readings come in two shapes: + * + * - History records changes, not samples. A value holds until the next one, so each reading is + * weighted by how long it held (as the grouped mean in group.ts is). A heater that sat at 20 °C + * all day and flickered to 30 °C ten times in a minute has a median of 20, not 30. + * - Long-term statistics rows are all the same length (an hour, five minutes), so each row's + * mean counts once, which is already time-weighted. Each row also carries the lowest and highest + * reading within it; whiskers drawn to the extremes use those, so a short spike inside an hour + * still shows. + */ + +/** One value and how much it counts (ms held, or 1 for a statistics row). */ +export interface Sample { + v: number; + w: number; +} + +/** The values that held within [start, stop), each weighted by how long it held. */ +export function heldSamples(points: readonly Point[], start: number, stop: number): Sample[] { + const out: Sample[] = []; + if (stop <= start) return out; + for (let i = 0; i < points.length; i++) { + const p = points[i]; + if (p.t >= stop) break; + const next = i + 1 < points.length ? points[i + 1].t : Infinity; + const from = Math.max(p.t, start); + const to = Math.min(next, stop); + if (to <= from || p.v === null) continue; + out.push({ v: p.v, w: to - from }); + } + return out; +} + +/** Statistics rows starting within [start, stop), one sample each, with the rows' extremes. */ +export function rowSamples( + rows: readonly Point[], + start: number, + stop: number, +): { samples: Sample[]; extremes?: [number, number] } { + const samples: Sample[] = []; + let lo = Infinity; + let hi = -Infinity; + for (const r of rows) { + if (r.t < start || r.t >= stop || r.carried || r.v === null) continue; + samples.push({ v: r.v, w: 1 }); + lo = Math.min(lo, r.v, typeof r.min === 'number' ? r.min : r.v); + hi = Math.max(hi, r.v, typeof r.max === 'number' ? r.max : r.v); + } + return { samples, extremes: samples.length ? [lo, hi] : undefined }; +} + +/** + * The `q` quantile (0–1) of weighted samples sorted by value. Each sample sits at the middle of + * its share of the total weight, and values between are interpolated; with equal weights this is + * the textbook (Hazen) quantile, so four readings 1, 2, 3, 4 have a median of 2.5. + */ +export function weightedQuantile(sorted: readonly Sample[], q: number): number { + const total = sorted.reduce((a, s) => a + s.w, 0); + let before = 0; + let prev: { at: number; v: number } | undefined; + for (const s of sorted) { + const at = (before + s.w / 2) / total; + before += s.w; + if (q <= at) { + if (!prev) return s.v; + return prev.v + ((s.v - prev.v) * (q - prev.at)) / (at - prev.at); + } + prev = { at, v: s.v }; + } + return sorted[sorted.length - 1].v; +} + +export const WHISKERS = ['tukey', 'range'] as const; +export type Whiskers = (typeof WHISKERS)[number]; + +/** Outliers kept per box; beyond this the most extreme ones are kept. */ +export const MAX_OUTLIERS = 60; + +export interface BoxStats { + /** How many readings (or rows) went into it. */ + count: number; + q1: number; + median: number; + q3: number; + /** Time-weighted mean. */ + mean: number; + /** Where the whiskers end. */ + low: number; + high: number; + /** Lowest and highest of everything, outliers included. */ + min: number; + max: number; + /** Values beyond the whiskers (Tukey whiskers only), distinct and sorted. */ + outliers: number[]; +} + +/** + * Quartiles, whiskers and outliers. Tukey whiskers reach the furthest reading within 1.5 × the + * box's height of it, and readings beyond are outliers; range whiskers reach the lowest and + * highest, with no outliers. `extremes` (statistics rows' own min and max) widen the range. + * Undefined when there are no samples. + */ +export function boxStats( + samples: readonly Sample[], + whiskers: Whiskers, + extremes?: [number, number], +): BoxStats | undefined { + const sorted = samples.filter((s) => s.w > 0 && Number.isFinite(s.v)).sort((a, b) => a.v - b.v); + if (sorted.length === 0) return undefined; + const q1 = weightedQuantile(sorted, 0.25); + const median = weightedQuantile(sorted, 0.5); + const q3 = weightedQuantile(sorted, 0.75); + const total = sorted.reduce((a, s) => a + s.w, 0); + const mean = sorted.reduce((a, s) => a + s.v * s.w, 0) / total; + const min = Math.min(sorted[0].v, extremes?.[0] ?? Infinity); + const max = Math.max(sorted[sorted.length - 1].v, extremes?.[1] ?? -Infinity); + const base = { count: sorted.length, q1, median, q3, mean, min, max }; + if (whiskers === 'range') return { ...base, low: min, high: max, outliers: [] }; + const reach = 1.5 * (q3 - q1); + const inside = sorted.filter((s) => s.v >= q1 - reach && s.v <= q3 + reach); + const low = Math.min(inside[0]?.v ?? q1, q1); + const high = Math.max(inside[inside.length - 1]?.v ?? q3, q3); + let outliers = [...new Set(sorted.filter((s) => s.v < low || s.v > high).map((s) => s.v))]; + if (outliers.length > MAX_OUTLIERS) { + // Keep the furthest from the box on each side. + outliers = outliers + .map((v) => ({ v, d: v < low ? low - v : v - high })) + .sort((a, b) => b.d - a.d) + .slice(0, MAX_OUTLIERS) + .map((o) => o.v) + .sort((a, b) => a - b); + } + return { ...base, low, high, outliers }; +} + +/** At most `max` items, picked evenly through the list. */ +export function spread(items: readonly T[], max: number): T[] { + if (items.length <= max) return [...items]; + const out: T[] = []; + for (let i = 0; i < max; i++) out.push(items[Math.floor((i * items.length) / max)]); + return out; +} + +/** A repeatable sideways offset in [-1, 1) for reading `i`, so dots don't jump on each redraw. */ +export function jitter(i: number): number { + const x = Math.sin((i + 1) * 12.9898) * 43758.5453; + return (x - Math.floor(x)) * 2 - 1; +} diff --git a/src/chart/grid.ts b/src/chart/grid.ts index d8af4ee..dc55737 100644 --- a/src/chart/grid.ts +++ b/src/chart/grid.ts @@ -108,19 +108,19 @@ function gridLines(axes: PlotAxes): SVGTemplateResult { const pct = (n: number) => `${Math.round(n * 10000) / 100}%`; -function valueLabels(axes: PlotAxes, ctx: ChartContext) { +function valueLabels(axes: PlotAxes, ctx: ChartContext, format?: (v: number) => string) { const { scales, value } = axes; return value.ticks.map((v) => { const top = scales.y(v) / VIEW_H; // Inside the plot the top label hangs below its line, so it doesn't leave the chart. const end = top < 0.08 ? 'top' : top > 0.92 ? 'bottom' : ''; return html`${formatNumber(v, value.decimals, ctx.hass)}${format ? format(v) : formatNumber(v, value.decimals, ctx.hass)}`; }); } -function timeLabels(axes: PlotAxes, ctx: ChartContext) { +function timeLabels(axes: PlotAxes, ctx: ChartContext, format?: (v: number) => string) { const { scales, range } = axes; const offset = zoneOffset(timeZoneOf(ctx.hass)); return axes.times.map((t) => { @@ -128,7 +128,7 @@ function timeLabels(axes: PlotAxes, ctx: ChartContext) { // Labels near an edge line up with it instead of centring off the chart. const edge = left < 0.05 ? 'start' : left > 0.95 ? 'end' : ''; return html`${axes.label ? axes.label(t) : timeLabel(t, axes.unit, range.end - range.start, ctx.hass, offset)}${format ? format(t) : axes.label ? axes.label(t) : timeLabel(t, axes.unit, range.end - range.start, ctx.hass, offset)}`; }); } @@ -163,6 +163,9 @@ export interface ChartAreaOptions { hover?: Hover; /** A second value axis on the right. */ right?: RightAxis; + /** Text for each label along the bottom or up the side, when they aren't times and plain values. */ + xLabel?: (v: number) => string; + yLabel?: (v: number) => string; } /** @@ -179,18 +182,20 @@ export function chartArea({ overlay = nothing, hover, right, + xLabel, + yLabel, }: ChartAreaOptions) { const { options } = axes; const outside = options.labels === 'outside'; const y = options.y && axes.value.ticks.length > 0; const x = options.x && axes.times.length > 0; - const yTicks = y ? valueLabels(axes, ctx) : []; + const yTicks = y ? valueLabels(axes, ctx, yLabel) : []; const yLabels = y ? html`
${yTicks}
` : nothing; - const xLabels = x ? html`
${timeLabels(axes, ctx)}
` : nothing; + const xLabels = x ? html`
${timeLabels(axes, ctx, xLabel)}
` : nothing; const sizer = y ? html`` : nothing; diff --git a/src/chart/heatmap-grid.ts b/src/chart/heatmap-grid.ts new file mode 100644 index 0000000..1cf1a4d --- /dev/null +++ b/src/chart/heatmap-grid.ts @@ -0,0 +1,141 @@ +import { startOf, wallTime } from '../history/timezone'; +import type { Zone } from '../history/timezone'; +import type { Point } from '../history/types'; + +/** + * The maths behind the heatmap: hourly groups folded into a week of hours, daily groups laid out + * as a calendar, and where a value sits on the colour scale. No drawing here. + */ + +/** One cell of a week grid: the mean of every hour that fell in it, and how many there were. */ +export interface WeekCell { + /** Row 0 is the zone's first weekday. */ + row: number; + /** Hour of the day, 0–23. */ + hour: number; + v: number | null; + count: number; +} + +/** + * Fold one-hour groups into 7 × 24 cells by their weekday and hour on the wall clock in `zone`: + * Tuesday 14:00 collects every Tuesday's 14:00–15:00. A cell is the plain mean of its hours (each + * hour is already one value, so each counts the same); a cell with no known hour is a gap, not 0. + * Rows start on the zone's first weekday. The hour the clocks go back happens twice and both + * count; the hour they skip is a gap that day. + */ +export function weekCells(points: Point[], zone: Zone): WeekCell[] { + const sums = new Array(7 * 24).fill(0); + const counts = new Array(7 * 24).fill(0); + for (const p of points) { + if (p.v === null) continue; + const w = wallTime(p.t, zone.timeZone); + const i = ((w.weekday - zone.firstDay + 7) % 7) * 24 + w.hour; + sums[i] += p.v; + counts[i] += 1; + } + return sums.map((sum, i) => ({ + row: Math.floor(i / 24), + hour: i % 24, + v: counts[i] ? sum / counts[i] : null, + count: counts[i], + })); +} + +/** One day of a calendar grid. */ +export interface DayCell { + /** The day's start. */ + t: number; + /** Week column, 0 at the week holding the first day. */ + col: number; + /** Row 0 is the zone's first weekday. */ + row: number; + v: number | null; +} + +export interface Calendar { + cells: DayCell[]; + /** Number of week columns. */ + weeks: number; + /** Where each month starts: its column, and the instant it starts (for its name). */ + months: { col: number; t: number }[]; +} + +/** + * Lay one-day groups out as a calendar: a column per week (from the zone's first weekday), a row + * per weekday, like a contribution graph. Weeks are counted from the first day's week by calendar + * date, so a 23- or 25-hour day doesn't shift a column. A month is labelled at the column of its + * first day, or at column 0 for the month the calendar opens in (unless the next month starts + * within three columns: the two names would overlap). + */ +export function calendarCells(points: Point[], zone: Zone): Calendar { + if (points.length === 0) return { cells: [], weeks: 0, months: [] }; + const dayNumber = (t: number) => { + const w = wallTime(t, zone.timeZone); + return Math.round(Date.UTC(w.year, w.month - 1, w.day) / 86_400_000); + }; + const firstWeek = dayNumber(startOf('week', points[0].t, zone)); + const cells: DayCell[] = []; + const months: { col: number; t: number }[] = []; + let lastMonth = -1; + for (const p of points) { + const w = wallTime(p.t, zone.timeZone); + const col = Math.floor((dayNumber(p.t) - firstWeek) / 7); + cells.push({ t: p.t, col, row: (w.weekday - zone.firstDay + 7) % 7, v: p.v }); + const month = w.year * 12 + w.month; + if (month !== lastMonth) { + if (lastMonth === -1 || w.day === 1) months.push({ col, t: p.t }); + lastMonth = month; + } + } + // The month the calendar opens in has no room for its name when the next starts soon after. + if (months.length > 1 && months[1].col - months[0].col < 3) months.shift(); + return { cells, weeks: cells[cells.length - 1].col + 1, months }; +} + +/** + * The ends of the colour scale: the lowest and highest known value, or the card's fixed ends where + * set. Undefined when there are no values. A flat set of values gets a scale of one unit, so every + * cell shows at full strength rather than dividing by zero. + */ +export function colorExtent( + values: (number | null)[], + fixed: { min?: number; max?: number } = {}, +): [number, number] | undefined { + const known = values.filter((v): v is number => v !== null); + if (known.length === 0 && (fixed.min === undefined || fixed.max === undefined)) return undefined; + let lo = fixed.min ?? Math.min(...known); + let hi = fixed.max ?? Math.max(...known); + if (hi <= lo) { + if (fixed.max === undefined) hi = lo + 1; + else lo = hi - 1; + } + return [lo, hi]; +} + +/** Where `v` sits between the scale's ends, 0–1, clamped. */ +export function fraction(v: number, [lo, hi]: [number, number]): number { + return Math.min(1, Math.max(0, (v - lo) / (hi - lo))); +} + +/** The lightest shade on the scale, as a percent of the colour mixed into the card's background. */ +export const SHADE_FLOOR = 12; + +/** A shade of `color` for fraction `f`: from a faint tint (low) to the full colour (high). */ +export function shade(color: string, f: number): string { + const pct = Math.round(SHADE_FLOOR + (100 - SHADE_FLOOR) * f); + return pct >= 100 ? color : `color-mix(in srgb, ${color} ${pct}%, var(--card-background-color, #fff))`; +} + +/** A pointy-topped hexagon centred on (`cx`, `cy`), `w` across its flats. */ +export function hexPath(cx: number, cy: number, w: number): string { + const r = w / Math.sqrt(3); + const pts = [0, 1, 2, 3, 4, 5].map((k) => { + const a = ((60 * k - 90) * Math.PI) / 180; + return `${Math.round((cx + r * Math.cos(a)) * 100) / 100},${Math.round((cy + r * Math.sin(a)) * 100) / 100}`; + }); + return `M${pts.join('L')}Z`; +} + +/** Vertical step between rows of hexagons one unit across: they nest, so less than one. */ +export const HEX_ROW = Math.sqrt(3) / 2; diff --git a/src/chart/histogram.ts b/src/chart/histogram.ts new file mode 100644 index 0000000..452c223 --- /dev/null +++ b/src/chart/histogram.ts @@ -0,0 +1,151 @@ +import type { Point } from '../history/types'; +import { niceStep } from './axes'; + +/** + * Histogram maths: where the bins go, and how much of each series' readings fall in each. + * + * History records changes, not samples: a sensor that sat at 20 °C all day and flickered to 21 + * ten times in a minute has eleven rows, ten of them 21. Counting rows says it was mostly 21; + * weighting each reading by how long it held says it was 20, which is what the room was. So bins + * count time by default (as the grouped mean in group.ts does), and plain row counts are an option + * for sensors where each reading is an event (a response time, a measured dose). + */ + +export const COUNT_MODES = ['time', 'readings'] as const; +export type CountMode = (typeof COUNT_MODES)[number]; + +/** The most bins a histogram draws; a narrower bin width is widened to fit. */ +export const MAX_BINS = 100; + +const tidy = (v: number) => Number(v.toPrecision(12)); + +export interface BinOptions { + /** How many bins (exact edges from the lowest to the highest reading). */ + bins?: number; + /** How wide each bin is (edges on whole multiples of it). Wins over `bins`. */ + width?: number; + /** Fixed ends of the binned range; readings outside are left out. */ + min?: number; + max?: number; +} + +/** + * Bin edges (each bin's start, then the last end) for readings from `lo` to `hi`. A set width + * lines edges up on its multiples; a set count splits the span exactly; otherwise a round width + * (1, 2 or 5 × 10ⁿ) gives about `target` bins. Flat data gets one bin around its value. + */ +export function binEdges(lo: number, hi: number, opts: BinOptions = {}, target = 12): number[] { + const start0 = opts.min ?? lo; + const end0 = opts.max ?? hi; + if (opts.bins !== undefined && opts.width === undefined) { + let [a, b] = [start0, end0]; + if (!(b > a)) [a, b] = [a - 0.5, a + 0.5]; + const step = (b - a) / opts.bins; + return Array.from({ length: opts.bins + 1 }, (_, i) => (i === opts.bins ? b : tidy(a + i * step))); + } + const span = end0 - start0; + let width = opts.width ?? (span > 0 ? niceStep(span, target) : niceStep(Math.max(Math.abs(start0), 1), 10)); + while (span / width > MAX_BINS) width = niceStep(span, MAX_BINS / 2); + const first = opts.min ?? tidy(Math.floor(start0 / width + 1e-9) * width); + let last = opts.max ?? tidy(Math.ceil(end0 / width - 1e-9) * width); + if (!(last > first)) last = tidy(first + width); + const edges = [first]; + for (let i = 1; edges[edges.length - 1] < last - width * 1e-9; i++) + edges.push(Math.min(last, tidy(first + i * width))); + return edges; +} + +/** The bin a value falls in (the top edge belongs to the last bin), or -1 outside the edges. */ +export function binIndex(v: number, edges: readonly number[]): number { + const n = edges.length - 1; + if (n < 1 || v < edges[0] || v > edges[n]) return -1; + if (v === edges[n]) return n - 1; + let lo = 0; + let hi = n - 1; + while (lo < hi) { + const mid = (lo + hi + 1) >> 1; + if (edges[mid] <= v) lo = mid; + else hi = mid - 1; + } + return lo; +} + +export interface WeightOptions { + mode: CountMode; + /** Nothing is known past this moment (now, or the range's end). */ + until: number; + /** Long-term statistics rows (each one equal period), not history. */ + statistics: boolean; +} + +/** + * How much each point counts, in ms of time or in readings. + * + * - Grouped points (`end` set) are one value per group: time counts the group's length up to + * `until`, readings count each group once. + * - Statistics rows are all one period long, so each counts the typical gap between rows (a + * missing row is missing data, not a long-held value). + * - History holds each value until the next reading; the last holds until `until`. In readings + * mode the value carried in from before the range, and the copy carried on to its right edge, + * are not readings and count nothing. + * + * Gaps (`v: null`) count nothing. + */ +export function pointWeights(points: readonly Point[], opts: WeightOptions): number[] { + const { mode, until, statistics } = opts; + if (points.length === 0) return []; + if (points[0].end !== undefined) { + return points.map((p) => (p.v === null ? 0 : mode === 'readings' ? 1 : Math.max(0, Math.min(p.end!, until) - p.t))); + } + if (statistics) { + if (mode === 'readings') return points.map((p) => (p.v === null || p.carried ? 0 : 1)); + const gaps = points + .slice(1) + .map((p, i) => p.t - points[i].t) + .filter((g) => g > 0) + .sort((a, b) => a - b); + const period = gaps.length ? gaps[Math.floor(gaps.length / 2)] : 3_600_000; + return points.map((p) => (p.v === null || p.carried ? 0 : period)); + } + const last = points.length - 1; + return points.map((p, i) => { + if (p.v === null) return 0; + if (mode === 'readings') { + const edgeCopy = i === last && i > 0 && p.t >= until && points[i - 1].v === p.v; + return p.carried || edgeCopy ? 0 : 1; + } + const next = i < last ? points[i + 1].t : Math.max(p.t, until); + return Math.max(0, Math.min(next, until) - p.t); + }); +} + +/** How much of the weight falls in each bin; readings outside the edges are left out. */ +export function binTotals(points: readonly Point[], weights: readonly number[], edges: readonly number[]): number[] { + const totals = new Array(Math.max(0, edges.length - 1)).fill(0); + points.forEach((p, i) => { + if (p.v === null || !(weights[i] > 0)) return; + const b = binIndex(p.v, edges); + if (b >= 0) totals[b] += weights[i]; + }); + return totals; +} + +const MINUTE = 60_000; +const HOUR = 60 * MINUTE; +const DAY = 24 * HOUR; + +/** A length of time in a few words: "2 d 4 h", "3 h 20 min", "45 min", "< 1 min". */ +export function formatDuration(ms: number): string { + if (ms < MINUTE) return ms > 0 ? '< 1 min' : '0 min'; + const minutes = Math.round(ms / MINUTE); + if (minutes < 60) return `${minutes} min`; + if (ms < DAY) { + const h = Math.floor(minutes / 60); + const m = minutes % 60; + return m ? `${h} h ${m} min` : `${h} h`; + } + const hours = Math.round(ms / HOUR); + const d = Math.floor(hours / 24); + const h = hours % 24; + return h ? `${d} d ${h} h` : `${d} d`; +} diff --git a/src/chart/scatter-plot.ts b/src/chart/scatter-plot.ts new file mode 100644 index 0000000..d9a0ff7 --- /dev/null +++ b/src/chart/scatter-plot.ts @@ -0,0 +1,600 @@ +import { css, html, nothing, svg } from 'lit'; +import type { TemplateResult } from 'lit'; +import { baseColor } from '../bands'; +import { + AXES_FIELDS, + HISTORY_FIELDS, + LAYOUT_FIELDS, + cachedSchema, + chartForm, + choice, + historySchema, + historyVariant, + layoutSchema, +} from '../editor/chart-editors'; +import type { FieldBinding } from '../editor/schemas'; +import { formatNumber, withUnit } from '../format'; +import type { Point } from '../history/types'; +import { localize, registerStrings } from '../localize'; +import type { ChartContext, ChartEditorContext, EntityFields } from '../styles/index'; +import type { CardConfig, HomeAssistant } from '../types'; +import { AXIS_LABELS, formatDateTime, validateAxisOptions } from './axes'; +import { + SERIES_COLORS, + VIEW_H, + VIEW_W, + disconnectHistory, + frameMessage, + frameStyles, + historyFrame, + periodNav, + validateHistoryOptions, +} from './frame'; +import type { ChartSeries, HistoryFrame } from './frame'; +import { chartArea, gridStyles, plotAxes, plotWidth, stopChartWidth } from './grid'; +import { chartFrame, chartLayout, headerOver, layoutStyles, validateLayout } from './layout'; +import { extent } from './scale'; +import { dotsPath } from './series-style'; +import { stopHover, tooltipEnabled, tooltipStyles, validateTooltipOptions } from './tooltip'; +import { + X_RANGE_FIELDS, + X_RANGE_SCHEMA, + nearestPoint, + pickHandlers, + pickStyles, + pickTooltip, + pickedIndex, + stopPick, + validateXRange, + valueAxes, + xDecimals, + xRange, +} from './xy'; + +/** + * Scatter and bubble charts: one entity's readings against another's, paired in time (or against + * time itself), drawn as dots. A bubble chart adds a third entity that sets each dot's size. + */ + +export type XYKind = 'scatter' | 'bubble'; + +/** What goes along the bottom of a scatter chart: the first entity's readings, or time. */ +export const AGAINST = ['entity', 'time'] as const; +export type Against = (typeof AGAINST)[number]; + +/** Dots drawn per series at most; more are thinned evenly. */ +export const MAX_MARKS = 800; +/** Bubbles drawn at most: each is its own element. */ +export const MAX_BUBBLES = 400; + +export const DEFAULT_DOT_SIZE = 6; +export const DEFAULT_MIN_SIZE = 6; +export const DEFAULT_MAX_SIZE = 32; +const SIZE_LIMITS = [2, 80] as const; +const DOT_LIMITS = [2, 30] as const; +const JITTER_LIMITS = [0, 20] as const; + +registerStrings('en', { + editor_style_scatter: 'Scatter', + editor_style_bubble: 'Bubble', + editor_against: 'Along the bottom', + editor_against_entity: 'The first entity', + editor_against_time: 'Time', + editor_dot_size: 'Dot size (px)', + editor_jitter: 'Jitter (px)', + editor_min_size: 'Smallest bubble (px)', + editor_max_size: 'Largest bubble (px)', + editor_dot_color: 'Dot colour', + editor_xy_x_axis: 'Labels along the bottom', + scatter_needs_two: 'Add a second entity. The first goes along the bottom, the others up the side.', + bubble_needs_three: 'Add three entities: one along the bottom, one up the side, and one for the size of each bubble.', + scatter_role_x: 'Across', + bubble_role_size: 'Size', +}); + +/* -------------------------------------------------------------- maths -------------------------------------------------------------- */ + +/** One moment where every series had a value: the time, and each series' value then. */ +export interface Sample { + t: number; + v: number[]; +} + +/** + * Pair several series in time. A sensor holds its reading until the next one, so at every time + * any series has a reading, each series' value is the last one at or before it. A time where a + * series has no value yet, or is unavailable, is left out. `edge` is a time to skip: the reading + * the chart carried on to its right edge, which is not a new one. + */ +export function pairReadings(series: readonly Point[][], edge?: number): Sample[] { + const times = new Set(); + for (const points of series) for (const p of points) if (p.t !== edge) times.add(p.t); + const sorted = [...times].sort((a, b) => a - b); + const at = series.map(() => -1); + const out: Sample[] = []; + for (const t of sorted) { + const v: number[] = []; + let ok = true; + series.forEach((points, i) => { + while (at[i] + 1 < points.length && points[at[i] + 1].t <= t) at[i]++; + const p = points[at[i]]; + if (!p || p.v === null) ok = false; + else v.push(p.v); + }); + if (ok) out.push({ t, v }); + } + return out; +} + +/** Keep at most `max` items, evenly spaced, always including the last. */ +export function thin(items: T[], max: number): T[] { + if (items.length <= max) return items; + const step = items.length / max; + const out: T[] = []; + for (let i = 0; i < max - 1; i++) out.push(items[Math.floor(i * step)]); + out.push(items[items.length - 1]); + return out; +} + +/** + * A steady random number from -1 to 1 for a reading at time `t` in series `seed`. Jitter comes + * from the reading itself, not its position in the list, so a dot stays put as new readings + * arrive and the oldest drop off. + */ +export function jitter(t: number, seed: number): number { + let h = (Math.floor(t / 1000) ^ Math.imul(seed + 1, 0x9e3779b9)) | 0; + h = Math.imul(h ^ (h >>> 16), 0x45d9f3b); + h = Math.imul(h ^ (h >>> 16), 0x45d9f3b); + h ^= h >>> 16; + return ((h >>> 0) / 0xffffffff) * 2 - 1; +} + +/** + * A bubble's diameter in px. Its area, not its width, grows with the value, from `minPx` at the + * lowest reading to `maxPx` at the highest, so a value twice as far up the range covers twice as + * much of the chart. All readings equal: halfway. + */ +export function bubbleDiameter(v: number, [lo, hi]: [number, number], minPx: number, maxPx: number): number { + const f = hi > lo ? Math.min(1, Math.max(0, (v - lo) / (hi - lo))) : 0.5; + const a = minPx * minPx + (maxPx * maxPx - minPx * minPx) * f; + return Math.round(Math.sqrt(a) * 10) / 10; +} + +/* ------------------------------------------------------------- options ------------------------------------------------------------- */ + +const num = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v); + +function checkRange(raw: CardConfig, key: string, [lo, hi]: readonly [number, number]) { + const v = raw[key]; + if (v !== undefined && (!num(v) || v < lo || v > hi)) + throw new Error(`"${key}" must be a number from ${lo} to ${hi}`); +} + +export interface XYOptions { + against: Against; + dotSize: number; + jitter: number; + minSize: number; + maxSize: number; +} + +export function xyOptions(raw: CardConfig, kind: XYKind): XYOptions { + return { + against: kind === 'bubble' ? 'entity' : ((raw.against as Against | undefined) ?? 'entity'), + dotSize: num(raw.dot_size) ? raw.dot_size : DEFAULT_DOT_SIZE, + jitter: num(raw.jitter) ? raw.jitter : 0, + minSize: num(raw.min_size) ? raw.min_size : DEFAULT_MIN_SIZE, + maxSize: num(raw.max_size) ? raw.max_size : DEFAULT_MAX_SIZE, + }; +} + +/** Readable errors for every option a scatter or bubble chart reads. */ +export function validateXY(raw: CardConfig, kind: XYKind): void { + validateHistoryOptions(raw); + validateAxisOptions(raw); + validateTooltipOptions(raw); + validateLayout(raw); + validateXRange(raw); + checkRange(raw, 'jitter', JITTER_LIMITS); + if (kind === 'scatter') { + if (raw.against !== undefined && !AGAINST.includes(raw.against as Against)) { + throw new Error(`"against" must be one of: ${AGAINST.join(', ')}`); + } + checkRange(raw, 'dot_size', DOT_LIMITS); + } else { + checkRange(raw, 'min_size', SIZE_LIMITS); + checkRange(raw, 'max_size', SIZE_LIMITS); + const o = xyOptions(raw, kind); + if (o.minSize > o.maxSize) throw new Error('"min_size" must not be above "max_size"'); + } +} + +/* ------------------------------------------------------------- drawing ------------------------------------------------------------- */ + +/** One dot: which series it belongs to, when, where (view box), and its readings. */ +export interface Mark { + series: ChartSeries; + t: number; + x: number; + y: number; + /** The readings behind it: [x, y] or [x, y, size]; [y] against time. */ + v: number[]; + color: string; + /** Bubbles: diameter in px. */ + d?: number; +} + +/** A y series' colour: its own, else the palette from the start, as the x entity takes none. */ +function seriesColor(s: ChartSeries, offset: number): string { + const own = (s.entity.config as { color?: string }).color; + return baseColor(own ?? SERIES_COLORS[(s.index - offset) % SERIES_COLORS.length]); +} + +/** Entities a chart needs before it can draw anything. */ +function needed(kind: XYKind, against: Against): number { + return kind === 'bubble' ? 3 : against === 'entity' ? 2 : 1; +} + +/** + * The names row: the entity along the bottom and (bubbles) the one setting size get a small tag + * saying so; the others a swatch of their dot colour. Tapping one opens it. + */ +function roleHeader(series: ChartSeries[], colors: (string | undefined)[], ctx: ChartContext, kind: XYKind) { + const { show } = ctx.config; + if (!show.name && !show.value) return nothing; + return html`
+ ${series.map((s, i) => { + const color = colors[i]; + const role = + color !== undefined + ? html`` + : html`${localize(ctx.hass, kind === 'bubble' && i === 2 ? 'bubble_role_size' : 'scatter_role_x')}`; + return html`
+ ${role} ${show.name ? html`${s.entity.name}` : nothing} + ${show.value ? html`${s.entity.display}` : nothing} +
`; + })} +
`; +} + +/** A reading formatted as its entity is, with its unit. */ +const valueText = (v: number, s: ChartSeries, hass: HomeAssistant) => + withUnit(formatNumber(v, s.entity.decimals, hass), s.entity.unit); + +/** The plot's rough height in px, for turning jitter in px into view-box units. */ +const plotHeight = (compact: boolean) => (compact ? 96 : 162); + +/** Build the marks (before scaling): which readings to plot, paired as the chart needs. */ +function collect(frame: HistoryFrame, kind: XYKind, against: Against, edge: number | undefined) { + const { series } = frame; + if (against === 'time') { + return series.map((s) => ({ + s, + samples: thin( + s.points + .filter((p) => p.v !== null && p.t !== edge && !p.carried) + .map((p) => ({ t: p.t, v: [p.t, p.v as number] })), + MAX_MARKS, + ), + })); + } + if (kind === 'bubble') { + const [x, y, size] = series; + return [{ s: y, samples: thin(pairReadings([x.points, y.points, size.points], edge), MAX_BUBBLES) }]; + } + const [x, ...ys] = series; + return ys.map((s) => ({ s, samples: thin(pairReadings([x.points, s.points], edge), MAX_MARKS) })); +} + +/** Draw a scatter or bubble chart for the card. */ +export function renderXY(ctx: ChartContext, kind: XYKind, now = Date.now()): TemplateResult { + const opts = xyOptions(ctx.raw, kind); + const { against } = opts; + const layout = chartLayout(ctx.raw); + const frame = historyFrame(ctx, now); + const nav = periodNav(frame, ctx); + const byTime = against === 'time'; + const offset = byTime ? 0 : 1; + const colors = frame.series.map((s, i) => + byTime + ? s.color + : kind === 'bubble' + ? i === 1 + ? seriesColor(s, 1) + : undefined + : i === 0 + ? undefined + : seriesColor(s, offset), + ); + const names = roleHeader(frame.series, colors, ctx, kind); + const header = nav === nothing && names === nothing ? nothing : html`${nav}${names}`; + const above = layout.header === 'over' ? { ...layout, header: 'above' as const } : layout; + if (frame.series.length < needed(kind, against)) { + return chartFrame( + above, + header, + html`
+ ${localize(ctx.hass, kind === 'bubble' ? 'bubble_needs_three' : 'scatter_needs_two')} +
`, + ); + } + const message = frameMessage(frame, ctx); + if (message) return chartFrame(above, header, message); + // History without groups carries each series' last reading on to the right edge: not a reading. + const edge = frame.source === 'history' && !frame.group ? Math.min(frame.range.end, now) : undefined; + const groups = collect(frame, kind, against, edge); + const all = groups.flatMap((g) => g.samples); + if (all.length === 0) + return chartFrame(above, header, html`
${localize(ctx.hass, 'chart_no_data')}
`); + const ys = all.map((m) => m.v[1]); + const yData: [number, number] = [Math.min(...ys), Math.max(...ys)]; + let axes; + let xLabel: ((v: number) => string) | undefined; + if (byTime) { + axes = plotAxes(ctx, frame.range, extent(frame.series.map((s) => s.points))); + } else { + const xs = all.map((m) => m.v[0]); + axes = valueAxes(ctx, [Math.min(...xs), Math.max(...xs)], yData, { x: xRange(ctx.raw) }); + const decimals = xDecimals(axes); + xLabel = (v) => formatNumber(v, decimals, ctx.hass); + } + const { scales } = axes; + const width = plotWidth(ctx) || 1; + const jx = (opts.jitter * VIEW_W) / width; + const jy = (opts.jitter * VIEW_H) / plotHeight(layout.compact); + const sizes = kind === 'bubble' ? all.map((m) => m.v[2]) : []; + const sizeRange: [number, number] = sizes.length ? [Math.min(...sizes), Math.max(...sizes)] : [0, 0]; + const marks: Mark[] = groups.flatMap(({ s, samples }) => { + const color = byTime ? s.color : seriesColor(s, offset); + return samples.map((m) => ({ + series: s, + t: m.t, + x: scales.x(m.v[0]) + (jx ? jitter(m.t, s.index * 2) * jx : 0), + y: scales.y(m.v[1]) + (jy ? jitter(m.t, s.index * 2 + 1) * jy : 0), + v: byTime ? [m.v[1]] : m.v, + color, + d: kind === 'bubble' ? bubbleDiameter(m.v[2], sizeRange, opts.minSize, opts.maxSize) : undefined, + })); + }); + const content = + kind === 'bubble' + ? svg`${[...marks] + .sort((a, b) => (b.d ?? 0) - (a.d ?? 0)) + .map( + (m) => + svg``, + )}` + : svg`${groups.map(({ s }) => { + const own = marks.filter((m) => m.series === s); + return own.length + ? svg` [m.x, m.y]))} + stroke=${own[0].color} style="stroke-width:${opts.dotSize}px">` + : nothing; + })}`; + const tooltip = tooltipEnabled(ctx.raw); + const picked = tooltip ? pickedIndex(ctx.host) : undefined; + const mark = picked !== undefined ? marks[picked] : undefined; + const label = localize(ctx.hass, 'chart_aria', { + style: localize(ctx.hass, `editor_style_${kind}`), + names: frame.series.map((s) => s.entity.name).join(', '), + }); + const reach = Math.max(16, kind === 'bubble' ? opts.maxSize / 2 : opts.dotSize); + return chartFrame( + layout, + header, + html`${chartArea({ + axes, + ctx, + content, + label, + xLabel, + overlay: html`${layout.header === 'over' ? headerOver(header) : nothing} + ${mark ? markTooltip(mark, frame, kind, against, ctx, width) : nothing}`, + hover: tooltip + ? pickHandlers(ctx, (fx, fy, w, h) => + nearestPoint( + marks.map((m) => [m.x, m.y] as const), + fx, + fy, + w, + h, + reach, + ), + ) + : undefined, + })} + ${kind === 'bubble' ? sizeLegend(frame.series[2], sizeRange, opts, ctx.hass) : nothing}`, + ); +} + +function markTooltip(m: Mark, frame: HistoryFrame, kind: XYKind, against: Against, ctx: ChartContext, width: number) { + const shown = + against === 'time' ? [m.series] : kind === 'bubble' ? frame.series.slice(0, 3) : [frame.series[0], m.series]; + return pickTooltip({ + x: m.x / VIEW_W, + y: m.y / VIEW_H, + plotWidth: width, + title: formatDateTime(m.t, ctx.hass), + dot: m.color, + rows: shown.map((s, i) => ({ + color: s === m.series ? m.color : undefined, + name: s.entity.name, + value: valueText(m.v[i], s, ctx.hass), + })), + }); +} + +/** Under a bubble chart: the smallest and largest bubble, with the readings they stand for. */ +function sizeLegend(s: ChartSeries, [lo, hi]: [number, number], opts: XYOptions, hass: HomeAssistant) { + const item = (v: number, d: number) => + html`${valueText(v, s, hass)}`; + return html``; +} + +export const xyStyles = css` + ${frameStyles} + ${gridStyles} + ${tooltipStyles} + ${layoutStyles} + ${pickStyles} + .plot .dots, + .plot .bubble { + fill: none; + stroke-linecap: round; + } + .plot .dots { + stroke-opacity: 0.8; + } + .plot .bubble { + stroke-opacity: 0.55; + } + .role { + flex: none; + align-self: center; + font-size: 0.6875em; + line-height: 1; + padding: 2px 5px; + border-radius: 8px; + color: var(--secondary-text-color); + border: 1px solid var(--divider-color, rgba(127, 127, 127, 0.3)); + } + .size-legend { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px 12px; + padding-top: 6px; + font-size: 0.8125em; + color: var(--secondary-text-color); + } + .size-item { + display: inline-flex; + align-items: center; + gap: 6px; + } + .size-dot { + flex: none; + border-radius: 50%; + border: 1px solid var(--secondary-text-color); + background: color-mix(in srgb, var(--secondary-text-color) 25%, transparent); + box-sizing: border-box; + } + .frame.compact .size-legend { + padding-top: 2px; + } +`; + +export function disconnectXY(host: HTMLElement): void { + disconnectHistory(host); + stopChartWidth(host); + stopHover(host); + stopPick(host); +} + +/* ------------------------------------------------------------- editor ------------------------------------------------------------- */ + +/** The shared axes fields, with the bottom labels named for values, not times. */ +const XY_AXES_FIELDS: FieldBinding[] = AXES_FIELDS.map((f) => (f.name === 'x_axis' ? { ...f, name: 'xy_x_axis' } : f)); + +function xyAxesSchema(hass?: HomeAssistant) { + return [ + { + type: 'grid', + name: '', + schema: [ + { name: 'xy_x_axis', selector: { boolean: {} } }, + { name: 'y_axis', selector: { boolean: {} } }, + { name: 'grid_horizontal', selector: { boolean: {} } }, + { name: 'grid_vertical', selector: { boolean: {} } }, + ], + }, + { name: 'axis_labels', selector: choice(hass, 'axis_labels', AXIS_LABELS) }, + { + type: 'grid', + name: '', + schema: [ + { name: 'y_min', selector: { number: { mode: 'box', step: 'any' } } }, + { name: 'y_max', selector: { number: { mode: 'box', step: 'any' } } }, + ], + }, + { name: 'tooltip', selector: { boolean: {} } }, + ]; +} + +const px = (min: number, max: number, step = 1) => ({ + number: { min, max, step, mode: 'box', unit_of_measurement: 'px' }, +}); + +const FIELDS: Record = { + scatter: [ + ...HISTORY_FIELDS, + { name: 'against', path: ['against'], default: 'entity' }, + { name: 'dot_size', path: ['dot_size'], default: DEFAULT_DOT_SIZE }, + { name: 'jitter', path: ['jitter'], default: 0 }, + ...X_RANGE_FIELDS, + ...XY_AXES_FIELDS, + ...LAYOUT_FIELDS, + ], + bubble: [ + ...HISTORY_FIELDS, + { name: 'min_size', path: ['min_size'], default: DEFAULT_MIN_SIZE }, + { name: 'max_size', path: ['max_size'], default: DEFAULT_MAX_SIZE }, + { name: 'jitter', path: ['jitter'], default: 0 }, + ...X_RANGE_FIELDS, + ...XY_AXES_FIELDS, + ...LAYOUT_FIELDS, + ], +}; + +/** The Chart section of a scatter or bubble card's editor. */ +export function xyEditor(kind: XYKind) { + return (ctx: ChartEditorContext): TemplateResult => { + const byTime = kind === 'scatter' && ctx.config.against === 'time'; + const schema = cachedSchema(`${kind}|${historyVariant(ctx.config)}|${byTime}`, ctx.hass, () => [ + ...historySchema(ctx.hass, ctx.config), + ...(kind === 'scatter' + ? [ + { name: 'against', selector: choice(ctx.hass, 'against', AGAINST) }, + { + type: 'grid', + name: '', + schema: [ + { name: 'dot_size', selector: px(DOT_LIMITS[0], DOT_LIMITS[1]) }, + { name: 'jitter', selector: px(JITTER_LIMITS[0], JITTER_LIMITS[1]) }, + ], + }, + ] + : [ + { + type: 'grid', + name: '', + schema: [ + { name: 'min_size', selector: px(SIZE_LIMITS[0], SIZE_LIMITS[1]) }, + { name: 'max_size', selector: px(SIZE_LIMITS[0], SIZE_LIMITS[1]) }, + ], + }, + { name: 'jitter', selector: px(JITTER_LIMITS[0], JITTER_LIMITS[1]) }, + ]), + ...(byTime ? [] : X_RANGE_SCHEMA), + ...xyAxesSchema(ctx.hass), + ...layoutSchema(ctx.hass), + ]); + return chartForm(ctx, 'chart', schema, FIELDS[kind]); + }; +} + +const COLOR_FIELDS: FieldBinding[] = [{ name: 'dot_color', path: ['color'] }]; +const COLOR_SCHEMA = [{ name: 'dot_color', selector: { text: {} } }]; + +/** Each entity's own dot colour (the bottom and size entities ignore it). */ +export function xyEntityFields(): EntityFields { + return { schema: COLOR_SCHEMA, fields: COLOR_FIELDS }; +} diff --git a/src/chart/xy.ts b/src/chart/xy.ts new file mode 100644 index 0000000..4541cee --- /dev/null +++ b/src/chart/xy.ts @@ -0,0 +1,265 @@ +import { css, html, nothing } from 'lit'; +import type { TemplateResult } from 'lit'; +import type { FieldBinding } from '../editor/schemas'; +import { registerStrings } from '../localize'; +import type { ChartContext } from '../styles/index'; +import type { CardConfig } from '../types'; +import { axisOptions, valueAxis } from './axes'; +import { VIEW_H, VIEW_W } from './frame'; +import { chartWidth } from './grid'; +import type { PlotAxes } from './grid'; +import { linear } from './scale'; +import { TOUCH_LINGER_MS, tooltipSide } from './tooltip'; +import type { Hover } from './tooltip'; + +/** + * Shared pieces for charts whose bottom axis is a value, not time (histogram, scatter, bubble, + * box plot): the value axes, an x range from YAML, and a tooltip that picks one mark under the + * pointer rather than a moment in time. + */ + +/** Room each bottom label needs, in px, when choosing how many fit. */ +const X_LABEL_PX = 70; +/** Gaps between value labels on a full-height plot: about one per 40 px. */ +const Y_GAPS = 4; + +const isNumber = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v); + +/** Throw a readable error for a bad `x_min` / `x_max`. */ +export function validateXRange(raw: CardConfig): void { + for (const k of ['x_min', 'x_max'] as const) { + if (raw[k] !== undefined && !isNumber(raw[k])) throw new Error(`"${k}" must be a number`); + } + if (isNumber(raw.x_min) && isNumber(raw.x_max) && raw.x_min >= raw.x_max) { + throw new Error('"x_min" must be below "x_max"'); + } +} + +registerStrings('en', { + editor_x_min: 'Lowest value across', + editor_x_max: 'Highest value across', +}); + +/** Editor fields for `x_min` / `x_max`: empty fits the readings. */ +export const X_RANGE_FIELDS: FieldBinding[] = [ + { name: 'x_min', path: ['x_min'] }, + { name: 'x_max', path: ['x_max'] }, +]; + +/** The x range as one row of the editor form. */ +export const X_RANGE_SCHEMA = [ + { + type: 'grid', + name: '', + schema: [ + { name: 'x_min', selector: { number: { mode: 'box', step: 'any' } } }, + { name: 'x_max', selector: { number: { mode: 'box', step: 'any' } } }, + ], + }, +]; + +/** The fixed ends of the bottom axis, when set. */ +export function xRange(raw: CardConfig): { min?: number; max?: number } { + return { min: isNumber(raw.x_min) ? raw.x_min : undefined, max: isNumber(raw.x_max) ? raw.x_max : undefined }; +} + +export interface ValueAxesOptions { + /** Fixed ends of the bottom axis (kept exactly). */ + x?: { min?: number; max?: number }; + /** Where the side axis starts when the card doesn't fix it (0 for counts). */ + yFloor?: number; +} + +/** + * Axes for a chart with values along the bottom: round steps on both, sized to the card, the side + * axis honouring the card's `y_min` / `y_max`. Returned in the shape `chartArea` draws, with the + * bottom ticks in `times` and the bottom span in `range`. + */ +export function valueAxes( + ctx: ChartContext, + xData: [number, number] | undefined, + yData: [number, number] | undefined, + opts: ValueAxesOptions = {}, +): PlotAxes { + const options = axisOptions(ctx.raw); + const count = Math.max(2, Math.min(8, Math.round(chartWidth(ctx) / X_LABEL_PX))); + const x = valueAxis(xData, opts.x ?? {}, count); + const yFixed = { min: options.min ?? opts.yFloor, max: options.max }; + const value = valueAxis(yData, yFixed, Y_GAPS); + return { + options, + value, + times: x.ticks, + unit: 'minute', + range: { start: x.domain[0], end: x.domain[1] }, + scales: { + x: linear(x.domain, [0, VIEW_W]), + y: linear(value.domain, [VIEW_H, 0]), + domain: value.domain, + }, + }; +} + +/** Decimals that print the bottom axis' round steps exactly. */ +export function xDecimals(axes: PlotAxes): number { + const [a, b] = axes.times; + if (a === undefined || b === undefined) return 0; + return Math.max(0, -Math.floor(Math.log10(Math.abs(b - a)) + 1e-9)); +} + +/* ------------------------------------------------------------- picking ------------------------------------------------------------- */ + +interface PickState { + i?: number; + timer?: ReturnType; +} + +const picks = new WeakMap(); + +function pickState(host: HTMLElement): PickState { + let s = picks.get(host); + if (!s) { + s = {}; + picks.set(host, s); + } + return s; +} + +/** The mark the pointer is on, if any (an index the chart chose). */ +export function pickedIndex(host: HTMLElement): number | undefined { + return picks.get(host)?.i; +} + +/** Forget the pointer (the card leaves the page). */ +export function stopPick(host: HTMLElement): void { + const s = picks.get(host); + if (s?.timer !== undefined) clearTimeout(s.timer); + picks.delete(host); +} + +/** + * Pointer handlers that ask `pick` which mark is under the pointer, given where it is as a + * fraction of the element across and down, and the element's size in px. The card redraws only + * when the answer changes. A mouse leaving clears it at once; a finger lifting leaves it up for a + * moment so it can be read. + */ +export function pickHandlers( + ctx: Pick, + pick: (fx: number, fy: number, width: number, height: number) => number | undefined, +): Hover { + const state = pickState(ctx.host); + const set = (i: number | undefined) => { + if (i === state.i) return; + state.i = i; + ctx.requestUpdate(); + }; + const cancel = () => { + if (state.timer !== undefined) clearTimeout(state.timer); + state.timer = undefined; + }; + return { + move(ev) { + cancel(); + const rect = (ev.currentTarget as HTMLElement).getBoundingClientRect(); + const fx = rect.width > 0 ? Math.min(1, Math.max(0, (ev.clientX - rect.left) / rect.width)) : 0; + const fy = rect.height > 0 ? Math.min(1, Math.max(0, (ev.clientY - rect.top) / rect.height)) : 0; + set(pick(fx, fy, rect.width, rect.height)); + }, + leave(ev) { + cancel(); + if (ev.pointerType === 'touch' || ev.pointerType === 'pen') { + state.timer = setTimeout(() => { + state.timer = undefined; + set(undefined); + }, TOUCH_LINGER_MS); + } else { + set(undefined); + } + }, + }; +} + +/** + * The index of the point nearest the pointer in screen distance, or undefined when none is within + * `reach` px. Points are in the plot's view box (VIEW_W × VIEW_H), which stretches to `width` × + * `height` px; `fx`, `fy` are the pointer as fractions of that box. + */ +export function nearestPoint( + points: readonly (readonly [number, number])[], + fx: number, + fy: number, + width: number, + height: number, + reach = 24, +): number | undefined { + let best: number | undefined; + let bestD = reach * reach; + const px = fx * width; + const py = fy * height; + points.forEach(([x, y], i) => { + const dx = (x / VIEW_W) * width - px; + const dy = (y / VIEW_H) * height - py; + const d = dx * dx + dy * dy; + if (d <= bestD) { + bestD = d; + best = i; + } + }); + return best; +} + +export interface PickRow { + color?: string; + name: string; + value: string; +} + +const pct = (n: number) => `${Math.round(n * 10000) / 100}%`; + +/** + * A tooltip box for one picked mark, beside a point at (`x`, `y`) given as fractions of the plot, + * with a heading and rows of name and value. `dot` puts a ring on the point; `band` shades a + * column from `band[0]` to `band[1]` across instead (a bin or a box). + */ +export function pickTooltip(opts: { + x: number; + y: number; + plotWidth: number; + title: string; + rows: PickRow[]; + dot?: string; + band?: [number, number]; +}): TemplateResult | typeof nothing { + const { x, y, plotWidth, title, rows, dot, band } = opts; + if (!(x >= 0 && x <= 1)) return nothing; + const side = tooltipSide(x, plotWidth); + return html``; +} + +/** The shaded column behind a picked bin or box; the rest comes from tooltipStyles. */ +export const pickStyles = css` + .pick-band { + position: absolute; + top: 0; + bottom: 0; + background: color-mix(in srgb, var(--primary-text-color) 8%, transparent); + } +`; diff --git a/src/styles/box-plot.ts b/src/styles/box-plot.ts new file mode 100644 index 0000000..a71975c --- /dev/null +++ b/src/styles/box-plot.ts @@ -0,0 +1,542 @@ +import { css, html, nothing, svg } from 'lit'; +import type { SVGTemplateResult, TemplateResult } from 'lit'; +import { baseColor } from '../bands'; +import { axisOptions, timeLabel, timeZoneOf, validateAxisOptions, valueAxis, hour12Of } from '../chart/axes'; +import { MAX_OUTLIERS, WHISKERS, boxStats, heldSamples, jitter, rowSamples, spread } from '../chart/box'; +import type { BoxStats, Sample, Whiskers } from '../chart/box'; +import { + SERIES_COLORS, + VIEW_H, + VIEW_W, + disconnectHistory, + frameMessage, + frameStyles, + periodNav, + seriesHeader, + validateHistoryOptions, +} from '../chart/frame'; +import type { ChartSeries, HistoryFrame } from '../chart/frame'; +import { chartArea, chartWidth, gridStyles, plotWidth, stopChartWidth } from '../chart/grid'; +import type { PlotAxes } from '../chart/grid'; +import { chartFrame, chartLayout, headerOver, layoutStyles, validateLayout } from '../chart/layout'; +import { linear } from '../chart/scale'; +import { dotsPath } from '../chart/series-style'; +import { tooltipEnabled, tooltipStyles, validateTooltipOptions } from '../chart/tooltip'; +import { pickHandlers, pickStyles, pickTooltip, pickedIndex, stopPick, xDecimals } from '../chart/xy'; +import type { PickRow } from '../chart/xy'; +import { + AXES_FIELDS, + HISTORY_FIELDS, + LAYOUT_FIELDS, + axesSchema, + cachedSchema, + chartForm, + choice, + historySchema, + historyVariant, + layoutSchema, +} from '../editor/chart-editors'; +import type { FieldBinding } from '../editor/schemas'; +import { formatNumber, withUnit } from '../format'; +import { key } from '../history/fetch'; +import type { StatisticsQuery } from '../history/fetch'; +import { statisticsPeriod } from '../history/fetch'; +import { parseGrouping } from '../history/group'; +import type { GroupUnit } from '../history/group'; +import { parsePeriod, periodRange, stepsFor } from '../history/period'; +import { storeFor } from '../history/store'; +import { boundaries, startOf, zoneFor } from '../history/timezone'; +import type { Range, Source } from '../history/types'; +import { localize, registerStrings } from '../localize'; +import type { CardConfig } from '../types'; +import type { ChartContext, ChartEditorContext, ChartStyleModule } from './index'; + +export const ORIENTATIONS = ['vertical', 'horizontal'] as const; +export type Orientation = (typeof ORIENTATIONS)[number]; + +/** Readings drawn per box when `readings: true`; more are thinned evenly. */ +const MAX_READINGS = 120; +/** Widest a box gets, in px, however few there are. */ +const MAX_BOX_PX = 44; +/** Share of a category's room the boxes take together; the rest is the gap between categories. */ +const SLOT = 0.72; + +registerStrings('en', { + editor_style_box: 'Box plot', + box_aria: 'Box plot of {names}', + box_highest: 'Highest', + box_upper_quartile: 'Upper quartile', + box_median: 'Median', + box_lower_quartile: 'Lower quartile', + box_lowest: 'Lowest', + box_mean: 'Average', + box_count: 'Readings', + box_outliers: 'Outliers', + editor_orientation: 'Boxes', + editor_orientation_vertical: 'Upright', + editor_orientation_horizontal: 'On their side', + editor_whiskers: 'Whiskers reach', + editor_whiskers_tukey: '1.5 × box height (outliers as dots)', + editor_whiskers_range: 'The lowest and highest', + editor_readings: 'Show each reading', + editor_box_color: 'Box colour', +}); + +type BoxRaw = CardConfig & { orientation?: unknown; whiskers?: unknown; readings?: unknown }; + +/** One box: where it sits (category, and its slot among the series there) and what it shows. */ +export interface Box { + category: number; + slot: number; + series: ChartSeries; + stats: BoxStats; + /** The values drawn as dots when the card shows each reading (thinned). */ + readings: number[]; +} + +export interface BoxFrame extends HistoryFrame { + /** Group boundaries when grouped (each start, then the last end); else empty. */ + bounds: number[]; + unit?: GroupUnit; + until: number; +} + +/** + * Load the card's readings as they were recorded: never grouped into one value per group (a box + * needs every reading in it). Grouped, the range starts where its first group does. Statistics + * come in hours (five minutes for hourly boxes) so each box has rows to spread. + */ +export function boxFrame(ctx: ChartContext, now = Date.now()): BoxFrame { + const period = parsePeriod(ctx.raw.period as CardConfig['period']); + const source: Source = (ctx.raw.source as Source | undefined) ?? 'history'; + const zone = zoneFor(ctx.hass); + const span = periodRange(period, now, zone, period.navigate ? stepsFor(ctx.host, period) : 0); + const group = parseGrouping(ctx.raw); + const range: Range = { start: group ? startOf(group.unit, span.start, zone) : span.start, end: span.end }; + const requests = ctx.items.map((e) => ({ entity: e.entityId, attribute: e.config.attribute })); + const statistics: StatisticsQuery | undefined = + source === 'statistics' + ? { + period: group ? (group.unit === 'hour' ? '5minute' : 'hour') : statisticsPeriod(range), + types: ['mean', 'min', 'max'], + value: 'mean', + } + : undefined; + const loaded = storeFor(ctx.host, ctx.requestUpdate).get(ctx.hass, requests, range, source, { + live: span.live, + statistics, + }); + const series = ctx.items.map((entity, index): ChartSeries => { + const own = entity.config as typeof entity.config & { color?: string }; + return { + index, + entity, + points: loaded.data.get(key(requests[index])) ?? [], + color: baseColor(own.color ?? SERIES_COLORS[index % SERIES_COLORS.length]), + curve: 'straight', + dash: false, + }; + }); + return { + status: loaded.status, + error: loaded.error, + range, + period, + source, + series, + live: span.live, + back: span.back, + zone, + bounds: group ? boundaries(group.unit, range.start, range.end, zone) : [], + unit: group?.unit, + until: Math.min(range.end, now), + }; +} + +/** Every box to draw: one per series per group, or one per series when not grouped. */ +export function boxesOf(frame: BoxFrame, whiskers: Whiskers): { boxes: Box[]; categories: number; slots: number } { + const grouped = frame.bounds.length > 1; + const spans: [number, number][] = grouped + ? frame.bounds.slice(0, -1).map((start, i) => [start, frame.bounds[i + 1]]) + : [[frame.range.start, frame.range.end]]; + const boxes: Box[] = []; + spans.forEach(([start, end], g) => { + const stop = Math.min(end, frame.until); + frame.series.forEach((s, j) => { + let samples: Sample[]; + let extremes: [number, number] | undefined; + if (frame.source === 'statistics') ({ samples, extremes } = rowSamples(s.points, start, stop)); + else samples = heldSamples(s.points, start, stop); + const stats = boxStats(samples, whiskers, extremes); + if (!stats) return; + boxes.push({ + category: grouped ? g : j, + slot: grouped ? j : 0, + series: s, + stats, + readings: spread( + samples.map((x) => x.v), + MAX_READINGS, + ), + }); + }); + }); + return { boxes, categories: grouped ? spans.length : frame.series.length, slots: grouped ? frame.series.length : 1 }; +} + +/** Keep every `step`-th category label so they fit `room` labels. */ +export function categoryTicks(count: number, room: number): number[] { + const step = Math.max(1, Math.ceil(count / Math.max(1, room))); + const out: number[] = []; + for (let i = 0; i < count; i += step) out.push(i); + return out; +} + +/** + * Axes with the categories along one side and values along the other. Upright boxes put the + * categories along the bottom (as `times`); boxes on their side put them up the side (as value + * ticks, first at the top). Gridlines follow the value labels whichever way the chart is turned: + * "lines across" draws at each value label, "lines down" at each category. + */ +export function boxAxes( + ctx: ChartContext, + orientation: Orientation, + categories: number, + data: [number, number] | undefined, + labelPx: number, + sidePx = 0, +): PlotAxes { + const options = axisOptions(ctx.raw); + const fixed = { min: options.min, max: options.max }; + const cat: [number, number] = [-0.5, categories - 0.5]; + if (orientation === 'vertical') { + const width = plotWidth(ctx); + const value = valueAxis(data, fixed, 4); + return { + options, + value, + times: categoryTicks(categories, Math.floor(width / labelPx)), + unit: 'day', + range: { start: cat[0], end: cat[1] }, + scales: { x: linear(cat, [0, VIEW_W]), y: linear(value.domain, [VIEW_H, 0]), domain: value.domain }, + }; + } + // The category names take a column of their own beside the plot. + const width = chartWidth(ctx) - 32 - (options.y && options.labels === 'outside' ? sidePx : 0); + const count = Math.max(2, Math.min(8, Math.round(width / 70))); + const x = valueAxis(data, fixed, count); + const rows = ctx.raw.compact === true ? 6 : 10; + return { + options: { ...options, horizontal: options.vertical, vertical: options.horizontal }, + value: { domain: cat, ticks: categoryTicks(categories, rows), decimals: 0 }, + times: x.ticks, + unit: 'day', + range: { start: x.domain[0], end: x.domain[1] }, + scales: { x: linear(x.domain, [0, VIEW_W]), y: linear(cat, [0, VIEW_H]), domain: cat }, + }; +} + +const formatters = new Map(); + +/** A group's name for its tooltip: "Tue 29 Oct, 14:00", "Tue 29 Oct", "October 2026". */ +function groupTitle(t: number, unit: GroupUnit, hass: ChartContext['hass']): string { + const opts: Intl.DateTimeFormatOptions = + unit === 'month' + ? { month: 'long', year: 'numeric' } + : unit === 'hour' + ? { + weekday: 'short', + day: 'numeric', + month: 'short', + hour: 'numeric', + minute: '2-digit', + hour12: hour12Of(hass), + } + : { weekday: unit === 'day' ? 'short' : undefined, day: 'numeric', month: 'short' }; + const language = hass?.locale?.language ?? 'en'; + const k = `${language}|${unit}|${timeZoneOf(hass)}|${opts.hour12}`; + let f = formatters.get(k); + if (!f) { + try { + f = new Intl.DateTimeFormat(language, { ...opts, timeZone: timeZoneOf(hass) }); + } catch { + f = new Intl.DateTimeFormat('en', opts); + } + formatters.set(k, f); + } + return f.format(t); +} + +/** A group's short name under its box: as the time axis would label it. */ +function groupLabel(frame: BoxFrame, i: number, hass: ChartContext['hass']): string { + const t = frame.bounds[i]; + const span = frame.range.end - frame.range.start; + if (frame.unit === 'hour') return timeLabel(t, 'hour', span, hass); + if (frame.unit === 'month') return timeLabel(t, 'month', span, hass); + return timeLabel(t, 'day', frame.unit === 'week' ? Infinity : span, hass); +} + +const shorten = (s: string, n = 16) => (s.length > n ? `${s.slice(0, n - 1)}…` : s); + +/** Where a box's centre and half-width sit along the category axis, in category units. */ +export function boxPlace(box: Pick, slots: number, maxHalf: number) { + const width = SLOT / slots; + return { centre: box.category - SLOT / 2 + width * (box.slot + 0.5), half: Math.min(width * 0.4, maxHalf) }; +} + +/** + * Which box the pointer is on, from its position along the category axis (a fraction): the + * category under it, then the series' slot within. Undefined in a gap or where there's no box. + */ +export function pickBox(boxes: readonly Box[], categories: number, slots: number, along: number): number | undefined { + const at = along * categories - 0.5; + const category = Math.min(categories - 1, Math.max(0, Math.round(at))); + const within = at - category + SLOT / 2; + if (slots > 1 && (within < 0 || within >= SLOT)) return undefined; + const slot = Math.min(slots - 1, Math.max(0, Math.floor(within / (SLOT / slots)))); + const i = boxes.findIndex((b) => b.category === category && b.slot === slot); + return i < 0 ? undefined : i; +} + +function drawBox( + box: Box, + place: { centre: number; half: number }, + at: (c: number, v: number) => [number, number], + showReadings: boolean, +): SVGTemplateResult { + const { stats, series } = box; + const { centre: c, half } = place; + const line = (a: [number, number], b: [number, number], cls: string) => + svg``; + const [x0, y0] = at(c - half, stats.q1); + const [x1, y1] = at(c + half, stats.q3); + const readings = + showReadings && box.readings.length + ? svg` at(c + jitter(k) * half * 0.8, v)))}>` + : nothing; + return svg` + ${line(at(c, stats.low), at(c, stats.q1), 'whisker')} + ${line(at(c, stats.q3), at(c, stats.high), 'whisker')} + ${line(at(c - half / 2, stats.low), at(c + half / 2, stats.low), 'cap')} + ${line(at(c - half / 2, stats.high), at(c + half / 2, stats.high), 'cap')} + + ${readings} + ${line(at(c - half, stats.median), at(c + half, stats.median), 'median')} + ${stats.outliers.length ? svg` at(c, v)))}>` : nothing} + `; +} + +function validate(raw: CardConfig): void { + validateHistoryOptions(raw); + validateAxisOptions(raw); + validateTooltipOptions(raw); + validateLayout(raw); + const r = raw as BoxRaw; + if (r.orientation !== undefined && !ORIENTATIONS.includes(r.orientation as Orientation)) { + throw new Error(`"orientation" must be one of: ${ORIENTATIONS.join(', ')}`); + } + if (r.whiskers !== undefined && !WHISKERS.includes(r.whiskers as Whiskers)) { + throw new Error(`"whiskers" must be one of: ${WHISKERS.join(', ')}`); + } + if (r.readings !== undefined && typeof r.readings !== 'boolean') throw new Error('"readings" must be true or false'); +} + +const BOX_FIELDS: FieldBinding[] = [ + ...HISTORY_FIELDS, + { name: 'orientation', path: ['orientation'], default: 'vertical' }, + { name: 'whiskers', path: ['whiskers'], default: 'tukey' }, + { name: 'readings', path: ['readings'], default: false }, + ...AXES_FIELDS, + ...LAYOUT_FIELDS, +]; + +/** The Chart section: period and source, one box per group or per entity, the boxes' look, axes, layout. */ +export function boxEditor(ctx: ChartEditorContext): TemplateResult { + // Boxes need every reading, not one combined value per group: no "combine with" choice. + const ungrouped = { ...ctx.config, group_by: undefined }; + const schema = cachedSchema(`box|${historyVariant(ungrouped)}`, ctx.hass, () => [ + ...historySchema(ctx.hass, ungrouped), + { + type: 'grid', + name: '', + schema: [ + { name: 'orientation', selector: choice(ctx.hass, 'orientation', ORIENTATIONS) }, + { name: 'whiskers', selector: choice(ctx.hass, 'whiskers', WHISKERS) }, + ], + }, + { name: 'readings', selector: { boolean: {} } }, + ...axesSchema(ctx.hass), + ...layoutSchema(ctx.hass), + ]); + return chartForm(ctx, 'chart', schema, BOX_FIELDS); +} + +const ENTITY_SCHEMA = [{ name: 'box_color', selector: { text: {} } }]; +const ENTITY_FIELDS: FieldBinding[] = [{ name: 'box_color', path: ['color'] }]; + +export const box: ChartStyleModule = { + layout: 'chart', + group: 'distribution', + height: 240, + styles: css` + ${frameStyles} + ${gridStyles} + ${tooltipStyles} + ${layoutStyles} + ${pickStyles} + .style-box .box line { + stroke: var(--box-color); + stroke-width: 1.5; + } + .style-box .box .median { + stroke-width: 3; + } + .style-box .box .body { + fill: color-mix(in srgb, var(--box-color) 22%, transparent); + stroke: var(--box-color); + stroke-width: 1.5; + vector-effect: non-scaling-stroke; + } + .style-box .box .outliers { + fill: none; + stroke: var(--box-color); + stroke-width: 6px; + stroke-linecap: round; + } + .style-box .box .readings { + fill: none; + stroke: var(--box-color); + stroke-width: 4px; + stroke-linecap: round; + opacity: 0.45; + } + `, + thumbnail: () => svg` + + + + + + + + + `, + validate, + editor: boxEditor, + entityFields: () => ({ schema: ENTITY_SCHEMA, fields: ENTITY_FIELDS }), + disconnect(host) { + disconnectHistory(host); + stopChartWidth(host); + stopPick(host); + }, + render(ctx) { + const frame = boxFrame(ctx); + const nav = periodNav(frame, ctx); + const names = seriesHeader(frame, ctx); + const header = nav === nothing && names === nothing ? nothing : html`${nav}${names}`; + const layout = chartLayout(ctx.raw); + const message = frameMessage(frame, ctx); + if (message) return chartFrame(layout.header === 'over' ? { ...layout, header: 'above' } : layout, header, message); + const raw = ctx.raw as BoxRaw; + const orientation = (raw.orientation as Orientation | undefined) ?? 'vertical'; + const whiskers = (raw.whiskers as Whiskers | undefined) ?? 'tukey'; + const { boxes, categories, slots } = boxesOf(frame, whiskers); + let lo = Infinity; + let hi = -Infinity; + for (const b of boxes) { + lo = Math.min(lo, b.stats.low, ...b.stats.outliers, ...(raw.readings === true ? b.readings : [])); + hi = Math.max(hi, b.stats.high, ...b.stats.outliers, ...(raw.readings === true ? b.readings : [])); + } + const grouped = frame.bounds.length > 1; + const vertical = orientation === 'vertical'; + // Every entity is named; under upright boxes, cut to the room each one has (about 6.5 px a letter). + const room = vertical ? Math.max(4, Math.floor(plotWidth(ctx) / Math.max(1, categories) / 6.5)) : 16; + const categoryName = (i: number) => + grouped ? groupLabel(frame, i, ctx.hass) : shorten(frame.series[i]?.entity.name ?? '', room); + const longest = Math.max(0, ...Array.from({ length: categories }, (_, i) => categoryName(i).length)); + const axes = boxAxes( + ctx, + orientation, + categories, + boxes.length ? [lo, hi] : undefined, + grouped ? 56 : 1, + longest * 6 + 12, + ); + const cs = vertical ? axes.scales.x : axes.scales.y; + const vs = vertical ? axes.scales.y : axes.scales.x; + const at = (c: number, v: number): [number, number] => (vertical ? [cs(c), vs(v)] : [vs(v), cs(c)]); + // Along the category axis, how many category units make MAX_BOX_PX / 2. + const alongPx = vertical ? plotWidth(ctx) : layout.compact ? 110 : 160; + const maxHalf = alongPx > 0 ? (MAX_BOX_PX / 2) * (categories / alongPx) : Infinity; + const content = svg`${boxes.map((b) => drawBox(b, boxPlace(b, slots, maxHalf), at, raw.readings === true))}`; + const label = localize(ctx.hass, 'box_aria', { names: frame.series.map((s) => s.entity.name).join(', ') }); + const tooltip = tooltipEnabled(ctx.raw); + const picked = tooltip ? pickedIndex(ctx.host) : undefined; + const fmt = (b: Box, v: number) => + withUnit(formatNumber(v, b.series.entity.decimals, ctx.hass), b.series.entity.unit); + let tip: TemplateResult | typeof nothing = nothing; + const box = picked === undefined ? undefined : boxes[picked]; + if (box) { + const t = (k: string) => localize(ctx.hass, k); + const { stats } = box; + const rows: PickRow[] = [ + { name: t('box_highest'), value: fmt(box, stats.max) }, + { name: t('box_upper_quartile'), value: fmt(box, stats.q3) }, + { name: t('box_median'), value: fmt(box, stats.median), color: box.series.color }, + { name: t('box_lower_quartile'), value: fmt(box, stats.q1) }, + { name: t('box_lowest'), value: fmt(box, stats.min) }, + { name: t('box_mean'), value: fmt(box, stats.mean) }, + { name: t('box_count'), value: String(stats.count) }, + ]; + if (stats.outliers.length) { + const n = stats.outliers.length; + rows.push({ name: t('box_outliers'), value: n >= MAX_OUTLIERS ? `${n}+` : String(n) }); + } + const place = boxPlace(box, slots, maxHalf); + const title = grouped + ? `${groupTitle(frame.bounds[box.category], frame.unit!, ctx.hass)}${slots > 1 ? ` · ${box.series.entity.name}` : ''}` + : box.series.entity.name; + tip = vertical + ? pickTooltip({ + x: cs(place.centre) / VIEW_W, + y: vs(stats.median) / VIEW_H, + plotWidth: plotWidth(ctx), + title, + rows, + band: [cs(box.category - 0.5) / VIEW_W, cs(box.category + 0.5) / VIEW_W], + }) + : pickTooltip({ + x: vs(stats.high) / VIEW_W, + y: cs(place.centre) / VIEW_H, + plotWidth: plotWidth(ctx), + title, + rows, + }); + } + return chartFrame( + layout, + header, + chartArea({ + axes, + ctx, + content, + label, + overlay: html`${layout.header === 'over' ? headerOver(header) : nothing}${tip}`, + hover: tooltip + ? pickHandlers(ctx, (fx, fy) => pickBox(boxes, categories, slots, vertical ? fx : fy)) + : undefined, + ...(vertical + ? { xLabel: categoryName } + : { yLabel: categoryName, xLabel: (v: number) => formatNumber(v, xDecimals(axes), ctx.hass) }), + }), + ); + }, +}; diff --git a/src/styles/bubble.ts b/src/styles/bubble.ts new file mode 100644 index 0000000..efc3f74 --- /dev/null +++ b/src/styles/bubble.ts @@ -0,0 +1,21 @@ +import { svg } from 'lit'; +import { disconnectXY, renderXY, validateXY, xyEditor, xyEntityFields, xyStyles } from '../chart/scatter-plot'; +import type { ChartStyleModule } from './index'; + +/** A scatter of one entity against another, each dot sized by a third. */ +export const bubble: ChartStyleModule = { + layout: 'chart', + group: 'distribution', + height: 270, + styles: xyStyles, + thumbnail: () => svg` + + + + `, + validate: (raw) => validateXY(raw, 'bubble'), + editor: xyEditor('bubble'), + entityFields: () => xyEntityFields(), + disconnect: disconnectXY, + render: (ctx) => renderXY(ctx, 'bubble'), +}; diff --git a/src/styles/heatmap.ts b/src/styles/heatmap.ts new file mode 100644 index 0000000..0d5ad6b --- /dev/null +++ b/src/styles/heatmap.ts @@ -0,0 +1,584 @@ +import { css, html, nothing, svg } from 'lit'; +import type { SVGTemplateResult, TemplateResult } from 'lit'; +import { bandFor, baseColor, legendItems } from '../bands'; +import { hour12Of } from '../chart/axes'; +import { + disconnectHistory, + frameMessage, + frameStyles, + historyFrame, + periodNav, + validateHistoryOptions, +} from '../chart/frame'; +import type { ChartSeries, HistoryFrame } from '../chart/frame'; +import { chartWidth, stopChartWidth } from '../chart/grid'; +import { HEX_ROW, calendarCells, colorExtent, fraction, hexPath, shade, weekCells } from '../chart/heatmap-grid'; +import { tooltipEnabled, tooltipStyles, validateTooltipOptions } from '../chart/tooltip'; +import { pickHandlers, pickTooltip, pickedIndex, stopPick } from '../chart/xy'; +import { + HISTORY_FIELDS, + cachedSchema, + chartForm, + choice, + historySchema, + historyVariant, +} from '../editor/chart-editors'; +import type { FieldBinding } from '../editor/schemas'; +import { formatNumber, withUnit } from '../format'; +import { AGGREGATES } from '../history/group'; +import { localize, registerStrings } from '../localize'; +import type { ChartContext, ChartEditorContext, ChartStyleModule } from './index'; +import type { CardConfig, HomeAssistant } from '../types'; + +export const HEATMAP_GRIDS = ['hours', 'calendar'] as const; +export type HeatmapGrid = (typeof HEATMAP_GRIDS)[number]; +export const HEATMAP_COLORS = ['scale', 'bands'] as const; +export const CELL_SHAPES = ['square', 'rounded', 'hexagon'] as const; +export type CellShape = (typeof CELL_SHAPES)[number]; + +/** Cell pitch in the drawing's own units; cells are drawn a little smaller to leave a gap. */ +const CELL = 10; +const GAP = 1.2; +/** Grid, cell: one number for the picked cell (the pointer state holds a single index). */ +const PER_GRID = 10_000; + +/** + * What each layout covers and reads from when the card doesn't say: four weeks of history by + * hour (enough for each weekday hour to average a few days, inside the recorder's default 10 days + * of keeping… mostly), or twelve months of daily statistics (history rarely goes back a year). + */ +const DEFAULTS: Record = { + hours: { last: 4, unit: 'week', source: 'history' }, + calendar: { last: 12, unit: 'month', source: 'statistics' }, +}; + +registerStrings('en', { + editor_style_heatmap: 'Heatmap', + heatmap_aria: 'Heatmap of {name}', + heatmap_hours: 'Days averaged', + heatmap_empty: 'No readings', + editor_grid: 'Layout', + editor_grid_hours: 'Hour by weekday', + editor_grid_calendar: 'Calendar (a day per cell)', + editor_color_by_scale: 'Smooth scale', + editor_cell_shape: 'Cells', + editor_cell_shape_square: 'Square', + editor_cell_shape_rounded: 'Rounded', + editor_cell_shape_hexagon: 'Hexagon', + editor_color_min: 'Lowest colour at', + editor_color_max: 'Highest colour at', + editor_legend: 'Legend', +}); + +type Raw = CardConfig & { grid?: unknown; cell_shape?: unknown; color_min?: unknown; color_max?: unknown }; + +const isNumber = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v); + +function gridOf(raw: CardConfig): HeatmapGrid { + return ((raw as Raw).grid as HeatmapGrid | undefined) ?? 'hours'; +} + +/** The card's options with the layout's grouping, and its default span and source filled in. */ +export function heatmapRaw(raw: CardConfig): CardConfig { + const grid = gridOf(raw); + const d = DEFAULTS[grid]; + const period = (raw.period ?? {}) as Record; + return { + ...raw, + group_by: grid === 'hours' ? 'hour' : 'day', + source: raw.source ?? d.source, + period: + period.calendar !== undefined ? period : { ...period, last: period.last ?? d.last, unit: period.unit ?? d.unit }, + }; +} + +function validate(raw: CardConfig): void { + const r = raw as Raw; + const one = (key: string, values: readonly string[]) => { + const v = (r as Record)[key]; + if (v !== undefined && !values.includes(v as string)) + throw new Error(`"${key}" must be one of: ${values.join(', ')}`); + }; + one('grid', HEATMAP_GRIDS); + one('color_by', HEATMAP_COLORS); + one('cell_shape', CELL_SHAPES); + for (const k of ['color_min', 'color_max'] as const) { + if (r[k] !== undefined && !isNumber(r[k])) throw new Error(`"${k}" must be a number`); + } + if (isNumber(r.color_min) && isNumber(r.color_max) && r.color_min >= r.color_max) { + throw new Error('"color_min" must be below "color_max"'); + } + if (r.legend !== undefined && typeof r.legend !== 'boolean') throw new Error('"legend" must be true or false'); + validateHistoryOptions(heatmapRaw(raw)); + validateTooltipOptions(raw); +} + +/* -------------------------------------------------------------- labels -------------------------------------------------------------- */ + +const formats = new Map(); + +function dateFormat(hass: HomeAssistant | undefined, timeZone: string, opts: Intl.DateTimeFormatOptions) { + const language = hass?.locale?.language ?? hass?.language ?? 'en'; + const k = `${language}|${timeZone}|${JSON.stringify(opts)}`; + let f = formats.get(k); + if (!f) { + try { + f = new Intl.DateTimeFormat(language, { ...opts, timeZone }); + } catch { + f = new Intl.DateTimeFormat('en', { ...opts, timeZone: 'UTC' }); + } + formats.set(k, f); + } + return f; +} + +/** A weekday's short name, 1 = Monday … 7 = Sunday (1 Jan 2024 was a Monday). */ +function weekdayName(day: number, hass?: HomeAssistant): string { + return dateFormat(hass, 'UTC', { weekday: 'short' }).format(Date.UTC(2024, 0, day)); +} + +/** An hour of the day on the user's clock: "14" or "2 PM". */ +function hourName(hour: number, hass?: HomeAssistant, minutes = false): string { + return dateFormat(hass, 'UTC', { + hour: 'numeric', + ...(minutes ? { minute: '2-digit' } : {}), + hour12: hour12Of(hass), + }).format(Date.UTC(2024, 0, 1, hour)); +} + +/* -------------------------------------------------------------- drawing -------------------------------------------------------------- */ + +interface Cell { + col: number; + row: number; + v: number | null; + /** Tooltip heading. */ + title: () => string; + count?: number; +} + +interface Geometry { + width: number; + height: number; + centre(col: number, row: number): [number, number]; + /** The cell under a point given as fractions of the drawing. */ + at(fx: number, fy: number): [number, number]; +} + +function geometry(cols: number, rows: number, shape: CellShape): Geometry { + if (shape === 'hexagon') { + const r = CELL / Math.sqrt(3); + const pitch = CELL * HEX_ROW; + const width = cols * CELL + CELL / 2; + const height = (rows - 1) * pitch + 2 * r; + return { + width, + height, + centre: (col, row) => [col * CELL + CELL / 2 + (row % 2) * (CELL / 2), r + row * pitch], + at(fx, fy) { + const row = Math.min(rows - 1, Math.max(0, Math.round((fy * height - r) / pitch))); + const col = Math.floor((fx * width - (row % 2) * (CELL / 2)) / CELL); + return [Math.min(cols - 1, Math.max(0, col)), row]; + }, + }; + } + return { + width: cols * CELL, + height: rows * CELL, + centre: (col, row) => [col * CELL + CELL / 2, row * CELL + CELL / 2], + at: (fx, fy) => [Math.min(cols - 1, Math.floor(fx * cols)), Math.min(rows - 1, Math.floor(fy * rows))], + }; +} + +function cellMark( + g: Geometry, + cell: Cell, + shape: CellShape, + fill: string | undefined, + cls = 'cell', +): SVGTemplateResult { + const [cx, cy] = g.centre(cell.col, cell.row); + const style = fill ? `fill:${fill}` : ''; + const empty = fill ? '' : 'empty'; + if (shape === 'hexagon') { + return svg``; + } + const s = CELL - GAP; + return svg``; +} + +const pct = (n: number) => `${Math.round(n * 10000) / 100}%`; + +interface Built { + cells: Cell[]; + cols: number; + rows: number; + rowLabels: { row: number; text: string }[]; + colLabels: { col: number; text: string }[]; +} + +function hoursGrid(s: ChartSeries, frame: HistoryFrame, hass: HomeAssistant, wide: boolean): Built { + const { zone } = frame; + const weekday = (row: number) => ((zone.firstDay - 1 + row) % 7) + 1; + const cells = weekCells(s.points, zone).map((c): Cell => ({ + col: c.hour, + row: c.row, + v: c.v, + count: c.count, + title: () => + `${weekdayName(weekday(c.row), hass)} ${hourName(c.hour, hass, true)} – ${hourName((c.hour + 1) % 24, hass, true)}`, + })); + const every = wide ? 3 : 6; + return { + cells, + cols: 24, + rows: 7, + rowLabels: [0, 1, 2, 3, 4, 5, 6].map((row) => ({ row, text: weekdayName(weekday(row), hass) })), + colLabels: Array.from({ length: 24 / every }, (_, i) => ({ col: i * every, text: hourName(i * every, hass) })), + }; +} + +function calendarGrid(s: ChartSeries, frame: HistoryFrame, hass: HomeAssistant): Built { + const { zone } = frame; + const cal = calendarCells(s.points, zone); + const day = dateFormat(hass, zone.timeZone, { weekday: 'short', day: 'numeric', month: 'short', year: 'numeric' }); + const month = dateFormat(hass, zone.timeZone, { month: 'short' }); + const weekday = (row: number) => ((zone.firstDay - 1 + row) % 7) + 1; + return { + cells: cal.cells.map((c) => ({ col: c.col, row: c.row, v: c.v, title: () => day.format(c.t) })), + cols: Math.max(1, cal.weeks), + rows: 7, + rowLabels: [0, 1, 2, 3, 4, 5, 6].map((row) => ({ row, text: weekdayName(weekday(row), hass) })), + colLabels: cal.months.map((m) => ({ col: m.col, text: month.format(m.t) })), + }; +} + +function legend(s: ChartSeries, extent: [number, number] | undefined, byBands: boolean, ctx: ChartContext) { + const e = s.entity; + const fmt = (v: number) => formatNumber(v, e.decimals, ctx.hass); + if (byBands) { + return html`
+ ${legendItems(e.bands, fmt, e.unit).map( + (item) => + html`${item.text}`, + )} +
`; + } + if (!extent) return nothing; + return html`
+ ${fmt(extent[0])} + + ${withUnit(fmt(extent[1]), e.unit)} +
`; +} + +function drawGrid(s: ChartSeries, frame: HistoryFrame, ctx: ChartContext, multi: boolean): TemplateResult { + const raw = ctx.raw as Raw; + const grid = gridOf(ctx.raw); + const shape = (raw.cell_shape as CellShape | undefined) ?? 'rounded'; + const byBands = raw.color_by === 'bands'; + const width = chartWidth(ctx); + const built = grid === 'hours' ? hoursGrid(s, frame, ctx.hass, width >= 400) : calendarGrid(s, frame, ctx.hass); + const g = geometry(built.cols, built.rows, shape); + // Rough size of a cell on screen (the card less its padding and the weekday labels), so labels + // that would collide on a narrow card are thinned out. + const cellPx = (Math.max(0, width - 72) / g.width) * CELL; + const rowStep = Math.ceil(13 / Math.max(1, cellPx * (shape === 'hexagon' ? HEX_ROW : 1))); + const rowLabels = built.rowLabels.filter((l) => l.row % rowStep === 0); + let last = -Infinity; + const colLabels = built.colLabels.filter((l) => { + if ((l.col - last) * cellPx < 32) return false; + last = l.col; + return true; + }); + const extent = colorExtent( + built.cells.map((c) => c.v), + { + min: isNumber(raw.color_min) ? raw.color_min : undefined, + max: isNumber(raw.color_max) ? raw.color_max : undefined, + }, + ); + const fillOf = (v: number | null) => { + if (v === null) return undefined; + if (byBands) return baseColor(bandFor(v, s.entity.bands).color); + return extent ? shade(s.color, fraction(v, extent)) : s.color; + }; + const picked = pickedIndex(ctx.host); + const mine = picked !== undefined && Math.floor(picked / PER_GRID) === s.index ? picked % PER_GRID : undefined; + const byPos = new Map(built.cells.map((c, i) => [c.col * 8 + c.row, i])); + const tooltip = tooltipEnabled(ctx.raw); + const hover = tooltip + ? pickHandlers(ctx, (fx, fy) => { + const [col, row] = g.at(fx, fy); + const i = byPos.get(col * 8 + row); + return i === undefined ? undefined : s.index * PER_GRID + i; + }) + : undefined; + const pickedCell = mine === undefined ? undefined : built.cells[mine]; + let overlay: TemplateResult | typeof nothing = nothing; + if (pickedCell) { + const [cx, cy] = g.centre(pickedCell.col, pickedCell.row); + const value = + pickedCell.v === null + ? localize(ctx.hass, 'heatmap_empty') + : withUnit(formatNumber(pickedCell.v, s.entity.decimals, ctx.hass), s.entity.unit); + overlay = pickTooltip({ + x: cx / g.width, + y: cy / g.height, + plotWidth: Math.max(0, width - 80), + title: pickedCell.title(), + rows: [ + { color: fillOf(pickedCell.v) ?? 'var(--cd-track)', name: s.entity.name, value }, + ...(pickedCell.count !== undefined && pickedCell.v !== null + ? [{ name: localize(ctx.hass, 'heatmap_hours'), value: String(pickedCell.count) }] + : []), + ], + }); + } + const { show } = ctx.config; + const head = + show.name || show.value || multi + ? html`
+
+ ${byBands ? nothing : html``} + ${show.name || multi ? html`${s.entity.name}` : nothing} + ${show.value ? html`${s.entity.display}` : nothing} +
+
` + : nothing; + const label = localize(ctx.hass, 'heatmap_aria', { name: s.entity.name }); + return html`
+ ${head} +
+ +
+ + ${built.cells.map((c) => cellMark(g, c, shape, fillOf(c.v)))} + ${pickedCell ? cellMark(g, pickedCell, shape, 'none', 'cell picked') : nothing} + + ${overlay} +
+ +
+ ${raw.legend === false ? nothing : legend(s, extent, byBands, ctx)} +
`; +} + +/* -------------------------------------------------------------- editor -------------------------------------------------------------- */ + +const fieldCache = new Map(); + +/** The history fields without grouping (the layout sets it), defaulting to the layout's span. */ +function heatmapFields(grid: HeatmapGrid): FieldBinding[] { + let fields = fieldCache.get(grid); + if (!fields) { + const d = DEFAULTS[grid]; + const own: Record = { period_last: d.last, period_unit: d.unit, source: d.source }; + fields = [ + { name: 'grid', path: ['grid'], default: 'hours' }, + ...HISTORY_FIELDS.filter((f) => f.name !== 'group_by').map((f) => + f.name in own ? { ...f, default: own[f.name] } : f, + ), + { name: 'color_by', path: ['color_by'], default: 'scale' }, + { name: 'cell_shape', path: ['cell_shape'], default: 'rounded' }, + { name: 'color_min', path: ['color_min'] }, + { name: 'color_max', path: ['color_max'] }, + { name: 'legend', path: ['legend'], default: true }, + { name: 'tooltip', path: ['tooltip'], default: true }, + ]; + fieldCache.set(grid, fields); + } + return fields; +} + +function heatmapEditor(ctx: ChartEditorContext): TemplateResult { + const grid = gridOf(ctx.config); + const variant = historyVariant({ ...ctx.config, group_by: undefined }); + const schema = cachedSchema(`heatmap|${variant}`, ctx.hass, () => [ + { name: 'grid', selector: choice(ctx.hass, 'grid', HEATMAP_GRIDS) }, + // The span and source; grouping is the layout's, so only how to combine readings stays. + ...historySchema(ctx.hass, { ...ctx.config, group_by: undefined }).filter( + (s) => !JSON.stringify(s).includes('"group_by"'), + ), + { name: 'aggregate', selector: choice(ctx.hass, 'aggregate', AGGREGATES) }, + { + type: 'grid', + name: '', + schema: [ + { name: 'color_by', selector: choice(ctx.hass, 'color_by', HEATMAP_COLORS) }, + { name: 'cell_shape', selector: choice(ctx.hass, 'cell_shape', CELL_SHAPES) }, + ], + }, + { + type: 'grid', + name: '', + schema: [ + { name: 'color_min', selector: { number: { mode: 'box', step: 'any' } } }, + { name: 'color_max', selector: { number: { mode: 'box', step: 'any' } } }, + ], + }, + { + type: 'grid', + name: '', + schema: [ + { name: 'legend', selector: { boolean: {} } }, + { name: 'tooltip', selector: { boolean: {} } }, + ], + }, + ]); + return chartForm(ctx, 'chart', schema, heatmapFields(grid)); +} + +/* -------------------------------------------------------------- module -------------------------------------------------------------- */ + +export const heatmap: ChartStyleModule = { + layout: 'chart', + group: 'distribution', + height: 220, + styles: css` + ${frameStyles} + ${tooltipStyles} + .hm + .hm { + margin-top: 12px; + } + .hm-body { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + grid-template-rows: auto auto; + column-gap: 6px; + } + .hm-rows { + position: relative; + grid-column: 1; + grid-row: 1; + min-width: 2em; + } + .hm-plot { + position: relative; + grid-column: 2; + grid-row: 1; + } + .hm-plot.hoverable { + cursor: crosshair; + } + .hm-svg { + display: block; + width: 100%; + height: auto; + opacity: 0; + transition: opacity var(--cd-duration) var(--cd-easing); + } + :host([ready]) .hm-svg { + opacity: 1; + } + :host([static]) .hm-svg { + transition: none; + } + .hm-svg .cell.empty { + fill: var(--cd-track); + } + .hm-svg .cell.picked { + fill: none; + stroke: var(--primary-text-color); + stroke-width: 1px; + vector-effect: non-scaling-stroke; + } + .hm-cols { + position: relative; + grid-column: 2; + grid-row: 2; + height: 16px; + } + .hm-label { + position: absolute; + font-size: 11px; + line-height: 1; + color: var(--secondary-text-color); + white-space: nowrap; + pointer-events: none; + } + .hm-rows .hm-label { + right: 0; + transform: translateY(-50%); + } + .hm-cols .hm-label { + top: 4px; + } + .grid-calendar .hm-label { + font-size: 10px; + } + .hm-legend { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 4px 10px; + padding-top: 6px; + font-size: 11px; + color: var(--secondary-text-color); + font-variant-numeric: tabular-nums; + } + .hm-legend.scale { + justify-content: flex-end; + gap: 6px; + } + .hm-gradient { + width: min(160px, 40%); + height: 8px; + border-radius: 4px; + } + .hm-key { + display: inline-flex; + align-items: center; + gap: 4px; + } + .hm-swatch { + width: 10px; + height: 10px; + border-radius: 2px; + } + `, + thumbnail: () => svg` + ${[0, 1, 2, 3].map((r) => + [0, 1, 2, 3, 4, 5, 6].map((c) => { + const f = ((c * 3 + r * 5) % 7) / 6; + return svg``; + }), + )}`, + validate, + editor: heatmapEditor, + entityFields: () => ({ + schema: [{ name: 'series_color', selector: { text: {} } }], + fields: [{ name: 'series_color', path: ['color'] }], + }), + disconnect(host) { + disconnectHistory(host); + stopChartWidth(host); + stopPick(host); + }, + render(ctx) { + const frame = historyFrame({ ...ctx, raw: heatmapRaw(ctx.raw) }); + const nav = periodNav(frame, ctx); + const message = frameMessage(frame, ctx); + if (message) return html`
${nav}${message}
`; + const multi = frame.series.length > 1; + return html`
${nav}${frame.series.map((s) => drawGrid(s, frame, ctx, multi))}
`; + }, +}; diff --git a/src/styles/histogram.ts b/src/styles/histogram.ts new file mode 100644 index 0000000..90402b9 --- /dev/null +++ b/src/styles/histogram.ts @@ -0,0 +1,386 @@ +import { css, html, nothing, svg } from 'lit'; +import type { TemplateResult } from 'lit'; +import { bandFor, baseColor } from '../bands'; +import { stepDecimals, validateAxisOptions } from '../chart/axes'; +import { + VIEW_W, + disconnectHistory, + frameMessage, + frameStyles, + historyFrame, + periodNav, + seriesHeader, + validateHistoryOptions, +} from '../chart/frame'; +import type { ChartSeries } from '../chart/frame'; +import { chartArea, gridStyles, plotWidth, stopChartWidth } from '../chart/grid'; +import { COUNT_MODES, MAX_BINS, binEdges, binTotals, formatDuration, pointWeights } from '../chart/histogram'; +import type { CountMode } from '../chart/histogram'; +import { chartFrame, chartLayout, headerOver, layoutStyles, validateLayout } from '../chart/layout'; +import { tooltipEnabled, tooltipSide, tooltipStyles, validateTooltipOptions } from '../chart/tooltip'; +import { + X_RANGE_FIELDS, + X_RANGE_SCHEMA, + pickHandlers, + pickStyles, + pickTooltip, + pickedIndex, + stopPick, + validateXRange, + valueAxes, + xDecimals, + xRange, +} from '../chart/xy'; +import { + AXES_FIELDS, + HISTORY_FIELDS, + LAYOUT_FIELDS, + axesSchema, + cachedSchema, + chartForm, + choice, + historySchema, + historyVariant, + layoutSchema, +} from '../editor/chart-editors'; +import type { FieldBinding } from '../editor/schemas'; +import { formatNumber, withUnit } from '../format'; +import { localize, registerStrings } from '../localize'; +import type { CardConfig, HomeAssistant } from '../types'; +import type { ChartContext, ChartEditorContext, ChartStyleModule } from './index'; + +const SHOW_AS = ['percent', 'total'] as const; +type ShowAs = (typeof SHOW_AS)[number]; +const BARS = ['side', 'overlap'] as const; +type Bars = (typeof BARS)[number]; +const COLOR_BY = ['series', 'bands'] as const; + +const HOUR = 3_600_000; + +registerStrings('en', { + editor_style_histogram: 'Histogram', + editor_count: 'Count', + editor_count_time: 'Time spent in each range', + editor_count_readings: 'Readings in each range', + editor_show_as: 'Show as', + editor_show_as_percent: 'Share of the whole (%)', + editor_show_as_total: 'Hours or readings', + editor_bins: 'Number of bins (empty: automatic)', + editor_bin_width: 'Bin width (wins over the number)', + editor_bars: 'Two or more entities', + editor_bars_side: 'Bars side by side', + editor_bars_overlap: 'Bars over each other', + editor_hist_x_axis: 'Value labels along the bottom', + editor_bar_color: 'Bar colour', + histogram_readings: '{n} readings', + histogram_reading: '1 reading', +}); + +const isNumber = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v); + +function oneOf(raw: CardConfig, key: string, values: readonly string[]) { + if (raw[key] !== undefined && !values.includes(raw[key] as string)) { + throw new Error(`"${key}" must be one of: ${values.join(', ')}`); + } +} + +interface HistogramOptions { + count: CountMode; + showAs: ShowAs; + bars: Bars; + bands: boolean; + bins?: number; + width?: number; +} + +function options(raw: CardConfig): HistogramOptions { + return { + count: (raw.count as CountMode | undefined) ?? 'time', + showAs: (raw.show_as as ShowAs | undefined) ?? 'percent', + bars: (raw.bars as Bars | undefined) ?? 'side', + bands: raw.color_by === 'bands', + bins: isNumber(raw.bins) ? raw.bins : undefined, + width: isNumber(raw.bin_width) ? raw.bin_width : undefined, + }; +} + +/** One series' share of each bin, ready to draw. */ +interface Binned { + series: ChartSeries; + /** Weight per bin: ms of time, or readings. */ + totals: number[]; + /** What is drawn per bin: a percentage, hours, or readings. */ + heights: number[]; + whole: number; +} + +/** A small step, so a value printed for a bin edge isn't longer than it needs to be. */ +function edgeDecimals(edges: number[]): number { + if (edges.length < 2) return 0; + const width = edges[1] - edges[0]; + const exact = edges.every((e) => Math.abs(e - Number(e.toFixed(stepDecimals(width)))) < width * 1e-6); + return exact ? stepDecimals(width) : Math.min(4, stepDecimals(width) + 1); +} + +function binned(ctx: ChartContext, now: number) { + const frame = historyFrame(ctx, now); + const opts = options(ctx.raw); + const until = Math.min(frame.range.end, now); + const weights = frame.series.map((s) => + pointWeights(s.points, { mode: opts.count, until, statistics: frame.source === 'statistics' }), + ); + let lo = Infinity; + let hi = -Infinity; + frame.series.forEach((s, i) => + s.points.forEach((p, k) => { + if (p.v === null || !(weights[i][k] > 0)) return; + lo = Math.min(lo, p.v); + hi = Math.max(hi, p.v); + }), + ); + const fixed = xRange(ctx.raw); + const side = opts.bars === 'side' && frame.series.length > 1; + const target = Math.max(5, Math.min(24, Math.round(plotWidth(ctx) / (side ? 40 : 28)))); + const edges = + lo === Infinity + ? [] + : binEdges(lo, hi, { bins: opts.bins, width: opts.width, min: fixed.min, max: fixed.max }, target); + const data: Binned[] = frame.series.map((s, i) => { + const totals = binTotals(s.points, weights[i], edges); + const whole = totals.reduce((a, b) => a + b, 0); + const heights = totals.map((t) => + opts.showAs === 'percent' ? (whole > 0 ? (t / whole) * 100 : 0) : opts.count === 'time' ? t / HOUR : t, + ); + return { series: s, totals, heights, whole }; + }); + return { frame, opts, edges, data, side }; +} + +function amountText(total: number, count: CountMode, ctx: ChartContext): string { + if (count === 'time') return formatDuration(total); + return total === 1 + ? localize(ctx.hass, 'histogram_reading') + : localize(ctx.hass, 'histogram_readings', { n: formatNumber(total, 0, ctx.hass) }); +} + +export const histogram: ChartStyleModule = { + layout: 'chart', + group: 'distribution', + height: 240, + styles: css` + ${frameStyles} + ${gridStyles} + ${tooltipStyles} + ${layoutStyles} + ${pickStyles} + .style-histogram .bin { + shape-rendering: crispEdges; + } + .style-histogram .overlap .bin { + fill-opacity: 0.35; + stroke-width: 1.5px; + vector-effect: non-scaling-stroke; + } + `, + thumbnail: () => svg` + + + + + + + + + `, + validate(raw: CardConfig) { + validateHistoryOptions(raw); + validateAxisOptions(raw); + validateTooltipOptions(raw); + validateLayout(raw); + validateXRange(raw); + oneOf(raw, 'count', COUNT_MODES); + oneOf(raw, 'show_as', SHOW_AS); + oneOf(raw, 'bars', BARS); + oneOf(raw, 'color_by', COLOR_BY); + if ( + raw.bins !== undefined && + !(Number.isInteger(raw.bins) && (raw.bins as number) >= 1 && (raw.bins as number) <= MAX_BINS) + ) { + throw new Error(`"bins" must be a whole number from 1 to ${MAX_BINS}`); + } + if (raw.bin_width !== undefined && !(isNumber(raw.bin_width) && raw.bin_width > 0)) { + throw new Error('"bin_width" must be a number above 0'); + } + }, + editor: histogramEditor, + entityFields: () => ({ schema: ENTITY_SCHEMA, fields: ENTITY_FIELDS }), + disconnect(host) { + disconnectHistory(host); + stopChartWidth(host); + stopPick(host); + }, + render(ctx) { + const now = Date.now(); + const { frame, opts, edges, data, side } = binned(ctx, now); + const nav = periodNav(frame, ctx); + const names = seriesHeader(frame, ctx); + const header = nav === nothing && names === nothing ? nothing : html`${nav}${names}`; + const layout = chartLayout(ctx.raw); + const message = + frameMessage(frame, ctx) ?? (edges.length < 2 ? frameMessage({ ...frame, series: [] }, ctx) : undefined); + if (message) return chartFrame(layout.header === 'over' ? { ...layout, header: 'above' } : layout, header, message); + + const top = Math.max(0, ...data.flatMap((d) => d.heights)); + const axes = valueAxes(ctx, [edges[0], edges[edges.length - 1]], [0, top], { + x: { min: edges[0], max: edges[edges.length - 1] }, + yFloor: 0, + }); + const { scales } = axes; + const base = scales.y(Math.max(0, axes.value.domain[0])); + const n = data.length; + const overlap = opts.bars === 'overlap' && n > 1; + const bars = svg`${data.map((d, si) => { + const bins = d.heights.map((h, b) => { + if (!(h > 0)) return nothing; + const x0 = scales.x(edges[b]); + const x1 = scales.x(edges[b + 1]); + const pad = Math.min(4, (x1 - x0) * 0.08); + const slot = (x1 - x0 - 2 * pad) / (side ? n : 1); + const x = x0 + pad + (side ? slot * si : 0); + const y = scales.y(h); + const fill = + opts.bands && d.series.entity.bands.length + ? baseColor(bandFor((edges[b] + edges[b + 1]) / 2, d.series.entity.bands).color) + : d.series.color; + return svg``; + }); + return svg`${bins}`; + })}`; + + const decimals = edgeDecimals(edges); + const xLabel = (v: number) => formatNumber(v, xDecimals(axes), ctx.hass); + const yLabel = (v: number) => + opts.showAs === 'percent' + ? `${formatNumber(v, axes.value.decimals, ctx.hass)}%` + : opts.count === 'time' + ? `${formatNumber(v, axes.value.decimals, ctx.hass)} h` + : formatNumber(v, axes.value.decimals, ctx.hass); + + const tooltip = tooltipEnabled(ctx.raw); + const picked = tooltip ? pickedIndex(ctx.host) : undefined; + let overlay: TemplateResult | typeof nothing = nothing; + if (picked !== undefined && picked < edges.length - 1) { + const unit = data[0]?.series.entity.unit ?? ''; + const from = formatNumber(edges[picked], decimals, ctx.hass); + const to = formatNumber(edges[picked + 1], decimals, ctx.hass); + const x0 = scales.x(edges[picked]) / VIEW_W; + const x1 = scales.x(edges[picked + 1]) / VIEW_W; + // Beside the bin, not over it: from its right edge, or its left when there's no room. + const pw = plotWidth(ctx); + overlay = pickTooltip({ + x: tooltipSide(x1, pw) === 'right' ? x1 : x0, + y: 0, + plotWidth: pw, + title: withUnit(`${from} – ${to}`, unit), + band: [x0, x1], + rows: data.map((d) => { + const share = d.whole > 0 ? (d.totals[picked] / d.whole) * 100 : 0; + return { + color: d.series.color, + name: d.series.entity.name, + value: `${formatNumber(share, share > 0 && share < 1 ? 1 : 0, ctx.hass)}% · ${amountText(d.totals[picked], opts.count, ctx)}`, + }; + }), + }); + } + + const label = localize(ctx.hass, 'chart_aria', { + style: localize(ctx.hass, 'editor_style_histogram'), + names: frame.series.map((s) => s.entity.name).join(', '), + }); + return chartFrame( + layout, + header, + chartArea({ + axes, + ctx, + content: svg`${bars}`, + label, + xLabel, + yLabel, + overlay: html`${layout.header === 'over' ? headerOver(header) : nothing}${overlay}`, + hover: tooltip + ? pickHandlers(ctx, (fx) => { + const v = edges[0] + fx * (edges[edges.length - 1] - edges[0]); + const b = edges.findIndex((e, i) => i < edges.length - 1 && v >= e && v <= edges[i + 1]); + return b < 0 ? undefined : b; + }) + : undefined, + }), + ); + }, +}; + +const ENTITY_FIELDS: FieldBinding[] = [{ name: 'bar_color', path: ['color'] }]; +const ENTITY_SCHEMA = [{ name: 'bar_color', selector: { text: {} } }]; + +const HIST_AXES_FIELDS: FieldBinding[] = AXES_FIELDS.map((f) => + f.name === 'x_axis' ? { ...f, name: 'hist_x_axis' } : f, +); + +const HISTOGRAM_FIELDS: FieldBinding[] = [ + ...HISTORY_FIELDS, + { name: 'count', path: ['count'], default: 'time' }, + { name: 'show_as', path: ['show_as'], default: 'percent' }, + { name: 'bins', path: ['bins'] }, + { name: 'bin_width', path: ['bin_width'] }, + ...X_RANGE_FIELDS, + { name: 'bars', path: ['bars'], default: 'side' }, + { name: 'color_by', path: ['color_by'], default: 'series' }, + ...HIST_AXES_FIELDS, + ...LAYOUT_FIELDS, +]; + +function histogramEditor(ctx: ChartEditorContext): TemplateResult { + const hass: HomeAssistant | undefined = ctx.hass; + const schema = cachedSchema(`histogram|${historyVariant(ctx.config)}`, hass, () => [ + ...historySchema(hass, ctx.config), + { + type: 'grid', + name: '', + schema: [ + { name: 'count', selector: choice(hass, 'count', COUNT_MODES) }, + { name: 'show_as', selector: choice(hass, 'show_as', SHOW_AS) }, + ], + }, + { + type: 'grid', + name: '', + schema: [ + { name: 'bins', selector: { number: { min: 1, max: MAX_BINS, mode: 'box' } } }, + { name: 'bin_width', selector: { number: { min: 0, mode: 'box', step: 'any' } } }, + ], + }, + ...X_RANGE_SCHEMA, + { + type: 'grid', + name: '', + schema: [ + { name: 'bars', selector: choice(hass, 'bars', BARS) }, + { name: 'color_by', selector: choice(hass, 'color_by', COLOR_BY) }, + ], + }, + ...axesSchema(hass).map((part) => + (part as { type?: string }).type === 'grid' + ? { + ...(part as object), + schema: (part as { schema: { name: string }[] }).schema.map((f) => + f.name === 'x_axis' ? { ...f, name: 'hist_x_axis' } : f, + ), + } + : part, + ), + ...layoutSchema(hass), + ]); + return chartForm(ctx, 'chart', schema, HISTOGRAM_FIELDS); +} diff --git a/src/styles/index.ts b/src/styles/index.ts index bd9cf19..6e0645d 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -4,14 +4,19 @@ import type { ResolvedEntity } from '../resolve'; import type { CardConfig, HomeAssistant, NormalizedConfig, ShowOptions, StyleName } from '../types'; import { area } from './area'; import { bar } from './bar'; +import { box } from './box-plot'; +import { bubble } from './bubble'; import { columnChart } from './column'; import { combined } from './combined'; +import { heatmap } from './heatmap'; +import { histogram } from './histogram'; import { line } from './line'; import { needle } from './needle'; import { pictograph } from './pictograph'; import { pie } from './pie'; import { arc, ring } from './radial'; import { rings } from './rings'; +import { scatter } from './scatter'; import { segmented } from './segmented'; import { tank } from './tank'; import { thin } from './thin'; @@ -120,4 +125,9 @@ export const STYLES: Record = { rings, pictograph, waffle, + histogram, + scatter, + bubble, + heatmap, + box, }; diff --git a/src/styles/scatter.ts b/src/styles/scatter.ts new file mode 100644 index 0000000..8948b35 --- /dev/null +++ b/src/styles/scatter.ts @@ -0,0 +1,25 @@ +import { svg } from 'lit'; +import { disconnectXY, renderXY, validateXY, xyEditor, xyEntityFields, xyStyles } from '../chart/scatter-plot'; +import type { ChartStyleModule } from './index'; + +/** One entity's readings against another's (or against time), as dots. */ +export const scatter: ChartStyleModule = { + layout: 'chart', + group: 'distribution', + height: 240, + styles: xyStyles, + thumbnail: () => svg` + + + + + + + + `, + validate: (raw) => validateXY(raw, 'scatter'), + editor: xyEditor('scatter'), + entityFields: () => xyEntityFields(), + disconnect: disconnectXY, + render: (ctx) => renderXY(ctx, 'scatter'), +}; diff --git a/src/types.ts b/src/types.ts index eb318e5..6c7cbbe 100644 --- a/src/types.ts +++ b/src/types.ts @@ -14,7 +14,12 @@ export type StyleName = | 'pie' | 'rings' | 'pictograph' - | 'waffle'; + | 'waffle' + | 'histogram' + | 'scatter' + | 'bubble' + | 'heatmap' + | 'box'; export const STYLE_NAMES: StyleName[] = [ 'bar', 'thin', @@ -31,6 +36,11 @@ export const STYLE_NAMES: StyleName[] = [ 'rings', 'pictograph', 'waffle', + 'histogram', + 'scatter', + 'bubble', + 'heatmap', + 'box', ]; export type SortMode = 'none' | 'ascending' | 'descending'; /** A number, or a string naming an entity whose state supplies the number. */ diff --git a/test/box-plot.test.ts b/test/box-plot.test.ts new file mode 100644 index 0000000..753952d --- /dev/null +++ b/test/box-plot.test.ts @@ -0,0 +1,318 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import '../src/chartdeck-card'; +import { boxStats, heldSamples, jitter, rowSamples, spread, weightedQuantile, MAX_OUTLIERS } from '../src/chart/box'; +import { boxPlace, categoryTicks, pickBox } from '../src/styles/box-plot'; +import type { Box } from '../src/styles/box-plot'; +import type { CardConfig, HomeAssistant } from '../src/types'; +import { makeHass } from './helpers/hass'; + +class Stub extends HTMLElement { + [key: string]: unknown; +} +for (const tag of [ + 'ha-form', + 'ha-expansion-panel', + 'ha-selector', + 'ha-entity-picker', + 'ha-icon-picker', + 'ha-icon-button', + 'ha-switch', +]) { + if (!customElements.get(tag)) customElements.define(tag, class extends Stub {}); +} +await import('../src/editor/chartdeck-card-editor'); + +const eq = (vs: number[]) => vs.map((v) => ({ v, w: 1 })); + +describe('box maths', () => { + it('takes textbook quartiles from equal weights', () => { + const s = eq([1, 2, 3, 4]); + expect(weightedQuantile(s, 0.5)).toBe(2.5); + expect(weightedQuantile(s, 0)).toBe(1); + expect(weightedQuantile(s, 1)).toBe(4); + expect(weightedQuantile(eq([7]), 0.25)).toBe(7); + }); + + it('weights history by how long each value held', () => { + // 20 for 9 hours, a flicker to 30 for ten minutes in all, then 20 again. + const H = 3_600_000; + const pts = [ + { t: 0, v: 20 }, + { t: 9 * H, v: 30 }, + { t: 9 * H + 600_000, v: null }, + { t: 9.5 * H, v: 20 }, + ]; + const samples = heldSamples(pts, 0, 10 * H); + expect(samples).toEqual([ + { v: 20, w: 9 * H }, + { v: 30, w: 600_000 }, + { v: 20, w: 0.5 * H }, + ]); + const stats = boxStats(samples, 'tukey')!; + expect(stats.median).toBe(20); + expect(stats.count).toBe(3); + // Only the part inside the window counts; nothing before the first reading. + expect(heldSamples(pts, 8 * H, 9 * H)).toEqual([{ v: 20, w: H }]); + expect(heldSamples(pts, 5, 5)).toEqual([]); + expect(heldSamples([{ t: 10, v: 1 }], 0, 5)).toEqual([]); + }); + + it('counts each statistics row once and widens the range with their extremes', () => { + const rows = [ + { t: 0, v: 5, min: 1, max: 6, carried: true }, + { t: 10, v: 10, min: 8, max: 12 }, + { t: 20, v: 12, min: null, max: 20 }, + { t: 30, v: null }, + { t: 40, v: 11 }, + ]; + const { samples, extremes } = rowSamples(rows, 0, 40); + expect(samples).toEqual(eq([10, 12])); + expect(extremes).toEqual([8, 20]); + expect(rowSamples(rows, 50, 60)).toEqual({ samples: [], extremes: undefined }); + const range = boxStats(samples, 'range', extremes)!; + expect([range.low, range.high, range.outliers]).toEqual([8, 20, []]); + }); + + it('draws Tukey whiskers to the last reading within 1.5 × the box, the rest outliers', () => { + const stats = boxStats(eq([1, 2, 3, 4, 5, 6, 7, 8, 30, -20]), 'tukey')!; + expect(stats.low).toBe(1); + expect(stats.high).toBe(8); + expect(stats.outliers).toEqual([-20, 30]); + expect(stats.min).toBe(-20); + expect(stats.max).toBe(30); + expect(stats.mean).toBeCloseTo(4.6); + expect(boxStats([], 'tukey')).toBeUndefined(); + expect(boxStats([{ v: 3, w: 0 }], 'range')).toBeUndefined(); + }); + + it('keeps only the furthest outliers when there are very many', () => { + const many = [...eq(Array.from({ length: 400 }, () => 0)), ...eq(Array.from({ length: 100 }, (_, i) => 100 + i))]; + const stats = boxStats(many, 'tukey')!; + expect(stats.outliers).toHaveLength(MAX_OUTLIERS); + expect(stats.outliers.at(-1)).toBe(199); + expect(stats.outliers[0]).toBe(140); + }); + + it('thins readings evenly and jitters them the same way every time', () => { + expect(spread([1, 2, 3], 5)).toEqual([1, 2, 3]); + expect(spread([0, 1, 2, 3, 4, 5, 6, 7, 8, 9], 5)).toEqual([0, 2, 4, 6, 8]); + expect(jitter(3)).toBe(jitter(3)); + for (let i = 0; i < 50; i++) { + expect(jitter(i)).toBeGreaterThanOrEqual(-1); + expect(jitter(i)).toBeLessThan(1); + } + }); + + it('places boxes side by side and picks the one under the pointer', () => { + expect(categoryTicks(10, 4)).toEqual([0, 3, 6, 9]); + expect(categoryTicks(3, 0)).toEqual([0]); + const a = boxPlace({ category: 1, slot: 0 }, 2, Infinity); + const b = boxPlace({ category: 1, slot: 1 }, 2, Infinity); + expect(a.centre).toBeLessThan(1); + expect(b.centre).toBeGreaterThan(1); + expect(boxPlace({ category: 0, slot: 0 }, 1, 0.1).half).toBe(0.1); + const boxes = [ + { category: 0, slot: 0 }, + { category: 1, slot: 0 }, + { category: 1, slot: 1 }, + ] as Box[]; + // Two categories: the second spans 0.5–1 of the axis, its slots either side of 0.75. + expect(pickBox(boxes, 2, 2, 0.7)).toBe(1); + expect(pickBox(boxes, 2, 2, 0.8)).toBe(2); + expect(pickBox(boxes, 2, 2, 0.26)).toBeUndefined(); + expect(pickBox(boxes, 2, 2, 0.51)).toBeUndefined(); + expect(pickBox(boxes, 2, 1, 0.2)).toBe(0); + }); +}); + +type Card = HTMLElement & { setConfig(c: CardConfig): void; hass?: HomeAssistant; updateComplete: Promise }; + +const now = Date.now(); +const H = 3_600_000; +const sec = (msAgo: number) => (now - msAgo) / 1000; + +function hassWith(history: Record, statistics: Record = {}) { + const hass = makeHass({ + 'sensor.in': ['21', { friendly_name: 'Inside', unit_of_measurement: '°C' }], + 'sensor.out': ['9', { friendly_name: 'Outside', unit_of_measurement: '°C' }], + 'sensor.off': ['unavailable', { friendly_name: 'Off' }], + }); + hass.callWS = vi.fn(async (msg: Record) => + msg.type === 'recorder/statistics_during_period' ? statistics : history, + ) as HomeAssistant['callWS']; + return hass; +} + +const history = { + 'sensor.in': [ + { s: '19', lu: sec(20 * H) }, + { s: '20', lu: sec(15 * H) }, + { s: '22', lu: sec(10 * H) }, + { s: '40', lu: sec(9 * H) }, + { s: '21', lu: sec(8.9 * H) }, + { s: '23', lu: sec(4 * H) }, + ], + 'sensor.out': [ + { s: '5', lu: sec(20 * H) }, + { s: '9', lu: sec(4 * H) }, + ], + 'sensor.off': [{ s: 'unavailable', lu: sec(20 * H) }], +}; + +async function mount(config: Partial, hass = hassWith(history)) { + const card = document.createElement('chartdeck-card') as Card; + card.setConfig({ + type: 'custom:chartdeck-card', + style: 'box', + entities: ['sensor.in', 'sensor.out'], + ...config, + } as CardConfig); + card.hass = hass; + document.body.appendChild(card); + await card.updateComplete; + await new Promise((r) => setTimeout(r, 0)); + await card.updateComplete; + return card; +} + +const q = (card: Card, sel: string) => card.shadowRoot!.querySelector(sel); +const qa = (card: Card, sel: string) => [...card.shadowRoot!.querySelectorAll(sel)]; + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('box plot', () => { + it('draws one upright box per entity, named along the bottom, with the outlier as a dot', async () => { + const card = await mount({ entities: ['sensor.in', 'sensor.out', 'sensor.off', 'sensor.gone'] }); + expect(qa(card, 'g.box')).toHaveLength(2); + expect(qa(card, '.x-axis .axis-label').map((e) => e.textContent!.trim())).toEqual([ + 'Inside', + 'Outside', + 'Off', + 'sensor.gone', + ]); + // 40 °C held for six minutes is beyond the whisker. + expect(qa(card, 'g.box')[0].querySelector('.outliers')).not.toBeNull(); + expect(q(card, 'svg.plot')!.getAttribute('aria-label')).toBe('Box plot of Inside, Outside, Off, sensor.gone'); + expect(q(card, '.content.style-box')).not.toBeNull(); + }); + + it('groups by day into side-by-side boxes, shows readings, and turns on its side', async () => { + const card = await mount({ + group_by: 'day', + period: { last: 2, unit: 'day' }, + readings: true, + } as Partial); + const boxes = qa(card, 'g.box'); + expect(boxes.length).toBeGreaterThanOrEqual(2); + expect(new Set(boxes.map((b) => b.getAttribute('data-series')))).toEqual(new Set(['0', '1'])); + expect(qa(card, '.readings').length).toBe(boxes.length); + const side = await mount({ orientation: 'horizontal', whiskers: 'range' } as Partial); + expect(qa(side, '.y-axis .axis-label').map((e) => e.textContent!.trim())).toEqual(['Inside', 'Outside']); + expect(qa(side, '.x-axis .axis-label').every((e) => /^-?[\d.,]+$/.test(e.textContent!.trim()))).toBe(true); + expect(qa(side, '.outliers')).toHaveLength(0); + }); + + it('reads statistics rows in hours when grouped', async () => { + const t0 = Math.floor((now - 30 * H) / H) * H; + const rows = Array.from({ length: 30 }, (_, i) => ({ + start: t0 + i * H, + end: t0 + (i + 1) * H, + mean: 10 + (i % 5), + min: 5, + max: 20, + })); + const hass = hassWith({}, { 'sensor.in': rows }); + const card = await mount( + { source: 'statistics', group_by: 'day', entities: ['sensor.in'] } as Partial, + hass, + ); + const call = (hass.callWS as ReturnType).mock.calls[0][0]; + expect(call.period).toBe('hour'); + expect(call.types).toEqual(['mean', 'min', 'max']); + expect(qa(card, 'g.box').length).toBeGreaterThanOrEqual(1); + }); + + it('shows the picked box in a tooltip', async () => { + const card = await mount({ group_by: 'day', period: { last: 1, unit: 'day' } } as Partial); + const area = q(card, '.plot-area') as HTMLElement; + area.getBoundingClientRect = () => ({ left: 0, top: 0, width: 400, height: 180 }) as DOMRect; + // Find a spot with a box: the last day's first slot sits just left of its centre. + const ticks = qa(card, '.x-axis .axis-label').length; + let tip: Element | null = null; + for (let x = 1; x < 400 && !tip; x += 4) { + area.dispatchEvent(new PointerEvent('pointermove', { clientX: x, clientY: 50, pointerType: 'mouse' })); + await card.updateComplete; + tip = q(card, '.tooltip'); + } + expect(ticks).toBeGreaterThan(0); + expect(tip).not.toBeNull(); + const names = qa(card, '.tooltip-name').map((e) => e.textContent); + expect(names).toEqual(['Highest', 'Upper quartile', 'Median', 'Lower quartile', 'Lowest', 'Average', 'Readings']); + expect(q(card, '.pick-band')).not.toBeNull(); + area.dispatchEvent(new PointerEvent('pointerleave', { pointerType: 'mouse' })); + await card.updateComplete; + expect(q(card, '.tooltip')).toBeNull(); + }); + + it('shows a tooltip on its side, and none when turned off', async () => { + const card = await mount({ orientation: 'horizontal' } as Partial); + const area = q(card, '.plot-area') as HTMLElement; + area.getBoundingClientRect = () => ({ left: 0, top: 0, width: 400, height: 180 }) as DOMRect; + area.dispatchEvent(new PointerEvent('pointermove', { clientX: 100, clientY: 40, pointerType: 'mouse' })); + await card.updateComplete; + expect(q(card, '.tooltip-time')!.textContent).toBe('Inside'); + expect(qa(card, '.tooltip-name')).toContainEqual(expect.objectContaining({ textContent: 'Outliers' })); + const off = await mount({ tooltip: false } as Partial); + expect(q(off, '.plot-area.hoverable')).toBeNull(); + }); + + it('says when there is nothing to draw', async () => { + const empty = await mount({}, hassWith({})); + expect(q(empty, '.chart-message')!.textContent).toBe('No readings in this period.'); + }); + + it('checks its options', () => { + const card = document.createElement('chartdeck-card') as Card; + const set = (extra: Record) => () => + card.setConfig({ type: 'x', style: 'box', entities: ['sensor.in'], ...extra } as CardConfig); + expect(set({ orientation: 'sideways' })).toThrow('"orientation" must be one of: vertical, horizontal'); + expect(set({ whiskers: 'long' })).toThrow('"whiskers" must be one of: tukey, range'); + expect(set({ readings: 'yes' })).toThrow('"readings" must be true or false'); + expect(set({ group_by: 'year' })).toThrow(/group_by/); + expect(set({ orientation: 'horizontal', whiskers: 'range', readings: true })).not.toThrow(); + }); +}); + +describe('box plot editor', () => { + it('writes orientation, whiskers and readings, without a "combine with" choice', async () => { + const ed = document.createElement('chartdeck-card-editor') as HTMLElement & { + setConfig(c: CardConfig): void; + hass?: unknown; + updateComplete: Promise; + }; + const events: CardConfig[] = []; + ed.addEventListener('config-changed', (e) => events.push((e as CustomEvent).detail.config)); + ed.hass = makeHass({}); + const raw = { type: 'custom:chartdeck-card', style: 'box', group_by: 'day', entities: ['sensor.in'] } as CardConfig; + ed.setConfig(raw); + document.body.appendChild(ed); + await ed.updateComplete; + const form = ed.shadowRoot!.querySelector('.section-chart ha-form') as Stub; + const data = form.data as Record; + expect(data).toMatchObject({ orientation: 'vertical', whiskers: 'tukey', readings: false, group_by: 'day' }); + const names = JSON.stringify(form.schema); + expect(names).toContain('"orientation"'); + expect(names).not.toContain('"aggregate"'); + form.dispatchEvent( + new CustomEvent('value-changed', { + detail: { value: { ...data, orientation: 'horizontal', whiskers: 'range', readings: true } }, + }), + ); + expect(events.at(-1)).toEqual({ ...raw, orientation: 'horizontal', whiskers: 'range', readings: true }); + const list = ed.shadowRoot!.querySelector('cd-entities-editor') as Stub; + const fields = (list.chartFields as { fields: { name: string; path: string[] }[] }).fields; + expect(fields).toEqual([{ name: 'box_color', path: ['color'] }]); + }); +}); diff --git a/test/bubble.test.ts b/test/bubble.test.ts new file mode 100644 index 0000000..69c1611 --- /dev/null +++ b/test/bubble.test.ts @@ -0,0 +1,179 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import '../src/chartdeck-card'; +import type { CardConfig, HomeAssistant } from '../src/types'; +import { makeHass } from './helpers/hass'; + +class Stub extends HTMLElement { + [key: string]: unknown; +} +for (const tag of [ + 'ha-form', + 'ha-expansion-panel', + 'ha-selector', + 'ha-entity-picker', + 'ha-icon-picker', + 'ha-icon-button', + 'ha-switch', +]) { + if (!customElements.get(tag)) customElements.define(tag, class extends Stub {}); +} +await import('../src/editor/chartdeck-card-editor'); + +type Card = HTMLElement & { setConfig(c: CardConfig): void; hass?: HomeAssistant; updateComplete: Promise }; + +const now = Date.now(); +const sec = (msAgo: number) => (now - msAgo) / 1000; +const H = 3_600_000; + +const history = { + 'sensor.out': [ + { s: '2', lu: sec(20 * H) }, + { s: '5', lu: sec(14 * H) }, + { s: '9', lu: sec(8 * H) }, + { s: 'unavailable', lu: sec(5 * H) }, + { s: '12', lu: sec(3 * H) }, + ], + 'sensor.heat': [ + { s: '3000', lu: sec(19 * H) }, + { s: '2200', lu: sec(13 * H) }, + { s: '900', lu: sec(7 * H) }, + { s: '400', lu: sec(2 * H) }, + ], + 'sensor.flow': [ + { s: '40', lu: sec(18 * H) }, + { s: '45', lu: sec(6 * H) }, + ], +}; + +function hassWith(data: Record = history) { + const hass = makeHass({ + 'sensor.out': ['12', { friendly_name: 'Outside', unit_of_measurement: '°C' }], + 'sensor.heat': ['400', { friendly_name: 'Heating', unit_of_measurement: 'W' }], + 'sensor.flow': ['45', { friendly_name: 'Flow', unit_of_measurement: '°C' }], + }); + hass.callWS = vi.fn(async () => data) as HomeAssistant['callWS']; + return hass; +} + +async function mount(config: Partial, hass = hassWith()) { + const card = document.createElement('chartdeck-card') as Card; + card.setConfig({ + type: 'custom:chartdeck-card', + style: 'bubble', + entities: ['sensor.out', 'sensor.heat', 'sensor.flow'], + ...config, + } as CardConfig); + card.hass = hass; + document.body.appendChild(card); + await card.updateComplete; + await new Promise((r) => setTimeout(r, 0)); + await card.updateComplete; + return card; +} + +const q = (card: Card, sel: string) => card.shadowRoot!.querySelector(sel); +const qa = (card: Card, sel: string) => [...card.shadowRoot!.querySelectorAll(sel)]; + +function point(el: Element, type: string, fx: number, fy: number, pointerType = 'mouse') { + el.getBoundingClientRect = () => ({ left: 0, top: 0, width: 1000, height: 200, right: 1000, bottom: 200 }) as DOMRect; + const ev = new MouseEvent(type, { bubbles: true, clientX: fx * 1000, clientY: fy * 200 }); + Object.defineProperty(ev, 'pointerType', { value: pointerType }); + el.dispatchEvent(ev); +} + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('bubble chart', () => { + it('sizes each paired dot by the third entity, largest drawn first, with a size legend', async () => { + const card = await mount({}); + const bubbles = qa(card, 'path.bubble'); + // Every time any of the three changed once all three had a value, less the unavailable stretch. + expect(bubbles).toHaveLength(8); + const widths = bubbles.map((b) => parseFloat((b as SVGElement).style.strokeWidth)); + expect(widths).toEqual([...widths].sort((a, b) => b - a)); + expect(Math.max(...widths)).toBe(32); + expect(Math.min(...widths)).toBe(6); + expect(bubbles[0].getAttribute('stroke')).toContain('--primary-color'); + expect(q(card, 'svg.plot')!.getAttribute('aria-label')).toBe('Bubble chart of Outside, Heating, Flow'); + const roles = qa(card, '.series-item .role').map((e) => e.textContent!.trim()); + expect(roles).toEqual(['Across', 'Size']); + const legend = q(card, '.size-legend')!.textContent!.replace(/\s+/g, ' ').trim(); + expect(legend).toBe('Flow 40.0 °C 45.0 °C'); + expect(qa(card, '.size-dot')).toHaveLength(2); + }); + + it('uses the y entity colour and the card sizes, and shows all three readings on hover', async () => { + const card = await mount({ + min_size: 4, + max_size: 20, + x_axis: false, + y_axis: false, + entities: ['sensor.out', { entity: 'sensor.heat', color: 'green' }, 'sensor.flow'], + } as Partial); + const bubbles = qa(card, 'path.bubble'); + expect(bubbles[0].getAttribute('stroke')).toContain('--success-color'); + const widths = bubbles.map((b) => parseFloat((b as SVGElement).style.strokeWidth)); + expect(Math.max(...widths)).toBe(20); + expect(Math.min(...widths)).toBe(4); + const [x, y] = bubbles[0] + .getAttribute('d')! + .match(/M([\d.-]+),([\d.-]+)/)! + .slice(1) + .map(Number); + point(q(card, '.plot-area')!, 'pointermove', x / 1000, y / 200); + await card.updateComplete; + const rows = qa(card, '.tooltip-row').map((e) => e.textContent!.replace(/\s+/g, ' ').trim()); + expect(rows).toHaveLength(3); + expect(rows[2]).toBe('Flow 45.0 °C'); + }); + + it('asks for three entities, and shows one size when every size reading is the same', async () => { + const two = await mount({ entities: ['sensor.out', 'sensor.heat'] }); + expect(q(two, '.chart-message')!.textContent).toContain('Add three entities'); + const flat = await mount({}, hassWith({ ...history, 'sensor.flow': [{ s: '40', lu: sec(30 * H) }] })); + expect(qa(flat, '.size-dot')).toHaveLength(1); + }); + + it('checks its options', () => { + const card = document.createElement('chartdeck-card') as Card; + const set = (extra: Record) => () => + card.setConfig({ type: 'x', style: 'bubble', entities: ['sensor.out'], ...extra } as CardConfig); + expect(set({ min_size: 40, max_size: 10 })).toThrow(/min_size/); + expect(set({ max_size: 0 })).toThrow(/max_size/); + }); +}); + +describe('bubble chart editor', () => { + it('writes the bubble sizes and jitter', async () => { + const ed = document.createElement('chartdeck-card-editor') as HTMLElement & { + setConfig(c: CardConfig): void; + hass?: unknown; + updateComplete: Promise; + }; + const events: CardConfig[] = []; + ed.addEventListener('config-changed', (e) => events.push((e as CustomEvent).detail.config)); + ed.hass = makeHass({}); + const raw = { + type: 'custom:chartdeck-card', + style: 'bubble', + entities: ['sensor.out', 'sensor.heat', 'sensor.flow'], + } as CardConfig; + ed.setConfig(raw); + document.body.appendChild(ed); + await ed.updateComplete; + const form = ed.shadowRoot!.querySelector('.section-chart ha-form') as Stub; + expect(form.data).toMatchObject({ min_size: 6, max_size: 32, jitter: 0 }); + expect(form.data).not.toHaveProperty('against'); + form.dispatchEvent( + new CustomEvent('value-changed', { + detail: { value: { ...(form.data as object), min_size: 3, max_size: 40, jitter: 2 } }, + }), + ); + expect(events.at(-1)).toEqual({ ...raw, min_size: 3, max_size: 40, jitter: 2 }); + const list = ed.shadowRoot!.querySelector('cd-entities-editor') as Stub; + const fields = (list.chartFields as { fields: { name: string }[] }).fields.map((f) => f.name); + expect(fields).toEqual(['dot_color']); + }); +}); diff --git a/test/chart-style.test.ts b/test/chart-style.test.ts index b024369..2ec4d94 100644 --- a/test/chart-style.test.ts +++ b/test/chart-style.test.ts @@ -133,7 +133,7 @@ describe('editor with chart styles', () => { it('groups the style picker and draws chart thumbnails from the style', async () => { const ed = await editor({}); const groups = [...ed.shadowRoot!.querySelectorAll('.styles')].map((g) => g.getAttribute('data-group')); - expect(groups).toEqual(['bars', 'gauges', 'history', 'totals']); + expect(groups.slice(0, 3)).toEqual(['bars', 'gauges', 'history']); expect(ed.shadowRoot!.querySelector('.style[data-style="fake"] svg rect')).not.toBeNull(); expect(ed.shadowRoot!.querySelector('.style-group')!.textContent).toBe('Bars'); }); diff --git a/test/heatmap.test.ts b/test/heatmap.test.ts new file mode 100644 index 0000000..b5e528b --- /dev/null +++ b/test/heatmap.test.ts @@ -0,0 +1,299 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import '../src/chartdeck-card'; +import { calendarCells, colorExtent, fraction, hexPath, shade, weekCells } from '../src/chart/heatmap-grid'; +import { heatmapRaw } from '../src/styles/heatmap'; +import type { CardConfig, HomeAssistant } from '../src/types'; +import { makeHass } from './helpers/hass'; + +class Stub extends HTMLElement { + [key: string]: unknown; +} +for (const tag of [ + 'ha-form', + 'ha-expansion-panel', + 'ha-selector', + 'ha-entity-picker', + 'ha-icon-picker', + 'ha-icon-button', + 'ha-switch', +]) { + if (!customElements.get(tag)) customElements.define(tag, class extends Stub {}); +} +await import('../src/editor/chartdeck-card-editor'); + +const H = 3_600_000; +const D = 24 * H; +const utc = { timeZone: 'UTC', firstDay: 1 }; + +describe('heatmap maths', () => { + it('folds hours into weekday rows and hour columns, averaging across weeks', () => { + const mon14 = Date.UTC(2024, 0, 1, 14); + const cells = weekCells( + [ + { t: mon14, v: 10 }, + { t: mon14 + 7 * D, v: 20 }, + { t: mon14 + D, v: null }, + { t: mon14 + D + H, v: 4 }, + ], + utc, + ); + expect(cells).toHaveLength(168); + expect(cells[14]).toEqual({ row: 0, hour: 14, v: 15, count: 2 }); + expect(cells[24 + 14].v).toBeNull(); + expect(cells[24 + 15].v).toBe(4); + // Weeks from Sunday: Monday is the second row. + expect(weekCells([{ t: mon14, v: 1 }], { timeZone: 'UTC', firstDay: 7 })[24 + 14].v).toBe(1); + // The wall clock in the zone decides the hour: 14:00 UTC is 15:00 in Paris in winter. + expect(weekCells([{ t: mon14, v: 1 }], { timeZone: 'Europe/Paris', firstDay: 1 })[15].v).toBe(1); + }); + + it('lays days out in week columns from the first weekday, naming each month where it starts', () => { + const days = (from: number, n: number) => Array.from({ length: n }, (_, i) => ({ t: from + i * D, v: i })); + const jan = calendarCells(days(Date.UTC(2024, 0, 1), 70), utc); + expect(jan.cells[0]).toEqual({ t: Date.UTC(2024, 0, 1), col: 0, row: 0, v: 0 }); + expect(jan.cells[9]).toMatchObject({ col: 1, row: 2 }); + expect(jan.weeks).toBe(10); + // 1 Feb 2024 was a Thursday, in the week of Monday 29 January: column 4. + expect(jan.months.map((m) => m.col)).toEqual([0, 4, 8]); + // Opening on 31 January: no room to name January before February starts. + const late = calendarCells(days(Date.UTC(2024, 0, 31), 10), utc); + expect(late.cells[0]).toMatchObject({ col: 0, row: 2 }); + expect(late.months).toEqual([{ col: 0, t: Date.UTC(2024, 1, 1) }]); + expect(calendarCells([], utc)).toEqual({ cells: [], weeks: 0, months: [] }); + }); + + it('sets the colour scale from the values or fixed ends', () => { + expect(colorExtent([null, 3, 9])).toEqual([3, 9]); + expect(colorExtent([null])).toBeUndefined(); + expect(colorExtent([5, 5])).toEqual([5, 6]); + expect(colorExtent([5, 8], { max: 2 })).toEqual([1, 2]); + expect(colorExtent([], { min: 0, max: 10 })).toEqual([0, 10]); + expect(fraction(15, [0, 10])).toBe(1); + expect(fraction(-1, [0, 10])).toBe(0); + expect(fraction(5, [0, 10])).toBe(0.5); + expect(shade('red', 1)).toBe('red'); + expect(shade('red', 0)).toBe('color-mix(in srgb, red 12%, var(--card-background-color, #fff))'); + expect(hexPath(5, 5, 10).match(/[ML]/g)).toHaveLength(6); + }); + + it('fills in each layout’s grouping, span and source', () => { + expect(heatmapRaw({ type: 'x' })).toMatchObject({ + group_by: 'hour', + source: 'history', + period: { last: 4, unit: 'week' }, + }); + expect(heatmapRaw({ type: 'x', grid: 'calendar', group_by: 'week' })).toMatchObject({ + group_by: 'day', + source: 'statistics', + period: { last: 12, unit: 'month' }, + }); + expect(heatmapRaw({ type: 'x', grid: 'calendar', source: 'history', period: { calendar: 'year' } })).toMatchObject({ + source: 'history', + period: { calendar: 'year' }, + }); + }); +}); + +type Card = HTMLElement & { setConfig(c: CardConfig): void; hass?: HomeAssistant; updateComplete: Promise }; + +function hassWithData() { + const hass = makeHass({ + 'sensor.power': ['300', { friendly_name: 'Power', unit_of_measurement: 'W' }], + 'sensor.down': ['unavailable', { friendly_name: 'Down' }], + }); + // The server's zone is the browser's: statistics come as whole days. + hass.config = { time_zone: Intl.DateTimeFormat().resolvedOptions().timeZone }; + const now = Date.now(); + hass.callWS = vi.fn(async (msg: Record) => { + if (msg.type === 'history/history_during_period') { + const rows = []; + for (let t = now - 30 * D; t < now; t += H) rows.push({ s: String(Math.round(t / H) % 24), lu: t / 1000 }); + return { 'sensor.power': rows }; + } + const rows = []; + for (let t = Math.floor((now - 400 * D) / D) * D; t < now; t += D) { + rows.push({ start: t, end: t + D, mean: (t / D) % 10, state: 1, change: 2 }); + } + return { 'sensor.power': rows }; + }) as HomeAssistant['callWS']; + return hass; +} + +async function mount(config: Partial, hass = hassWithData()) { + const card = document.createElement('chartdeck-card') as Card; + card.setConfig({ + type: 'custom:chartdeck-card', + style: 'heatmap', + entities: ['sensor.power'], + ...config, + } as CardConfig); + card.hass = hass; + document.body.appendChild(card); + await card.updateComplete; + await new Promise((r) => setTimeout(r, 0)); + await card.updateComplete; + return card; +} + +const q = (card: Card, sel: string) => card.shadowRoot!.querySelector(sel); +const qa = (card: Card, sel: string) => [...card.shadowRoot!.querySelectorAll(sel)]; + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('heatmap card', () => { + it('draws an hour-by-weekday grid of rounded cells with a gradient legend', async () => { + const card = await mount({}); + expect(qa(card, '.hm')).toHaveLength(1); + expect(qa(card, 'rect.cell')).toHaveLength(168); + expect(q(card, 'rect.cell')!.getAttribute('rx')).toBe('2'); + expect(q(card, 'svg.hm-svg')!.getAttribute('role')).toBe('img'); + expect(q(card, 'svg.hm-svg')!.getAttribute('aria-label')).toBe('Heatmap of Power'); + expect(qa(card, '.hm-rows .hm-label').length).toBeGreaterThan(0); + expect(qa(card, '.hm-cols .hm-label').length).toBeGreaterThan(0); + expect(q(card, '.hm-gradient')).not.toBeNull(); + const ends = qa(card, '.hm-legend .hm-end').map((e) => e.textContent); + expect(ends).toHaveLength(2); + expect(ends[1]).toMatch(/ W$/); + // History asked for, from four weeks back. + const call = (card.hass!.callWS as ReturnType).mock.calls[0][0]; + expect(call.type).toBe('history/history_during_period'); + expect(Date.now() - Date.parse(call.start_time)).toBeGreaterThan(27 * D); + }); + + it('shows a cell’s value on hover and opens the entity from its name', async () => { + const card = await mount({}); + const plot = q(card, '.hm-plot') as HTMLElement; + plot.dispatchEvent(new MouseEvent('pointermove', { bubbles: true, clientX: 0, clientY: 0 })); + await card.updateComplete; + expect(q(card, '.tooltip')).not.toBeNull(); + expect(q(card, '.cell.picked')).not.toBeNull(); + expect(qa(card, '.tooltip-row')).toHaveLength(2); + plot.dispatchEvent(new MouseEvent('pointerleave', { bubbles: true })); + await card.updateComplete; + expect(q(card, '.tooltip')).toBeNull(); + const fired: string[] = []; + card.addEventListener('hass-action', (e) => fired.push((e as CustomEvent).detail.config.entity)); + const item = q(card, '.series-item') as HTMLElement; + item.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true })); + item.dispatchEvent(new MouseEvent('pointerup', { bubbles: true })); + expect(fired).toEqual(['sensor.power']); + }); + + it('draws a calendar from daily statistics, in hexagons coloured by band, one grid per entity', async () => { + const card = await mount({ + grid: 'calendar', + cell_shape: 'hexagon', + color_by: 'bands', + severity: [ + { from: 0, color: 'blue' }, + { from: 5, color: 'red' }, + ], + entities: ['sensor.power', 'sensor.down', 'sensor.missing'], + } as Partial); + expect(qa(card, '.hm')).toHaveLength(3); + const call = (card.hass!.callWS as ReturnType).mock.calls[0][0]; + expect(call.type).toBe('recorder/statistics_during_period'); + expect(call.period).toBe('day'); + const first = q(card, '.hm')!; + const cells = [...first.querySelectorAll('path.cell')]; + expect(cells.length).toBeGreaterThan(360); + expect(cells.some((c) => (c as SVGElement).style.fill.includes('--error-color'))).toBe(true); + expect(first.querySelectorAll('.hm-key')).toHaveLength(2); + expect(first.querySelectorAll('.hm-cols .hm-label').length).toBeGreaterThan(3); + // The entities with no statistics draw empty grids rather than breaking the card. + const missing = qa(card, '.hm')[2]; + expect(missing.querySelectorAll('.cell:not(.empty)')).toHaveLength(0); + }); + + it('uses square cells, fixed colour ends, and hides the legend and tooltip when asked', async () => { + const card = await mount({ + cell_shape: 'square', + color_min: 0, + color_max: 100, + legend: false, + tooltip: false, + } as Partial); + expect(q(card, 'rect.cell')!.getAttribute('rx')).toBe('0.4'); + expect(q(card, '.hm-legend')).toBeNull(); + expect(q(card, '.hm-plot.hoverable')).toBeNull(); + const fills = qa(card, 'rect.cell').map((c) => (c as SVGElement).style.fill); + // Every value is under 24 of 100: nothing reaches the full colour. + expect(fills.every((f) => f.includes('color-mix'))).toBe(true); + }); + + it('says when there is nothing to draw', async () => { + const hass = hassWithData(); + hass.callWS = vi.fn(async () => ({})) as HomeAssistant['callWS']; + const card = await mount({}, hass); + expect(q(card, '.chart-message')!.textContent).toBe('No readings in this period.'); + }); + + it('checks its options', () => { + const card = document.createElement('chartdeck-card') as Card; + const set = (extra: Record) => () => + card.setConfig({ type: 'x', style: 'heatmap', entities: ['sensor.power'], ...extra } as CardConfig); + expect(set({ grid: 'year' })).toThrow('"grid" must be one of: hours, calendar'); + expect(set({ color_by: 'series' })).toThrow('"color_by" must be one of: scale, bands'); + expect(set({ cell_shape: 'circle' })).toThrow(/cell_shape/); + expect(set({ color_min: 'low' })).toThrow('"color_min" must be a number'); + expect(set({ color_min: 5, color_max: 1 })).toThrow('"color_min" must be below "color_max"'); + expect(set({ legend: 'yes' })).toThrow(/legend/); + expect(set({ aggregate: 'median' })).toThrow(/aggregate/); + expect(set({ tooltip: 1 })).toThrow(/tooltip/); + expect( + set({ grid: 'calendar', cell_shape: 'hexagon', color_by: 'bands', color_min: 0, color_max: 1 }), + ).not.toThrow(); + }); +}); + +describe('heatmap editor', () => { + it('shows the layout, span, combining, colour and cell options, with each layout’s defaults', async () => { + const ed = document.createElement('chartdeck-card-editor') as HTMLElement & { + setConfig(c: CardConfig): void; + hass?: unknown; + updateComplete: Promise; + }; + const events: CardConfig[] = []; + ed.addEventListener('config-changed', (e) => events.push((e as CustomEvent).detail.config)); + ed.hass = makeHass({}); + const raw = { type: 'custom:chartdeck-card', style: 'heatmap', entities: ['sensor.power'] } as CardConfig; + ed.setConfig(raw); + document.body.appendChild(ed); + await ed.updateComplete; + const form = ed.shadowRoot!.querySelector('.section-chart ha-form') as Stub; + expect(form.data).toEqual({ + grid: 'hours', + period_calendar: 'rolling', + period_last: 4, + period_unit: 'week', + period_offset: 0, + period_until: 'now', + period_navigate: false, + source: 'history', + aggregate: 'mean', + color_by: 'scale', + cell_shape: 'rounded', + legend: true, + tooltip: true, + }); + const names = JSON.stringify(form.schema); + expect(names).not.toContain('group_by'); + for (const n of ['grid', 'aggregate', 'cell_shape', 'color_min', 'color_max', 'legend']) + expect(names).toContain(`"${n}"`); + form.dispatchEvent( + new CustomEvent('value-changed', { + detail: { value: { ...(form.data as object), grid: 'calendar', cell_shape: 'hexagon', color_max: 50 } }, + }), + ); + expect(events.at(-1)).toEqual({ ...raw, grid: 'calendar', cell_shape: 'hexagon', color_max: 50 }); + ed.setConfig(events.at(-1)!); + await ed.updateComplete; + const cal = ed.shadowRoot!.querySelector('.section-chart ha-form') as Stub; + expect(cal.data).toMatchObject({ grid: 'calendar', period_last: 12, period_unit: 'month', source: 'statistics' }); + const list = ed.shadowRoot!.querySelector('cd-entities-editor') as Stub; + const fields = (list.chartFields as { fields: { name: string }[] }).fields.map((f) => f.name); + expect(fields).toEqual(['series_color']); + }); +}); diff --git a/test/histogram.test.ts b/test/histogram.test.ts new file mode 100644 index 0000000..1e256d4 --- /dev/null +++ b/test/histogram.test.ts @@ -0,0 +1,334 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import '../src/chartdeck-card'; +import { binEdges, binIndex, binTotals, formatDuration, pointWeights } from '../src/chart/histogram'; +import type { Point } from '../src/history/types'; +import type { CardConfig, HomeAssistant } from '../src/types'; +import { makeHass } from './helpers/hass'; + +class Stub extends HTMLElement { + [key: string]: unknown; +} +for (const tag of [ + 'ha-form', + 'ha-expansion-panel', + 'ha-selector', + 'ha-entity-picker', + 'ha-icon-picker', + 'ha-icon-button', + 'ha-switch', +]) { + if (!customElements.get(tag)) customElements.define(tag, class extends Stub {}); +} +await import('../src/editor/chartdeck-card-editor'); + +const H = 3_600_000; + +describe('bin edges', () => { + it('picks a round width for about the target number of bins, on its multiples', () => { + expect(binEdges(17.3, 26.8, {}, 10)).toEqual([17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27]); + expect(binEdges(0.12, 0.47, {}, 4)).toEqual([0.1, 0.2, 0.3, 0.4, 0.5]); + }); + + it('honours a set width, a set count, and fixed ends', () => { + expect(binEdges(17.3, 19.1, { width: 0.5 })).toEqual([17, 17.5, 18, 18.5, 19, 19.5]); + expect(binEdges(10, 20, { bins: 4 })).toEqual([10, 12.5, 15, 17.5, 20]); + expect(binEdges(10, 20, { bins: 2, min: 0, max: 30 })).toEqual([0, 15, 30]); + expect(binEdges(3, 40, { width: 10, min: 0, max: 25 })).toEqual([0, 10, 20, 25]); + // A width too narrow for the span is widened to keep the bin count sane. + expect(binEdges(0, 1000, { width: 0.001 }).length).toBeLessThanOrEqual(101); + }); + + it('gives flat readings one bin around their value', () => { + expect(binEdges(20, 20, {})).toEqual([20, 22]); + expect(binEdges(5, 5, { bins: 3 }).map((e) => e.toFixed(3))).toEqual(['4.500', '4.833', '5.167', '5.500']); + expect(binEdges(0, 0, {})).toEqual([0, 0.1]); + }); + + it('finds the bin for a value, with the top edge in the last bin', () => { + const edges = [0, 10, 20, 30]; + expect(binIndex(0, edges)).toBe(0); + expect(binIndex(9.99, edges)).toBe(0); + expect(binIndex(10, edges)).toBe(1); + expect(binIndex(30, edges)).toBe(2); + expect(binIndex(-1, edges)).toBe(-1); + expect(binIndex(31, edges)).toBe(-1); + expect(binIndex(1, [0])).toBe(-1); + }); +}); + +describe('weights', () => { + // 20 for 10 h, a burst of ten 30s in one minute, then back to 20 for the rest of the day. + const burst: Point[] = [ + { t: 0, v: 20 }, + ...Array.from({ length: 10 }, (_, i) => ({ t: 10 * H + i * 6000, v: 30 })), + { t: 10 * H + 60_000, v: 20 }, + { t: 24 * H, v: 20 }, + ]; + + it('counts time held, so a burst of changes does not outweigh a steady day', () => { + const w = pointWeights(burst, { mode: 'time', until: 24 * H, statistics: false }); + const totals = binTotals(burst, w, [15, 25, 35]); + expect(totals[0]).toBe(24 * H - 60_000); + expect(totals[1]).toBe(60_000); + // Counting rows instead says the 30s won: ten of them against two (the right-edge copy is not a reading). + const rows = binTotals( + burst, + pointWeights(burst, { mode: 'readings', until: 24 * H, statistics: false }), + [15, 25, 35], + ); + expect(rows).toEqual([2, 10]); + }); + + it('stops the last value at now, and skips gaps and the value carried in', () => { + const pts: Point[] = [ + { t: 0, v: 5, carried: true }, + { t: H, v: null }, + { t: 2 * H, v: 7 }, + ]; + expect(pointWeights(pts, { mode: 'time', until: 3 * H, statistics: false })).toEqual([H, 0, H]); + expect(pointWeights(pts, { mode: 'readings', until: 3 * H, statistics: false })).toEqual([0, 0, 1]); + expect(pointWeights([], { mode: 'time', until: 0, statistics: false })).toEqual([]); + }); + + it('weighs statistics rows as one period each, and grouped points by their group', () => { + const rows: Point[] = [ + { t: 0, v: 1 }, + { t: H, v: 2 }, + { t: 5 * H, v: 3 }, + { t: 6 * H, v: null }, + ]; + expect(pointWeights(rows, { mode: 'time', until: 9 * H, statistics: true })).toEqual([H, H, H, 0]); + expect(pointWeights(rows, { mode: 'readings', until: 9 * H, statistics: true })).toEqual([1, 1, 1, 0]); + expect(pointWeights([{ t: 0, v: 1 }], { mode: 'time', until: H, statistics: true })).toEqual([H]); + const groups: Point[] = [ + { t: 0, end: 24 * H, v: 1 }, + { t: 24 * H, end: 48 * H, v: 2 }, + { t: 48 * H, end: 72 * H, v: null }, + ]; + expect(pointWeights(groups, { mode: 'time', until: 30 * H, statistics: false })).toEqual([24 * H, 6 * H, 0]); + expect(pointWeights(groups, { mode: 'readings', until: 30 * H, statistics: false })).toEqual([1, 1, 0]); + }); + + it('writes durations in a few words', () => { + expect(formatDuration(0)).toBe('0 min'); + expect(formatDuration(20_000)).toBe('< 1 min'); + expect(formatDuration(45 * 60_000)).toBe('45 min'); + expect(formatDuration(2 * H)).toBe('2 h'); + expect(formatDuration(3 * H + 20 * 60_000)).toBe('3 h 20 min'); + expect(formatDuration(52 * H)).toBe('2 d 4 h'); + expect(formatDuration(48 * H)).toBe('2 d'); + }); +}); + +type Card = HTMLElement & { setConfig(c: CardConfig): void; hass?: HomeAssistant; updateComplete: Promise }; + +const now = Date.now(); +const sec = (msAgo: number) => (now - msAgo) / 1000; + +const history = { + 'sensor.in': [ + { s: '19', lu: sec(20 * H) }, + { s: '21', lu: sec(14 * H) }, + { s: 'unavailable', lu: sec(8 * H) }, + { s: '22.5', lu: sec(2 * H) }, + ], + 'sensor.out': [ + { s: '5', lu: sec(20 * H) }, + { s: '9', lu: sec(4 * H) }, + ], +}; + +function hassWith(data: Record) { + const hass = makeHass({ + 'sensor.in': ['22.5', { friendly_name: 'Inside', unit_of_measurement: '°C' }], + 'sensor.out': ['9', { friendly_name: 'Outside', unit_of_measurement: '°C' }], + }); + hass.callWS = vi.fn(async () => data) as HomeAssistant['callWS']; + return hass; +} + +async function mount(config: Partial, data = history) { + const card = document.createElement('chartdeck-card') as Card; + card.setConfig({ + type: 'custom:chartdeck-card', + style: 'histogram', + entities: ['sensor.in'], + ...config, + } as CardConfig); + card.hass = hassWith(data); + document.body.appendChild(card); + await card.updateComplete; + await new Promise((r) => setTimeout(r, 0)); + await card.updateComplete; + return card; +} + +const qa = (card: Card, sel: string) => [...card.shadowRoot!.querySelectorAll(sel)]; +const q = (card: Card, sel: string) => card.shadowRoot!.querySelector(sel); + +afterEach(() => { + document.body.innerHTML = ''; +}); + +function hover(card: Card, fx: number, type = 'mouse') { + const area = q(card, '.plot-area') as HTMLElement; + area.getBoundingClientRect = () => ({ left: 0, top: 0, width: 400, height: 180 }) as DOMRect; + const ev = new MouseEvent('pointermove', { clientX: fx * 400, clientY: 50, bubbles: true }); + Object.defineProperty(ev, 'pointerType', { value: type }); + area.dispatchEvent(ev); +} + +describe('histogram chart', () => { + it('draws one bar per range, sized by share of time, with value labels along the bottom', async () => { + const card = await mount({ bin_width: 1 }); + const bars = qa(card, 'rect.bin'); + // 19 for 6 h, 21 for 6 h, 22.5 for 2 h: three bins with time in them. + expect(bars).toHaveLength(3); + const heights = bars.map((b) => Number(b.getAttribute('height'))); + expect(heights[0]).toBeCloseTo(heights[1]); + expect(heights[0]).toBeGreaterThan(heights[2] * 2.5); + expect(qa(card, '.y-axis .axis-label').some((l) => l.textContent!.includes('%'))).toBe(true); + expect(qa(card, '.x-axis .axis-label').map((l) => l.textContent)).toContain('20'); + expect(q(card, 'svg.plot')!.getAttribute('aria-label')).toBe('Histogram chart of Inside'); + expect(bars[0].getAttribute('fill')).toContain('--primary-color'); + }); + + it('counts readings instead, as hours, and colours bins by band', async () => { + const readings = await mount({ count: 'readings', show_as: 'total', bin_width: 1 } as Partial); + expect(qa(readings, 'rect.bin')).toHaveLength(3); + const hours = await mount({ + show_as: 'total', + bins: 2, + color_by: 'bands', + severity: [ + { from: 0, color: 'blue' }, + { from: 21, color: 'red' }, + ], + } as Partial); + expect(qa(hours, '.y-axis .axis-label').some((l) => / h$/.test(l.textContent!))).toBe(true); + const fills = qa(hours, 'rect.bin').map((b) => b.getAttribute('fill')); + expect(fills[0]).toContain('--info-color'); + expect(fills[1]).toContain('--error-color'); + }); + + it('puts two entities side by side, or over each other', async () => { + const side = await mount({ entities: ['sensor.in', 'sensor.out'], bin_width: 5 } as Partial); + const groups = qa(side, 'g.series'); + expect(groups).toHaveLength(2); + const [a] = qa(side, 'g.series[data-series="0"] rect.bin'); + const [b] = qa(side, 'g.series[data-series="1"] rect.bin'); + expect(Number(a.getAttribute('width'))).toBeLessThan(200); + expect(b.getAttribute('fill')).toContain('--warning-color'); + const over = await mount({ + entities: ['sensor.in', 'sensor.out'], + bars: 'overlap', + bin_width: 5, + } as Partial); + expect(q(over, 'g.overlap')).not.toBeNull(); + expect(qa(over, 'rect.bin')[0].getAttribute('stroke')).not.toBe('none'); + }); + + it('leaves out readings outside a fixed range', async () => { + const card = await mount({ x_min: 20, x_max: 25, bin_width: 1 } as Partial); + expect(qa(card, 'rect.bin')).toHaveLength(2); + expect(qa(card, '.x-axis .axis-label')[0].textContent).toBe('20'); + }); + + it('shows the time and share in a bin on hover', async () => { + const card = await mount({ entities: ['sensor.in', 'sensor.out'], bin_width: 5 } as Partial); + // Bins 5–10, 10–15, 15–20, 20–25: the first quarter is 5–10. + hover(card, 0.1); + await card.updateComplete; + expect(q(card, '.tooltip-time')!.textContent).toBe('5 – 10 °C'); + const rows = qa(card, '.tooltip-row').map((r) => r.textContent!.replace(/\s+/g, ' ').trim()); + expect(rows[0]).toBe('Inside 0% · 0 min'); + expect(rows[1]).toMatch(/^Outside 100% · 20 h/); + expect(q(card, '.pick-band')).not.toBeNull(); + const area = q(card, '.plot-area') as HTMLElement; + area.dispatchEvent(Object.assign(new MouseEvent('pointerleave', { bubbles: true }), {})); + await card.updateComplete; + expect(q(card, '.tooltip')).toBeNull(); + const counted = await mount({ count: 'readings', bin_width: 5 } as Partial); + hover(counted, 0.9); + await counted.updateComplete; + expect(q(counted, '.tooltip-row')!.textContent).toMatch(/67% · 2 readings\s*$/); + const many = await mount({ count: 'readings', bins: 1 } as Partial); + hover(many, 0.5); + await many.updateComplete; + expect(q(many, '.tooltip-row')!.textContent).toMatch(/3 readings\s*$/); + const off = await mount({ tooltip: false } as Partial); + expect(q(off, '.plot-area.hoverable')).toBeNull(); + }); + + it('says when there is nothing to draw, and copes with an unavailable or missing entity', async () => { + const empty = await mount({}, {}); + expect(q(empty, '.chart-message')!.textContent).toBe('No readings in this period.'); + const card = await mount({ entities: ['sensor.in', 'sensor.missing'], header: 'over' } as Partial); + expect(qa(card, 'g.series')).toHaveLength(2); + expect(qa(card, 'g.series[data-series="1"] rect')).toHaveLength(0); + expect(q(card, '.header-over')).not.toBeNull(); + }); + + it('checks its options', () => { + const card = document.createElement('chartdeck-card') as Card; + const set = (extra: Record) => () => + card.setConfig({ type: 'x', style: 'histogram', entities: ['sensor.in'], ...extra } as CardConfig); + expect(set({ count: 'rows' })).toThrow('"count" must be one of: time, readings'); + expect(set({ show_as: 'pie' })).toThrow(/show_as/); + expect(set({ bars: 'stacked' })).toThrow(/bars/); + expect(set({ color_by: 'mood' })).toThrow(/color_by/); + expect(set({ bins: 0 })).toThrow(/bins/); + expect(set({ bins: 2.5 })).toThrow(/bins/); + expect(set({ bin_width: -1 })).toThrow(/bin_width/); + expect(set({ x_min: 5, x_max: 1 })).toThrow(/x_min/); + expect(set({ bins: 10, bin_width: 0.5, count: 'readings' })).not.toThrow(); + }); +}); + +describe('histogram editor', () => { + it('shows the Chart section and writes bins, counting and layout', async () => { + const ed = document.createElement('chartdeck-card-editor') as HTMLElement & { + setConfig(c: CardConfig): void; + hass?: unknown; + updateComplete: Promise; + }; + const events: CardConfig[] = []; + ed.addEventListener('config-changed', (e) => events.push((e as CustomEvent).detail.config)); + ed.hass = makeHass({}); + const raw = { type: 'custom:chartdeck-card', style: 'histogram', entities: ['sensor.in'] } as CardConfig; + ed.setConfig(raw); + document.body.appendChild(ed); + await ed.updateComplete; + const form = ed.shadowRoot!.querySelector('.section-chart ha-form') as Stub; + expect(form.data).toMatchObject({ + count: 'time', + show_as: 'percent', + bars: 'side', + color_by: 'series', + hist_x_axis: true, + y_axis: true, + }); + const names = JSON.stringify(form.schema); + for (const n of ['bins', 'bin_width', 'x_min', 'x_max', 'hist_x_axis', 'period_calendar', 'compact']) { + expect(names).toContain(`"${n}"`); + } + form.dispatchEvent( + new CustomEvent('value-changed', { + detail: { + value: { + ...(form.data as object), + bins: 12, + count: 'readings', + bars: 'overlap', + x_min: 10, + hist_x_axis: false, + }, + }, + }), + ); + expect(events.at(-1)).toEqual({ ...raw, bins: 12, count: 'readings', bars: 'overlap', x_min: 10, x_axis: false }); + const list = ed.shadowRoot!.querySelector('cd-entities-editor') as Stub; + const fields = (list.chartFields as { fields: { name: string; path: string[] }[] }).fields; + expect(fields).toEqual([{ name: 'bar_color', path: ['color'] }]); + }); +}); diff --git a/test/period-card.test.ts b/test/period-card.test.ts index fb2c20b..811e6a4 100644 --- a/test/period-card.test.ts +++ b/test/period-card.test.ts @@ -32,10 +32,13 @@ function hassWith() { ); hass.callWS = vi.fn(async (msg: Record) => { const start = Date.parse(msg.start_time as string) / 1000; + // Like the recorder, never return a reading from the future (just after midnight the period's + // first hour hasn't finished yet). + const second = Math.min(start + 3600, Math.max(start + 1, Date.now() / 1000 - 60)); return { 'sensor.in': [ { s: '20', lu: start }, - { s: '22', lu: start + 3600 }, + { s: '22', lu: second }, ], }; }) as HomeAssistant['callWS']; diff --git a/test/scatter-plot.test.ts b/test/scatter-plot.test.ts new file mode 100644 index 0000000..dabab43 --- /dev/null +++ b/test/scatter-plot.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from 'vitest'; +import { bubbleDiameter, jitter, pairReadings, thin, validateXY, xyOptions } from '../src/chart/scatter-plot'; +import type { CardConfig } from '../src/types'; + +describe('pairing readings in time', () => { + it('samples every series at each time any of them changed, holding the last value', () => { + const x = [ + { t: 0, v: 1 }, + { t: 10, v: 2 }, + ]; + const y = [ + { t: 5, v: 50 }, + { t: 10, v: 60 }, + { t: 20, v: 70 }, + ]; + expect(pairReadings([x, y])).toEqual([ + { t: 5, v: [1, 50] }, + { t: 10, v: [2, 60] }, + { t: 20, v: [2, 70] }, + ]); + }); + + it('leaves out times when a series is unavailable, has no value yet, or is the carried edge', () => { + const x = [ + { t: 0, v: 1 }, + { t: 10, v: null }, + { t: 20, v: 3 }, + { t: 30, v: 3 }, + ]; + const y = [ + { t: 5, v: 7 }, + { t: 30, v: 8 }, + ]; + expect(pairReadings([x, y], 30)).toEqual([ + { t: 5, v: [1, 7] }, + { t: 20, v: [3, 7] }, + ]); + expect(pairReadings([x, []])).toEqual([]); + }); + + it('pairs three series for bubbles', () => { + const s = (v: number) => [{ t: 0, v }]; + expect(pairReadings([s(1), s(2), s(3)])).toEqual([{ t: 0, v: [1, 2, 3] }]); + }); +}); + +describe('scatter maths', () => { + it('thins evenly, keeping the last', () => { + const items = Array.from({ length: 10 }, (_, i) => i); + expect(thin(items, 20)).toBe(items); + expect(thin(items, 4)).toEqual([0, 2, 5, 9]); + }); + + it('jitters steadily per reading, between -1 and 1', () => { + const a = jitter(1_700_000_000_000, 0); + expect(jitter(1_700_000_000_000, 0)).toBe(a); + expect(jitter(1_700_000_000_000, 1)).not.toBe(a); + const all = Array.from({ length: 200 }, (_, i) => jitter(i * 60_000, 3)); + expect(Math.min(...all)).toBeGreaterThanOrEqual(-1); + expect(Math.max(...all)).toBeLessThanOrEqual(1); + expect(Math.min(...all)).toBeLessThan(-0.5); + expect(Math.max(...all)).toBeGreaterThan(0.5); + }); + + it('sizes bubbles by area between the smallest and largest', () => { + expect(bubbleDiameter(0, [0, 10], 4, 20)).toBe(4); + expect(bubbleDiameter(10, [0, 10], 4, 20)).toBe(20); + // Halfway up the range: half the area between the two, not half the width. + expect(bubbleDiameter(5, [0, 10], 0, 20)).toBeCloseTo(14.1, 1); + expect(bubbleDiameter(99, [0, 10], 4, 20)).toBe(20); + expect(bubbleDiameter(3, [3, 3], 0, 20)).toBeCloseTo(14.1, 1); + }); +}); + +describe('scatter and bubble options', () => { + const check = (kind: 'scatter' | 'bubble', extra: Record) => () => + validateXY({ type: 'x', ...extra } as CardConfig, kind); + + it('has defaults, and a bubble chart is always against an entity', () => { + expect(xyOptions({ type: 'x' }, 'scatter')).toEqual({ + against: 'entity', + dotSize: 6, + jitter: 0, + minSize: 6, + maxSize: 32, + }); + expect(xyOptions({ type: 'x', against: 'time', dot_size: 9, jitter: 3 }, 'scatter')).toMatchObject({ + against: 'time', + dotSize: 9, + jitter: 3, + }); + expect(xyOptions({ type: 'x', against: 'time' }, 'bubble').against).toBe('entity'); + }); + + it('gives readable errors', () => { + expect(check('scatter', { against: 'moon' })).toThrow('"against" must be one of: entity, time'); + expect(check('scatter', { dot_size: 0 })).toThrow('"dot_size" must be a number from 2 to 30'); + expect(check('scatter', { jitter: 'lots' })).toThrow('"jitter" must be a number from 0 to 20'); + expect(check('scatter', { x_min: 3, x_max: 1 })).toThrow(/x_min/); + expect(check('scatter', { source: 'cloud' })).toThrow(/source/); + expect(check('bubble', { max_size: 200 })).toThrow('"max_size" must be a number from 2 to 80'); + expect(check('bubble', { min_size: 30, max_size: 10 })).toThrow('"min_size" must not be above "max_size"'); + expect(check('bubble', { min_size: 4, max_size: 40, jitter: 2 })).not.toThrow(); + expect(check('scatter', { against: 'time', dot_size: 8 })).not.toThrow(); + }); +}); diff --git a/test/scatter.test.ts b/test/scatter.test.ts new file mode 100644 index 0000000..b42bc95 --- /dev/null +++ b/test/scatter.test.ts @@ -0,0 +1,222 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import '../src/chartdeck-card'; +import type { CardConfig, HomeAssistant } from '../src/types'; +import { makeHass } from './helpers/hass'; + +class Stub extends HTMLElement { + [key: string]: unknown; +} +for (const tag of [ + 'ha-form', + 'ha-expansion-panel', + 'ha-selector', + 'ha-entity-picker', + 'ha-icon-picker', + 'ha-icon-button', + 'ha-switch', +]) { + if (!customElements.get(tag)) customElements.define(tag, class extends Stub {}); +} +await import('../src/editor/chartdeck-card-editor'); + +type Card = HTMLElement & { setConfig(c: CardConfig): void; hass?: HomeAssistant; updateComplete: Promise }; + +const now = Date.now(); +const sec = (msAgo: number) => (now - msAgo) / 1000; +const H = 3_600_000; + +const history = { + 'sensor.out': [ + { s: '2', lu: sec(20 * H) }, + { s: '5', lu: sec(14 * H) }, + { s: '9', lu: sec(8 * H) }, + { s: 'unavailable', lu: sec(5 * H) }, + { s: '12', lu: sec(3 * H) }, + ], + 'sensor.heat': [ + { s: '3000', lu: sec(19 * H) }, + { s: '2200', lu: sec(13 * H) }, + { s: '900', lu: sec(7 * H) }, + { s: '400', lu: sec(2 * H) }, + ], + 'sensor.flow': [ + { s: '40', lu: sec(18 * H) }, + { s: '45', lu: sec(6 * H) }, + ], +}; + +function hassWith(data: Record = history) { + const hass = makeHass({ + 'sensor.out': ['12', { friendly_name: 'Outside', unit_of_measurement: '°C' }], + 'sensor.heat': ['400', { friendly_name: 'Heating', unit_of_measurement: 'W' }], + 'sensor.flow': ['45', { friendly_name: 'Flow', unit_of_measurement: '°C' }], + }); + hass.callWS = vi.fn(async () => data) as HomeAssistant['callWS']; + return hass; +} + +async function mount(config: Partial, hass = hassWith()) { + const card = document.createElement('chartdeck-card') as Card; + card.setConfig({ + type: 'custom:chartdeck-card', + style: 'scatter', + entities: ['sensor.out', 'sensor.heat'], + ...config, + } as CardConfig); + card.hass = hass; + document.body.appendChild(card); + await card.updateComplete; + await new Promise((r) => setTimeout(r, 0)); + await card.updateComplete; + return card; +} + +const q = (card: Card, sel: string) => card.shadowRoot!.querySelector(sel); +const qa = (card: Card, sel: string) => [...card.shadowRoot!.querySelectorAll(sel)]; +const dotCount = (el: Element) => (el.getAttribute('d')!.match(/M/g) ?? []).length; + +function point(el: Element, type: string, fx: number, fy: number, pointerType = 'mouse') { + el.getBoundingClientRect = () => ({ left: 0, top: 0, width: 1000, height: 200, right: 1000, bottom: 200 }) as DOMRect; + const ev = new MouseEvent(type, { bubbles: true, clientX: fx * 1000, clientY: fy * 200 }); + Object.defineProperty(ev, 'pointerType', { value: pointerType }); + el.dispatchEvent(ev); +} + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('scatter chart', () => { + it('plots the second entity against the first, one dot per time either changed', async () => { + const card = await mount({}); + const dots = qa(card, 'path.dots'); + expect(dots).toHaveLength(1); + // Paired at each change of either, leaving out the unavailable stretch and the carried edge. + expect(dotCount(dots[0])).toBe(7); + expect(dots[0].getAttribute('stroke')).toContain('--primary-color'); + expect(q(card, 'svg.plot')!.getAttribute('aria-label')).toBe('Scatter chart of Outside, Heating'); + const header = qa(card, '.series-item').map((e) => e.textContent!.replace(/\s+/g, ' ').trim()); + expect(header).toEqual(['Across Outside 12.0 °C', 'Heating 400.0 W']); + expect(q(card, '.series-item .role')).not.toBeNull(); + // Bottom labels are values: numbers, not times. + const bottom = qa(card, '.x-axis .axis-label').map((e) => e.textContent!.trim()); + expect(bottom.length).toBeGreaterThanOrEqual(2); + expect(bottom.every((v) => /^-?\d+$/.test(v))).toBe(true); + }); + + it('draws each further entity as its own coloured series, with jitter that stays put', async () => { + const card = await mount({ + entities: ['sensor.out', 'sensor.heat', { entity: 'sensor.flow', color: 'red' }], + jitter: 6, + dot_size: 9, + } as Partial); + const dots = qa(card, 'path.dots'); + expect(dots).toHaveLength(2); + expect(dots[1].getAttribute('stroke')).toContain('--error-color'); + expect((dots[0] as SVGElement).style.strokeWidth).toBe('9px'); + const before = dots.map((d) => d.getAttribute('d')); + card.hass = { ...card.hass! }; + await card.updateComplete; + expect(qa(card, 'path.dots').map((d) => d.getAttribute('d'))).toEqual(before); + const plain = await mount({ entities: ['sensor.out', 'sensor.heat', 'sensor.flow'] } as Partial); + expect(qa(plain, 'path.dots')[0].getAttribute('d')).not.toBe(before[0]); + }); + + it('plots values against time as dots, with time labels', async () => { + const card = await mount({ against: 'time', entities: ['sensor.out'] } as Partial); + const dots = qa(card, 'path.dots'); + expect(dots).toHaveLength(1); + // The readings in range, not the gap and not the value carried to the right edge. + expect(dotCount(dots[0])).toBe(4); + expect(q(card, '.role')).toBeNull(); + expect(q(card, '.swatch')).not.toBeNull(); + }); + + it('asks for a second entity, and copes with one that is missing or has no history', async () => { + const one = await mount({ entities: ['sensor.out'] }); + expect(q(one, '.chart-message')!.textContent).toContain('Add a second entity'); + const missing = await mount({ entities: ['sensor.out', 'sensor.gone'] }); + expect(q(missing, '.chart-message')!.textContent).toBe('No readings in this period.'); + const empty = await mount({}, hassWith({})); + expect(q(empty, '.chart-message')!.textContent).toBe('No readings in this period.'); + }); + + it('shows both readings and the time of the dot under the pointer', async () => { + const card = await mount({ x_axis: false, y_axis: false } as Partial); + const area = q(card, '.plot-area')!; + expect(area.classList.contains('hoverable')).toBe(true); + // Highest outside temperature (right edge) has the lowest heating (bottom). + const d = q(card, 'path.dots')!.getAttribute('d')!; + const pts = [...d.matchAll(/M([\d.-]+),([\d.-]+)/g)].map((m) => [Number(m[1]), Number(m[2])]); + const [x, y] = pts.reduce((a, b) => (b[0] > a[0] || (b[0] === a[0] && b[1] > a[1]) ? b : a)); + point(area, 'pointermove', x / 1000, y / 200); + await card.updateComplete; + const rows = qa(card, '.tooltip-row').map((e) => e.textContent!.replace(/\s+/g, ' ').trim()); + expect(rows).toEqual(['Outside 12.0 °C', 'Heating 400.0 W']); + expect(q(card, '.hover-dot')).not.toBeNull(); + point(area, 'pointerleave', 0, 0); + await card.updateComplete; + expect(q(card, '.tooltip')).toBeNull(); + const off = await mount({ tooltip: false } as Partial); + expect(q(off, '.plot-area')!.classList.contains('hoverable')).toBe(false); + }); + + it('keeps a fixed bottom range and the header over the chart', async () => { + const card = await mount({ x_min: 0, x_max: 20, header: 'over', compact: true } as Partial); + const bottom = qa(card, '.x-axis .axis-label').map((e) => e.textContent!.trim()); + expect(bottom[0]).toBe('0'); + expect(bottom.at(-1)).toBe('20'); + expect(q(card, '.header-over')).not.toBeNull(); + expect(q(card, '.frame.compact')).not.toBeNull(); + }); + + it('checks its options', () => { + const card = document.createElement('chartdeck-card') as Card; + const set = (extra: Record) => () => + card.setConfig({ type: 'x', style: 'scatter', entities: ['sensor.out'], ...extra } as CardConfig); + expect(set({ against: 'moon' })).toThrow(/against/); + expect(set({ jitter: 99 })).toThrow(/jitter/); + }); +}); + +describe('scatter chart editor', () => { + it('writes what goes along the bottom, dot size and jitter, and a colour per entity', async () => { + const ed = document.createElement('chartdeck-card-editor') as HTMLElement & { + setConfig(c: CardConfig): void; + hass?: unknown; + updateComplete: Promise; + }; + const events: CardConfig[] = []; + ed.addEventListener('config-changed', (e) => events.push((e as CustomEvent).detail.config)); + ed.hass = makeHass({}); + const raw = { + type: 'custom:chartdeck-card', + style: 'scatter', + entities: ['sensor.out', 'sensor.heat'], + } as CardConfig; + ed.setConfig(raw); + document.body.appendChild(ed); + await ed.updateComplete; + const form = ed.shadowRoot!.querySelector('.section-chart ha-form') as Stub; + expect(form.data).toMatchObject({ against: 'entity', dot_size: 6, jitter: 0, xy_x_axis: true, tooltip: true }); + const names = JSON.stringify(form.schema); + expect(names).toContain('"x_min"'); + form.dispatchEvent( + new CustomEvent('value-changed', { + detail: { value: { ...(form.data as object), jitter: 4, dot_size: 8, x_min: -5, xy_x_axis: false } }, + }), + ); + expect(events.at(-1)).toEqual({ ...raw, jitter: 4, dot_size: 8, x_min: -5, x_axis: false }); + form.dispatchEvent( + new CustomEvent('value-changed', { detail: { value: { ...(form.data as object), against: 'time' } } }), + ); + expect(events.at(-1)).toEqual({ ...raw, against: 'time' }); + ed.setConfig({ ...raw, against: 'time' } as CardConfig); + await ed.updateComplete; + const timeForm = ed.shadowRoot!.querySelector('.section-chart ha-form') as Stub; + expect(JSON.stringify(timeForm.schema)).not.toContain('"x_min"'); + const list = ed.shadowRoot!.querySelector('cd-entities-editor') as Stub; + const fields = (list.chartFields as { fields: { name: string }[] }).fields.map((f) => f.name); + expect(fields).toEqual(['dot_color']); + }); +}); diff --git a/test/xy.test.ts b/test/xy.test.ts new file mode 100644 index 0000000..2f35aa7 --- /dev/null +++ b/test/xy.test.ts @@ -0,0 +1,131 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { render } from 'lit'; +import { VIEW_H, VIEW_W } from '../src/chart/frame'; +import { + nearestPoint, + pickHandlers, + pickTooltip, + pickedIndex, + stopPick, + validateXRange, + valueAxes, + xDecimals, + xRange, +} from '../src/chart/xy'; +import type { ChartContext } from '../src/styles/index'; +import type { CardConfig } from '../src/types'; + +const ctxWith = (raw: Partial = {}) => { + const host = document.createElement('div'); + return { host, raw: { type: 'x', ...raw }, requestUpdate: vi.fn() } as unknown as ChartContext; +}; + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('value axes', () => { + it('puts round steps on both axes, with the bottom ones as ticks and range', () => { + const axes = valueAxes(ctxWith(), [3, 27], [0, 9], { yFloor: 0 }); + expect(axes.range.start).toBeLessThanOrEqual(3); + expect(axes.range.end).toBeGreaterThanOrEqual(27); + expect(axes.times[0]).toBe(axes.range.start); + expect(axes.value.domain[0]).toBe(0); + expect(axes.scales.x(axes.range.start)).toBe(0); + expect(axes.scales.x(axes.range.end)).toBeCloseTo(VIEW_W); + expect(axes.scales.y(0)).toBeCloseTo(VIEW_H); + expect(xDecimals(axes)).toBe(0); + }); + + it('keeps fixed ends, the card y range over the floor, and copes with no data', () => { + const axes = valueAxes(ctxWith({ y_min: 2, y_max: 8 }), [0.1, 0.4], undefined, { + x: { min: 0, max: 0.5 }, + yFloor: 0, + }); + expect(axes.range).toEqual({ start: 0, end: 0.5 }); + expect(axes.value.domain).toEqual([2, 8]); + expect(xDecimals(axes)).toBe(1); + expect(xDecimals({ ...axes, times: [1] })).toBe(0); + }); + + it('reads and checks x_min and x_max', () => { + expect(xRange({ type: 'x', x_min: 1, x_max: 'a' })).toEqual({ min: 1, max: undefined }); + expect(() => validateXRange({ type: 'x', x_min: 'low' })).toThrow('"x_min" must be a number'); + expect(() => validateXRange({ type: 'x', x_min: 5, x_max: 5 })).toThrow('"x_min" must be below "x_max"'); + expect(() => validateXRange({ type: 'x', x_min: 0, x_max: 5 })).not.toThrow(); + }); +}); + +describe('picking a mark', () => { + it('finds the nearest point within reach in screen pixels', () => { + const pts: [number, number][] = [ + [0, 0], + [500, 100], + [1000, 200], + ]; + // A 200 × 100 px plot: the middle point is at (100, 50) px. + expect(nearestPoint(pts, 0.52, 0.5, 200, 100)).toBe(1); + expect(nearestPoint(pts, 0.98, 0.98, 200, 100)).toBe(2); + expect(nearestPoint(pts, 0.25, 0.25, 200, 100, 10)).toBeUndefined(); + }); + + it('asks which mark is under the pointer and redraws only when it changes', () => { + vi.useFakeTimers(); + const ctx = ctxWith(); + const pick = vi.fn((fx: number) => (fx < 0.5 ? 0 : 1)); + const h = pickHandlers(ctx, pick); + const el = document.createElement('div'); + el.getBoundingClientRect = () => ({ left: 0, top: 0, width: 100, height: 50 }) as DOMRect; + const ev = (x: number, type = 'mouse') => + ({ currentTarget: el, clientX: x, clientY: 10, pointerType: type }) as never; + h.move(ev(20)); + expect(pick).toHaveBeenLastCalledWith(0.2, 0.2, 100, 50); + expect(pickedIndex(ctx.host)).toBe(0); + h.move(ev(30)); + expect(ctx.requestUpdate).toHaveBeenCalledTimes(1); + h.move(ev(80)); + expect(pickedIndex(ctx.host)).toBe(1); + h.leave(ev(80)); + expect(pickedIndex(ctx.host)).toBeUndefined(); + h.move(ev(80, 'touch')); + h.leave(ev(80, 'touch')); + expect(pickedIndex(ctx.host)).toBe(1); + vi.advanceTimersByTime(3000); + expect(pickedIndex(ctx.host)).toBeUndefined(); + h.move(ev(10, 'touch')); + h.leave(ev(10, 'touch')); + stopPick(ctx.host); + expect(pickedIndex(ctx.host)).toBeUndefined(); + const zero = document.createElement('div'); + zero.getBoundingClientRect = () => ({ left: 0, top: 0, width: 0, height: 0 }) as DOMRect; + h.move({ currentTarget: zero, clientX: 5, clientY: 5, pointerType: 'mouse' } as never); + expect(pick).toHaveBeenLastCalledWith(0, 0, 0, 0); + }); + + it('draws a tooltip with a dot or a shaded band, on the side that fits', () => { + const el = document.createElement('div'); + render( + pickTooltip({ + x: 0.9, + y: 0.5, + plotWidth: 400, + title: 'Head', + rows: [{ name: 'A', value: '1', color: 'red' }], + dot: 'red', + }), + el, + ); + expect(el.querySelector('.tooltip')!.classList.contains('left')).toBe(true); + expect(el.querySelector('.hover-dot')).not.toBeNull(); + expect(el.querySelector('.tooltip-time')!.textContent).toBe('Head'); + render( + pickTooltip({ x: 0.1, y: 2, plotWidth: 400, title: 'B', rows: [{ name: 'n', value: 'v' }], band: [0, 0.2] }), + el, + ); + expect(el.querySelector('.pick-band')).not.toBeNull(); + expect(el.querySelector('.hover-dot')).toBeNull(); + expect(el.querySelector('.swatch')).toBeNull(); + render(pickTooltip({ x: 2, y: 0, plotWidth: 400, title: 'C', rows: [] }), el); + expect(el.querySelector('.tooltip')).toBeNull(); + }); +});