diff --git a/README.md b/README.md index 2823f3d..46a5574 100644 --- a/README.md +++ b/README.md @@ -22,8 +22,8 @@ Commit activity

-> **1.5 adds the distribution charts: histogram, scatter, bubble, heatmap and box plot.** See the -> [roadmap board][roadmap] for what is coming and in what order. +> **2.0 adds the rest of the roadmap: parts of a total, distributions, comparisons, flows, hierarchies, and zoom, annotations and more on the line chart.** Ideas for what comes next are on the +> [roadmap board][roadmap]. ![The seven Chartdeck styles in a light theme: classic bar, thin bar, segmented, tank, ring, needle and arc](images/preview-v1.png) @@ -56,16 +56,36 @@ This page is the summary. What changed in each version is on the [releases page] | Area chart | Solar, power use, import and export either side of zero | 1.3 | | Column chart | Energy per day, rain per hour | 1.3 | | Combined chart | Columns, lines and areas on one time axis, with a second axis | 1.3 | -| Pie and donut | How a total splits into parts, such as where today's energy came from | 1.4 | -| Rings | Several batteries or goals in one dial | 1.4 | -| Pictograph | Counting: chores done, glasses of water, people at home | 1.4 | -| Waffle | One share out of 100, or a split of one total | 1.4 | -| Histogram | How long a room spent in each temperature range | 1.5 | -| Scatter | One sensor against another, such as heating against outside temperature | 1.5 | -| Bubble | A scatter sized by a third sensor: temperature, humidity and CO₂ | 1.5 | -| Heatmap | Power by hour and weekday, or a year of days as a calendar | 1.5 | -| Box plot | How readings spread, per entity or per day, week or month | 1.5 | -| Sparklines, range areas, timelines and more | More from your history | [Planned][roadmap] | +| Pie and donut | How a total splits into parts, such as where today's energy came from | 2.0 | +| Rings | Several batteries or goals in one dial | 2.0 | +| Pictograph | Counting: chores done, glasses of water, people at home | 2.0 | +| Waffle | One share out of 100, or a split of one total | 2.0 | +| Histogram | How long a room spent in each temperature range | 2.0 | +| Scatter | One sensor against another, such as heating against outside temperature | 2.0 | +| Bubble | A scatter sized by a third sensor: temperature, humidity and CO₂ | 2.0 | +| Heatmap | Power by hour and weekday, or a year of days as a calendar | 2.0 | +| Box plot | How readings spread, per entity or per day, week or month | 2.0 | +| Funnel | Solar produced, used in the house, exported | 2.0 | +| Waterfall | How a total is built up or worn down, step by step | 2.0 | +| Treemap | Power use by floor and room | 2.0 | +| Sunburst and icicle | A hierarchy as rings or bars, tap to drill in | 2.0 | +| Radar | Comparing profiles of several values | 2.0 | +| Polar area | Values as slice lengths around a circle | 2.0 | +| Grouped and stacked bars | Several values side by side, stacked or as 100 % | 2.0 | +| Bullet and lollipop | A value against its target; many values with little ink | 2.0 | +| Slope and dumbbell | Each entity then and now, or its lowest and highest | 2.0 | +| Range area | Each period's lowest to highest, shaded | 2.0 | +| Sparklines | A tiny trend beside each entity's name and value | 2.0 | +| Streamgraph | How several entities share a total over time | 2.0 | +| Candlestick | Open, high, low and close per period | 2.0 | +| Violin, raincloud and beeswarm | How readings are spread, or every reading as a dot | 2.0 | +| State timeline | When each entity was on, off, home or heating | 2.0 | +| Climate | A thermostat's temperature against its set point | 2.0 | +| Sankey | How power flows between sources and loads | 2.0 | +| Wind rose | Where the wind comes from, and how hard | 2.0 | +| 24-hour clock | When each entity was on, around the clock | 2.0 | +| Trend card | A compact tile: the value over its recent history | 2.0 | +| Line chart extras | Zoom and pan, annotations, value labels, synced charts, small multiples, highest/lowest/average marks, log scale | 2.0 | ## Installation diff --git a/dist/chartdeck-card.js b/dist/chartdeck-card.js index 287771c..b1fa463 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 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:_}; +const e=globalThis,t=e.ShadowRoot&&(void 0===e.ShadyCSS||e.ShadyCSS.nativeShadow)&&"adoptedStyleSheets"in Document.prototype&&"replace"in CSSStyleSheet.prototype,o=Symbol(),a=new WeakMap;let n=class{constructor(e,t,a){if(this._$cssResult$=!0,a!==o)throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead.");this.cssText=e,this.t=t}get styleSheet(){let e=this.o;const o=this.t;if(t&&void 0===e){const t=void 0!==o&&1===o.length;t&&(e=a.get(o)),void 0===e&&((this.o=e=new CSSStyleSheet).replaceSync(this.cssText),t&&a.set(o,e))}return e}toString(){return this.cssText}};const r=(e,...t)=>{const a=1===e.length?e[0]:t.reduce((t,o,a)=>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.")})(o)+e[a+1],e[0]);return new n(a,e,o)},i=t?e=>e:e=>e instanceof CSSStyleSheet?(e=>{let t="";for(const o of e.cssRules)t+=o.cssText;return(e=>new n("string"==typeof e?e:e+"",void 0,o))(t)})(e):e,{is:s,defineProperty:l,getOwnPropertyDescriptor:c,getOwnPropertyNames:d,getOwnPropertySymbols:u,getPrototypeOf:h}=Object,p=globalThis,m=p.trustedTypes,f=m?m.emptyScript:"",g=p.reactiveElementPolyfillSupport,v=(e,t)=>e,y={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 o=e;switch(t){case Boolean:o=null!==e;break;case Number:o=null===e?null:Number(e);break;case Object:case Array:try{o=JSON.parse(e)}catch(e){o=null}}return o}},b=(e,t)=>!s(e,t),_={attribute:!0,type:String,converter:y,reflect:!1,useDefault:!1,hasChanged:b}; /** * @license * Copyright 2017 Google LLC * SPDX-License-Identifier: BSD-3-Clause - */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"); + */Symbol.metadata??=Symbol("metadata"),p.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=_){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 o=Symbol(),a=this.getPropertyDescriptor(e,o,t);void 0!==a&&l(this.prototype,e,a)}}static getPropertyDescriptor(e,t,o){const{get:a,set:n}=c(this.prototype,e)??{get(){return this[t]},set(e){this[t]=e}};return{get:a,set(t){const r=a?.call(this);n?.call(this,t),this.requestUpdate(e,r,o)},configurable:!0,enumerable:!0}}static getPropertyOptions(e){return this.elementProperties.get(e)??_}static _$Ei(){if(this.hasOwnProperty(v("elementProperties")))return;const e=h(this);e.finalize(),void 0!==e.l&&(this.l=[...e.l]),this.elementProperties=new Map(e.elementProperties)}static finalize(){if(this.hasOwnProperty(v("finalized")))return;if(this.finalized=!0,this._$Ei(),this.hasOwnProperty(v("properties"))){const e=this.properties,t=[...d(e),...u(e)];for(const o of t)this.createProperty(o,e[o])}const e=this[Symbol.metadata];if(null!==e){const t=litPropertyMetadata.get(e);if(void 0!==t)for(const[e,o]of t)this.elementProperties.set(e,o)}this._$Eh=new Map;for(const[t,o]of this.elementProperties){const e=this._$Eu(t,o);void 0!==e&&this._$Eh.set(e,t)}this.elementStyles=this.finalizeStyles(this.styles)}static finalizeStyles(e){const t=[];if(Array.isArray(e)){const o=new Set(e.flat(1/0).reverse());for(const e of o)t.unshift(i(e))}else void 0!==e&&t.push(i(e));return t}static _$Eu(e,t){const o=t.attribute;return!1===o?void 0:"string"==typeof o?o:"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 o of t.keys())this.hasOwnProperty(o)&&(e.set(o,this[o]),delete this[o]);e.size>0&&(this._$Ep=e)}createRenderRoot(){const o=this.shadowRoot??this.attachShadow(this.constructor.shadowRootOptions);return((o,a)=>{if(t)o.adoptedStyleSheets=a.map(e=>e instanceof CSSStyleSheet?e:e.styleSheet);else for(const t of a){const a=document.createElement("style"),n=e.litNonce;void 0!==n&&a.setAttribute("nonce",n),a.textContent=t.cssText,o.appendChild(a)}})(o,this.constructor.elementStyles),o}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,o){this._$AK(e,o)}_$ET(e,t){const o=this.constructor.elementProperties.get(e),a=this.constructor._$Eu(e,o);if(void 0!==a&&!0===o.reflect){const n=(void 0!==o.converter?.toAttribute?o.converter:y).toAttribute(t,o.type);this._$Em=e,null==n?this.removeAttribute(a):this.setAttribute(a,n),this._$Em=null}}_$AK(e,t){const o=this.constructor,a=o._$Eh.get(e);if(void 0!==a&&this._$Em!==a){const e=o.getPropertyOptions(a),n="function"==typeof e.converter?{fromAttribute:e.converter}:void 0!==e.converter?.fromAttribute?e.converter:y;this._$Em=a;const r=n.fromAttribute(t,e.type);this[a]=r??this._$Ej?.get(a)??r,this._$Em=null}}requestUpdate(e,t,o,a=!1,n){if(void 0!==e){const r=this.constructor;if(!1===a&&(n=this[e]),o??=r.getPropertyOptions(e),!((o.hasChanged??b)(n,t)||o.useDefault&&o.reflect&&n===this._$Ej?.get(e)&&!this.hasAttribute(r._$Eu(e,o))))return;this.C(e,t,o)}!1===this.isUpdatePending&&(this._$ES=this._$EP())}C(e,t,{useDefault:o,reflect:a,wrapped:n},r){o&&!(this._$Ej??=new Map).has(e)&&(this._$Ej.set(e,r??t??this[e]),!0!==n||void 0!==r)||(this._$AL.has(e)||(this.hasUpdated||o||(t=void 0),this._$AL.set(e,t)),!0===a&&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,o]of e){const{wrapped:e}=o,a=this[t];!0!==e||this._$AL.has(t)||void 0===a||this.C(t,void 0,o,a)}}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[v("elementProperties")]=new Map,x[v("finalized")]=new Map,g?.({ReactiveElement:x}),(p.reactiveElementVersions??=[]).push("2.1.2"); /** * @license * Copyright 2017 Google LLC * SPDX-License-Identifier: BSD-3-Clause */ -const $=globalThis,w=e=>e,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;ie,k=w.trustedTypes,M=k?k.createPolicy("lit-html",{createHTML:e=>e}):void 0,z="$lit$",A=`lit$${Math.random().toFixed(9).slice(2)}$`,S="?"+A,E=`<${S}>`,C=document,j=()=>C.createComment(""),N=e=>null===e||"object"!=typeof e&&"function"!=typeof e,L=Array.isArray,T="[ \t\n\f\r]",O=/<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g,F=/-->/g,P=/>/g,D=RegExp(`>|${T}(?:([^\\s"'>=/]+)(${T}*=${T}*(?:[^ \t\n\f\r"'\`<>=]|("|')|))|$)`,"g"),U=/'/g,H=/"/g,I=/^(?:script|style|textarea|title)$/i,B=e=>(t,...o)=>({_$litType$:e,strings:t,values:o}),R=B(1),Z=B(2),q=Symbol.for("lit-noChange"),W=Symbol.for("lit-nothing"),V=new WeakMap,Y=C.createTreeWalker(C,129);function G(e,t){if(!L(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},o){let a;this.parts=[];let n=0,r=0;const i=e.length-1,s=this.parts,[l,c]=((e,t)=>{const o=e.length-1,a=[];let n,r=2===t?"":3===t?"":"",i=O;for(let s=0;s"===l[0]?(i=n??O,c=-1):void 0===l[1]?c=-2:(c=i.lastIndex-l[2].length,o=l[1],i=void 0===l[3]?D:'"'===l[3]?H:U):i===H||i===U?i=D:i===F||i===P?i=O:(i=D,n=void 0);const u=i===D&&e[s+1].startsWith("/>")?" ":"";r+=i===O?t+E:c>=0?(a.push(o),t.slice(0,c)+z+t.slice(c)+A+u):t+A+(-2===c?s:u)}return[G(e,r+(e[o]||"")+(2===t?"":3===t?"":"")),a]})(e,t);if(this.el=X.createElement(l,o),Y.currentNode=this.el.content,2===t||3===t){const e=this.el.content.firstChild;e.replaceWith(...e.childNodes)}for(;null!==(a=Y.nextNode())&&s.length0){a.textContent=k?k.emptyScript:"";for(let o=0;oL(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&&N(this._$AH)?this._$AA.nextSibling.data=e:this.T(C.createTextNode(e)),this._$AH=e}$(e){const{values:t,_$litType$:o}=e,a="number"==typeof o?this._$AC(e):(void 0===o.el&&(o.el=X.createElement(G(o.h,o.h[0]),this.options)),o);if(this._$AH?._$AD===a)this._$AH.p(t);else{const e=new K(a,this),o=e.u(this.options);e.p(t),this.T(o),this._$AH=e}}_$AC(e){let t=V.get(e.strings);return void 0===t&&V.set(e.strings,t=new X(e)),t}k(e){L(this._$AH)||(this._$AH=[],this._$AR());const t=this._$AH;let o,a=0;for(const n of e)a===t.length?t.push(o=new Q(this.O(j()),this.O(j()),this,this.options)):o=t[a],o._$AI(n),a++;a2||""!==o[0]||""!==o[1]?(this._$AH=Array(o.length-1).fill(new String),this.strings=o):this._$AH=W}_$AI(e,t=this,o,a){const n=this.strings;let r=!1;if(void 0===n)e=J(this,e,t,0),r=!N(e)||e!==this._$AH&&e!==q,r&&(this._$AH=e);else{const a=e;let i,s;for(e=n[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 q}};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,o)=>{const a=o?.renderBefore??t;let n=a._$litPart$;if(void 0===n){const e=o?.renderBefore??null;a._$litPart$=n=new Q(t.insertBefore(j(),e),e,void 0,o??{})}return n._$AI(e),n})(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,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)}}; +const de=1,ue=2,he=3,pe=4,me=e=>(...t)=>({_$litDirective$:e,values:t});let fe=class{constructor(e){}get _$AU(){return this._$AM._$AU}_$AT(e,t,o){this._$Ct=e,this._$AM=t,this._$Ci=o}_$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}=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; + */const{I:ge}=re,ve=e=>e,ye=()=>document.createComment(""),be=(e,t,o)=>{const a=e._$AA.parentNode,n=void 0===t?e._$AB:t._$AA;if(void 0===o){const t=a.insertBefore(ye(),n),r=a.insertBefore(ye(),n);o=new ge(t,r,e,e.options)}else{const t=o._$AB.nextSibling,r=o._$AM,i=r!==e;if(i){let t;o._$AQ?.(e),o._$AM=e,void 0!==o._$AP&&(t=e._$AU)!==r._$AU&&o._$AP(t)}if(t!==n||i){let e=o._$AA;for(;e!==t;){const t=ve(e).nextSibling;ve(a).insertBefore(e,n),e=t}}}return o},_e=(e,t,o=e)=>(e._$AI(t,o),e),xe={},we=(e,t=xe)=>e._$AH=t,$e=e=>{e._$AR(),e._$AA.remove()},ke=(e,t,o)=>{const a=new Map;for(let n=t;n<=o;n++)a.set(e[n],n);return a},Me=me(class extends fe{constructor(e){if(super(e),e.type!==ue)throw Error("repeat() can only be used in text expressions")}dt(e,t,o){let a;void 0===o?o=t:void 0!==t&&(a=t);const n=[],r=[];let i=0;for(const s of e)n[i]=a?a(s,i):i,r[i]=o(s,i),i++;return{values:r,keys:n}}render(e,t,o){return this.dt(e,t,o).values}update(e,[t,o,a]){const n=(e=>e._$AH)(e),{values:r,keys:i}=this.dt(t,o,a);if(!Array.isArray(n))return this.ut=i,r;const s=this.ut??=[],l=[];let c,d,u=0,h=n.length-1,p=0,m=r.length-1;for(;u<=h&&p<=m;)if(null===n[u])u++;else if(null===n[h])h--;else if(s[u]===i[p])l[p]=_e(n[u],r[p]),u++,p++;else if(s[h]===i[m])l[m]=_e(n[h],r[m]),h--,m--;else if(s[u]===i[m])l[m]=_e(n[u],r[m]),be(e,l[m+1],n[u]),u++,m--;else if(s[h]===i[p])l[p]=_e(n[h],r[p]),be(e,n[u],n[h]),h--,p++;else if(void 0===c&&(c=ke(i,p,m),d=ke(s,u,h)),c.has(s[u]))if(c.has(s[h])){const t=d.get(i[p]),o=void 0!==t?n[t]:null;if(null===o){const t=be(e,n[u]);_e(t,r[p]),l[p]=t}else l[p]=_e(o,r[p]),be(e,n[u],o),n[t]=null;p++}else $e(n[h]),h--;else $e(n[u]),u++;for(;p<=m;){const t=be(e,l[m+1]);_e(t,r[p]),l[p++]=t}for(;u<=h;){const e=n[u++];null!==e&&$e(e)}return this.ut=i,we(e,l),q}}),ze=e=>!!e&&"none"!==e.action; /** * @license * Copyright 2017 Google LLC * SPDX-License-Identifier: BSD-3-Clause - */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} + */function Ae(e){return ze(e.tap_action)||ze(e.hold_action)||ze(e.double_tap_action)}function Se(e,t){let o,a,n=0,r=0,i=!1,s=!1;const l=o=>{const a=t(),n="tap"===o?a.tap_action:"hold"===o?a.hold_action:a.double_tap_action;ze(n)&&e.dispatchEvent(new CustomEvent("hass-action",{bubbles:!0,composed:!0,detail:{config:a,action:o}}))},c=()=>{void 0!==o&&clearTimeout(o),o=void 0},d=e=>{const a=e;a.button>0||(i=!0,s=!1,n=a.clientX,r=a.clientY,c(),ze(t().hold_action)&&(o=setTimeout(()=>{o=void 0,s=!0,l("hold")},500)))},u=e=>{if(!i)return;const t=e;Math.hypot(t.clientX-n,t.clientY-r)>10&&(c(),i=!1)},h=()=>{c(),i=!1},p=()=>{const e=i;c(),i=!1,e&&!s?ze(t().double_tap_action)?void 0!==a?(clearTimeout(a),a=void 0,l("double_tap")):a=setTimeout(()=>{a=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=>{ze(t().hold_action)&&e.preventDefault()};return e.addEventListener("pointerdown",d),e.addEventListener("pointermove",u),e.addEventListener("pointerup",p),e.addEventListener("pointercancel",h),e.addEventListener("pointerleave",h),e.addEventListener("keydown",m),e.addEventListener("contextmenu",f),()=>{c(),void 0!==a&&clearTimeout(a),a=void 0,e.removeEventListener("pointerdown",d),e.removeEventListener("pointermove",u),e.removeEventListener("pointerup",p),e.removeEventListener("pointercancel",h),e.removeEventListener("pointerleave",h),e.removeEventListener("keydown",m),e.removeEventListener("contextmenu",f)}}const Ee={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 Ce(e){if(Object.prototype.hasOwnProperty.call(Ee,e)){const{variable:t,fallback:o}=Ee[e];return`var(${t}, ${o})`}return e}function je(e){const t=Ce(e);return{start:t,end:`color-mix(in srgb, ${t} 80%, #fff)`}}const Ne=new Set(["amber"]);function Le(e){if(Object.prototype.hasOwnProperty.call(Ee,e))return Ne.has(e)?"dark":"light";const t=function(e){const t=e.trim().toLowerCase(),o=/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/.exec(t);if(o){const e=o[1].length<=4?[...o[1]].map(e=>e+e).join(""):o[1],[t,a,n]=[0,2,4].map(t=>parseInt(e.slice(t,t+2),16));return[t,a,n,8===e.length?parseInt(e.slice(6,8),16)/255:1]}const a=/^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)\s*(?:[,/]\s*([\d.]+)(%?)\s*)?\)$/.exec(t);if(a){const[e,t,o]=[a[1],a[2],a[3]].map(e=>Math.min(255,Number(e))),n=void 0===a[4]?1:Number(a[4])/(a[5]?100:1);return[e,t,o,Math.min(1,n)]}}(e);return t?t[3]<.5?"theme":function([e,t,o]){const a=e=>{const t=e/255;return t<=.03928?t/12.92:((t+.055)/1.055)**2.4};return.2126*a(e)+.7152*a(t)+.0722*a(o)}([t[0],t[1],t[2]])>.4?"dark":"light":"light"}function Te(e,t){let o=t[0];for(const a of t){if(!(a.from<=e))break;o=a}return o}function Oe(e,t,o){const a=o?` ${o}`:"",n=[];return e.forEach((o,r)=>{const i=e[r+1],s=Ce(o.color);o.label?n.push({color:s,text:o.label}):Number.isFinite(o.from)?i?n.push({color:s,text:`${t(o.from)}–${t(i.from)}${a}`}):n.push({color:s,text:`${t(o.from)}${a}+`}):i&&n.push({color:s,text:`< ${t(i.from)}${a}`})}),n}const Fe={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},Pe=["label","area","floor","device_class","domain","entity"];function De(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 o="include"===t?Pe.filter(e=>"entity"!==e):Pe,a={};for(const[n,r]of Object.entries(e)){if(!o.includes(n))throw new Error(`"${t}" can't match on "${n}". Use: ${o.join(", ")}`);const e=(Array.isArray(r)?r:[r]).filter(e=>null!=e&&""!==e);if(e.some(e=>"string"!=typeof e))throw new Error(`"${t}.${n}" must be text or a list of text`);e.length&&(a[n]=e)}return Object.keys(a).length?a:void 0}function Ue(e){const t=e.state;return"unavailable"===t||"unknown"===t||""!==t.trim()&&Number.isFinite(Number(t))}function He(e,t){const o=e.entity_id,a=t.entities?.[o],n=a?.device_id?t.devices?.[a.device_id]:void 0,r=a?.area_id??n?.area_id??void 0,i=e.attributes?.device_class;return{label:[...a?.labels??[],...n?.labels??[]],area:r,floor:r?t.areas?.[r]?.floor_id??void 0:void 0,device_class:"string"==typeof i?i:void 0,domain:o.slice(0,o.indexOf(".")),entity:o}}function Ie(e,t,o){const a=e[t],n=o[t];return Array.isArray(n)?n.some(e=>a.includes(e)):void 0!==n&&a.includes(n)}function Be(e,t){return Object.keys(e).every(o=>Ie(e,o,t))}function Re(e,t){return!!e&&Object.keys(e).some(o=>Ie(e,o,t))}const Ze=new WeakMap;const qe=e=>(Math.abs(e)<.005?0:e).toFixed(2);function We(e,t,o,a){const n=a*Math.PI/180;return[e+o*Math.sin(n),t-o*Math.cos(n)]}function Ve(e,t,o,a,n){const r=String(Number(o.toFixed(2))),i=n-a,[s,l]=We(e,t,o,a);if(i>=359.99){const[n,i]=We(e,t,o,a+180);return`M${qe(s)} ${qe(l)} A${r} ${r} 0 1 1 ${qe(n)} ${qe(i)} A${r} ${r} 0 1 1 ${qe(s)} ${qe(l)}`}const[c,d]=We(e,t,o,n),u=i>180?1:0;return`M${qe(s)} ${qe(l)} A${r} ${r} 0 ${u} 1 ${qe(c)} ${qe(d)}`}const Ye=["value","band","name","text","none"],Ge={ring:[-180,180],arc:[-135,135],needle:[-90,90]};function Xe(e,t){if(null==e||""===e)return;const o="string"==typeof e&&""!==e.trim()?Number(e):e;if("number"!=typeof o||!Number.isFinite(o)||o<-360||o>360)throw new Error(`"${t}" must be a number of degrees from -360 to 360`);return o}function Je(e,t){return[t?.startAngle??e[0],t?.endAngle??e[1]]}function Ke(e){return void 0!==e?.startAngle||void 0!==e?.endAngle}function Qe([e,t],o){return e+(t-e)*o}function et(...e){return{x0:Math.min(...e.map(e=>e.x0)),y0:Math.min(...e.map(e=>e.y0)),x1:Math.max(...e.map(e=>e.x1)),y1:Math.max(...e.map(e=>e.y1))}}function tt(e,t,o,a,n,r){const i=[a,n];for(let h=Math.ceil(a/90);90*h<=n;h++)i.push(90*h);const s=i.map(a=>We(e,t,o,a)),l=Math.min(...s.map(e=>e[0])),c=Math.min(...s.map(e=>e[1])),d=Math.max(...s.map(e=>e[0])),u=Math.max(...s.map(e=>e[1]));return{x0:l-r,y0:c-r,x1:d+r,y1:u+r}}function ot(e,t=5){if(!Number.isFinite(e)||e<=0)return 1;const o=e/t,a=10**Math.floor(Math.log10(o)),n=o/a;return(n<=1?1:n<=2?2:n<=2.5?2.5:n<=5?5:10)*a}function at(e,t){const o=e=>{const t=String(Number(e.toPrecision(12)));return t.includes("e")?0:t.split(".")[1]?.length??0};return Math.min(3,Math.max(o(t),o(e)))}function nt(e){const t=e*Math.PI/180,o=Math.sin(t),a=Math.cos(t);return{anchor:o>.3?"start":o<-.3?"end":"middle",dy:a>.3?0:a<-.3?.75:.35}}function rt(e,t){switch(t?.center??"value"){case"band":return e.band.label?{kind:"text",text:e.band.label,band:!0}:{kind:"value"};case"name":return{kind:"text",text:e.name,band:!1};case"text":return t.centerText?{kind:"text",text:t.centerText,band:!1}:{kind:"none"};case"none":return{kind:"none"};default:return{kind:"value"}}}const it=["bar","thin","segmented","tank","ring","needle","arc","line","column","area","combined","pie","rings","pictograph","waffle","histogram","scatter","bubble","heatmap","box","funnel","radar","polar_area","treemap","sunburst","icicle","grouped_bars","bullet","lollipop","waterfall","slope","dumbbell","range_area","sparkline","streamgraph","candlestick","violin","beeswarm","sankey","timeline","climate","wind_rose","day_clock","trend"],st=[{from:-1/0,color:"primary"}],lt=["none","ascending","descending"];function ct(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 dt(e,t){if(null==e||""===e)return;const o=ct(e);if(void 0!==o)return o;if("string"==typeof e)return e;throw new Error(`"${t}" must be a number or an entity id`)}function ut(e,t,o,a=1/0){if(null==e||""===e)return;const n=ct(e);if(void 0===n||!Number.isInteger(n)||na){throw new Error(`"${t}" must be a whole number ${a===1/0?`of at least ${o}`:`from ${o} to ${a}`}`)}return n}function ht(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 o;try{o=dt(e?.from,"from")}catch{o=void 0}if(void 0===o)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:o}});return t.every(e=>"number"==typeof e.from)?t.sort((e,t)=>e.from-t.from):t}function pt(e,t,o){if(null==e)return{action:t};if("object"!=typeof e||"string"!=typeof e.action)throw new Error(`"${o}" must have an "action"`);return e}function mt(e,t){return void 0!==e?e:t}function ft(e,t,o){const a="string"==typeof e?{entity:e}:e;if(!a||"object"!=typeof a||"string"!=typeof a.entity||!a.entity)throw new Error(`Entity ${o+1} needs an "entity" id`);if(void 0!==a.attribute&&null!==a.attribute&&"string"!=typeof a.attribute)throw new Error(`Entity ${o+1}: "attribute" must be an attribute name`);const n=mt(dt(a.min,"min"),dt(t.min,"min"))??0,r=mt(dt(a.max,"max"),dt(t.max,"max"))??100;if("number"==typeof n&&"number"==typeof r&&n>=r)throw new Error(`"max" (${r}) must be greater than "min" (${n})`);const i={...a,entity:a.entity,min:n,max:r,severity:ht(a.severity)??ht(t.severity)??st,tap_action:pt(mt(a.tap_action,t.tap_action),"more-info","tap_action"),hold_action:pt(mt(a.hold_action,t.hold_action),"none","hold_action"),double_tap_action:pt(mt(a.double_tap_action,t.double_tap_action),"none","double_tap_action")},s=mt(a.name,t.name),l=mt(a.icon,t.icon),c=mt(a.unit,t.unit),d=mt(dt(a.target,"target"),dt(t.target,"target")),u=mt(ut(a.decimals,"decimals",0,20),ut(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!==u&&(i.decimals=u),a.attribute||delete i.attribute,Ge[t.style??"bar"]&&(i.gauge=function(e,t=e.style??"bar"){const o=Xe(e.start_angle,"start_angle"),a=Xe(e.end_angle,"end_angle"),n=Ge[t];if(n&&(void 0!==o||void 0!==a)){const[e,t]=[o??n[0],a??n[1]];if(!(t>e))throw new Error(`"end_angle" (${t}) must be greater than "start_angle" (${e})`);if(t-e>360)throw new Error('"end_angle" can be at most 360 degrees after "start_angle"')}const r=e.center??"value";if(!Ye.includes(r))throw new Error(`"center" must be one of: ${Ye.join(", ")}`);if(void 0!==e.center_text&&null!==e.center_text&&"string"!=typeof e.center_text)throw new Error('"center_text" must be text');if(void 0!==e.tick_labels&&"boolean"!=typeof e.tick_labels)throw new Error('"tick_labels" must be true or false');const i={center:r,centerText:e.center_text??"",tickLabels:!0===e.tick_labels};return void 0!==o&&(i.startAngle=o),void 0!==a&&(i.endAngle=a),i}(t)),i} /** * @license * Copyright 2020 Google LLC * SPDX-License-Identifier: BSD-3-Clause */ -const et=pe(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de&&e.type!==me)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===q||t===W)return t;const n=e.element,o=e.name;if(e.type===ue){if(t===n[o])return q}else if(e.type===me){if(!!t===n.hasAttribute(o))return q}else if(e.type===de&&n.getAttribute(o)===t+"")return q;return $e(e),t}}),tt={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 nt(e,t){tt[e]={...tt[e],...t}}function ot(e){return(e?.locale?.language??e?.language??"en").split("-")[0]}function at(e,t,n){const o=ot(e),a=tt[o]?.[t]??tt.en[t]??t;return n?a.replace(/\{(\w+)\}/g,(e,t)=>t in n?String(n[t]):e):a}function rt(e){return null==e||""===e}function it(e){return"object"==typeof e&&null!==e&&!Array.isArray(e)}function st(e,t,n){const[o,...a]=t,r=function(e,t){return Array.isArray(e)?[...e]:it(e)?{...e}:"number"==typeof t?[]:{}}(e,o),i=r,s=a.length?st(i[o],a,n):n,l=a.length>0&&it(s)&&0===Object.keys(s).length;return rt(s)?Array.isArray(r)&&"number"==typeof o?r.splice(o,1):delete i[o]:l&&it(r)?delete i[o]:i[o]=s,r}function lt(e,t,n){return 0===t.length||rt(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:st(e,t,n)}function ct(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 dt(e,t){return[...e.filter(e=>!t(e)),...e.filter(t)]}function ht(e){return e.map(([e,t])=>`M${Math.round(100*e)/100},${Math.round(100*t)/100}h0`).join("")}function ut(e){return Math.round(10*(e+3.5))/10}function mt(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 ft(e){mt(e.line_width,'"line_width"'),pt(e.dots,'"dots"');for(const t of e.entities??[])"object"==typeof t&&(mt(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 gt=[{name:"title",path:["title"]}],yt=[{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}],vt=[{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"]}],bt=[{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"}}],xt=[{name:"name",path:["name"]},{name:"unit",path:["unit"]},{name:"decimals",path:["decimals"]}],$t=[{name:"attribute",path:["attribute"]}],wt=[{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}],kt={number:{min:.5,max:12,step:.5,mode:"box",unit_of_measurement:"px"}},Mt=new Map;function At(e){let t=Mt.get(e);return t||(t=[...wt,{name:"dots",path:["dots"],default:e}],Mt.set(e,t)),t}const St=bt.map(({name:e,path:t})=>({name:e,path:t})),Et=[{name:"title",selector:{text:{}}}],Ct=bt.map(({name:e})=>({name:e,selector:{ui_action:{}}})),zt=[{type:"grid",name:"",schema:[{name:"name",selector:{text:{}}},{name:"unit",selector:{text:{}}},{name:"decimals",selector:{number:{min:0,max:20,mode:"box"}}}]}],Lt=new Map;let Tt;function jt(e){const t=ot(e);if(Tt?.language!==t){const n=t=>at(e,t);Tt={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:kt},{name:"dash",selector:{boolean:{}}},{name:"dots",selector:{boolean:{}}},{name:"on_top",selector:{boolean:{}}}]}]}}return Tt.schema}function Ot(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:at(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 Ut(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 Ft=new Map;function Pt(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=Ft.get(e);if(a?.key===o)return a.schema;const r=Nt[e].map(t=>({name:`${e}_${t}`,selector:Ut(t,n)}));return Ft.set(e,{key:o,schema:r}),r}let Dt;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 Bt(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=>Bt(e[n],t[n]))}function It(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 Rt(e,t,n){const o=It(e,t);let a=e;for(const r of t){const e=n[r.name];Bt(o[r.name],e)||(a=lt(a,r.path,void 0!==r.default&&Bt(e,r.default)?void 0:e))}return a}const Vt="var(--divider-color, rgba(127, 127, 127, 0.3))",qt="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 Yt(e,t,n,o){return Zt(e,t,n,o).join(" ")}function Gt(e,t,n,o,a){const r=a-o>180?1:0;return`M ${Yt(e,t,n,o)} A ${n} ${n} 0 ${r} 1 ${Yt(e,t,n,a)}`}function Xt(e){return R``}function Jt(e){let t=1/0,n=-1/0;for(const o of e)for(const e of o)for(const o of[e.v,e.min,e.max])"number"==typeof o&&(on&&(n=o));return t===1/0?void 0:[t,n]}function Kt(e,t){const[n,o]=e,[a,r]=t,i=o===n?0:(r-a)/(o-n);return e=>a+(e-n)*i}const Qt=["outside","inside"],en=!0,tn=!0,nn="outside",on=!0,an=!1,rn=e=>"number"==typeof e&&Number.isFinite(e);function sn(e){const t=e;for(const n of["x_axis","y_axis"])if(void 0!==t[n]&&"boolean"!=typeof t[n])throw new Error(`"${n}" must be true or false`);if(void 0!==t.axis_labels&&!Qt.includes(t.axis_labels))throw new Error(`"axis_labels" must be one of: ${Qt.join(", ")}`);if(void 0!==t.gridlines){const e=t.gridlines;if("object"!=typeof e||null===e||Array.isArray(e))throw new Error('"gridlines" must have "horizontal" and "vertical" (true or false)');for(const t of["horizontal","vertical"])if(void 0!==e[t]&&"boolean"!=typeof e[t])throw new Error(`"gridlines.${t}" must be true or false`)}for(const n of["y_min","y_max"])if(void 0!==t[n]&&!rn(t[n]))throw new Error(`"${n}" must be a number`);if(rn(t.y_min)&&rn(t.y_max)&&t.y_min>=t.y_max)throw new Error('"y_min" must be below "y_max"')}function ln(e){const t=e,n=t.gridlines??{};return{x:t.x_axis??en,y:t.y_axis??tn,labels:t.axis_labels??nn,horizontal:n.horizontal??on,vertical:n.vertical??an,min:rn(t.y_min)?t.y_min:void 0,max:rn(t.y_max)?t.y_max:void 0}}function cn(e,t){if(!(e>0&&Number.isFinite(e)))return 1;const n=e/Math.max(1,t),o=10**Math.floor(Math.log10(n));for(const a of[1,2,5])if(a*o>=n*(1-1e-9))return a*o;return 10*o}function dn(e){return Math.max(0,-Math.floor(Math.log10(e)+1e-9))}const hn=e=>Number(e.toPrecision(12));function un(e,t,n){let[o,a]=function([e,t],n=.08){if(e===t)return[e-1,t+1];const o=(t-e)*n;return[e-o,t+o]}(e??[0,1]);void 0!==t.min&&(o=t.min),void 0!==t.max&&(a=t.max),a<=o&&(void 0!==t.min?a=o+Math.max(1,.1*Math.abs(o)):o=a-Math.max(1,.1*Math.abs(a)));const r=cn(a-o,n);void 0===t.min&&(o=hn(Math.floor(o/r+1e-9)*r)),void 0===t.max&&(a=hn(Math.ceil(a/r-1e-9)*r));const i=[];for(let s=Math.ceil(o/r-1e-9);s*r<=a+1e-9*r;s++)i.push(hn(s*r));return{domain:[o,a],ticks:i,decimals:dn(r)}}const mn=6e4,pn=36e5,fn=864e5,gn=[...[1,5,10,15,30].map(e=>({unit:"minute",n:e,ms:e*mn})),...[1,2,3,6,12].map(e=>({unit:"hour",n:e,ms:e*pn})),...[1,2,7].map(e=>({unit:"day",n:e,ms:e*fn})),...[1,2,3,6].map(e=>({unit:"month",n:e,ms:30.44*e*fn})),...[1,2,5,10,20,50,100].map(e=>({unit:"year",n:e,ms:365.25*e*fn}))];const yn=e=>-new Date(e).getTimezoneOffset()*mn;function vn(e){if(!e)return yn;let t;try{t=new Intl.DateTimeFormat("en-US",{timeZone:e,hourCycle:"h23",year:"numeric",month:"numeric",day:"numeric",hour:"numeric",minute:"numeric",second:"numeric"})}catch{return yn}return e=>{const n={};for(const o of t.formatToParts(e))n[o.type]=Number(o.value);return Date.UTC(n.year,n.month-1,n.day,n.hour%24,n.minute,n.second)-1e3*Math.floor(e/1e3)}}function _n(e,t){return e-t(e-t(e))}function bn(e,t,n,o){const a=[],r=e.end+t(e.end);for(let i=n,s=0;i<=r&&s<1e3;i=o(i),s++){const n=_n(i,t);n>=e.start&&n<=e.end&&n!==a[a.length-1]&&a.push(n)}return a}function xn(e,t,n=yn){const{unit:o,n:a}=function(e,t){return gn.find(n=>e/n.ms<=Math.max(1,t))??gn[gn.length-1]}(e.end-e.start,t),r=e.start+n(e.start);if("minute"===o||"hour"===o||"day"===o&&7!==a){const t=a*("minute"===o?mn:"hour"===o?pn:fn);return{unit:o,ticks:bn(e,n,Math.ceil(r/t)*t,e=>e+t)}}if("day"===o){const t=Math.ceil(r/fn);return{unit:o,ticks:bn(e,n,(t+((4-t)%7+7)%7)*fn,e=>e+7*fn)}}const i=new Date(r);if("month"===o){let t=i.getUTCFullYear(),s=i.getUTCMonth();Date.UTC(t,s){const t=new Date(e);return Date.UTC(t.getUTCFullYear(),t.getUTCMonth()+a)};return{unit:o,ticks:bn(e,n,Date.UTC(t,s),l)}}let s=i.getUTCFullYear();return Date.UTC(s,0)Date.UTC(new Date(e).getUTCFullYear()+a,0))}}function $n(e){return"server"===e?.locale?.time_zone?e.config?.time_zone:void 0}function wn(e){const t=e?.locale?.time_format;return"12"===t||"24"!==t&&void 0}const kn=new Map;function Mn(e,t,n){const o=`${e}|${t}|${JSON.stringify(n)}`;let a=kn.get(o);if(!a){try{a=new Intl.DateTimeFormat(e,{...n,timeZone:t})}catch{a=new Intl.DateTimeFormat(void 0,n)}kn.set(o,a)}return a}function An(e,t,n=!1){return Mn(t?.locale?.language??"en",$n(t),{hour:"numeric",minute:"2-digit",...n?{second:"2-digit"}:{},hour12:wn(t)}).format(e)}function Sn(e,t){return Mn(t?.locale?.language??"en",$n(t),{weekday:"short",day:"numeric",month:"short",hour:"numeric",minute:"2-digit",hour12:wn(t)}).format(e)}function En(e,t,n,o,a){const r=o?.locale?.language??"en",i=$n(o),s=e+(a??vn(i))(e),l=new Date(s),c=s%fn===0;switch(t){case"minute":case"hour":return c?Mn(r,i,{weekday:"short"}).format(e):An(e,o);case"day":return n<=7*fn?Mn(r,i,{weekday:"short"}).format(e):Mn(r,i,{day:"numeric",month:"short"}).format(e);case"month":return Mn(r,i,0===l.getUTCMonth()?{month:"short",year:"numeric"}:{month:"short"}).format(e);default:return Mn(r,i,{year:"numeric"}).format(e)}}function Cn(e,t){if(e.length<=t)return e;const n=[];let o=[];for(const r of e)null===r.v?(o.length&&n.push(o),n.push([r]),o=[]):o.push(r);o.length&&n.push(o);const a=e.length;return n.flatMap(e=>null===e[0].v?e:function(e,t){if(e.length<=t||t<3)return e;const n=[e[0]],o=(e.length-2)/(t-2);let a=0;for(let r=0;rm&&(m=t,u=n)}n.push(e[u]),a=u}return n.push(e[e.length-1]),n}(e,Math.max(3,Math.round(e.length/a*t))))}const zn=36e5,Ln=["Mon","Tue","Wed","Thu","Fri","Sat","Sun"],Tn=["monday","tuesday","wednesday","thursday","friday","saturday","sunday"],jn=new Map;function On(e){let t=jn.get(e);return t||(t=new Intl.DateTimeFormat("en-US",{timeZone:e,hourCycle:"h23",year:"numeric",month:"numeric",day:"numeric",hour:"numeric",minute:"numeric",second:"numeric",weekday:"short"}),jn.set(e,t)),t}function Nn(e,t){const n={};for(const o of On(t).formatToParts(new Date(e)))n[o.type]=o.value;return{year:Number(n.year),month:Number(n.month),day:Number(n.day),hour:Number(n.hour)%24,minute:Number(n.minute),second:Number(n.second),weekday:Ln.indexOf(n.weekday)+1}}function Un(e,t){const n=Nn(e,t);return Date.UTC(n.year,n.month-1,n.day,n.hour,n.minute,n.second)-1e3*Math.floor(e/1e3)}function Fn(e,t,n,o=1,a=0,r=0,i=0){const s=Date.UTC(t,n-1,o,a,r,i),l=new Date(s),c=Un(s-864e5,e),d=Un(s+864e5,e),h=[s-c,s-d].filter(t=>{const n=Nn(t,e);return n.year===l.getUTCFullYear()&&n.month===l.getUTCMonth()+1&&n.day===l.getUTCDate()&&n.hour===l.getUTCHours()&&n.minute===l.getUTCMinutes()&&n.second===l.getUTCSeconds()});return h.length?Math.min(...h):function(e,t,n,o){for(;t-e>1e3;){const a=1e3*Math.floor((e+t)/2e3);Un(a,o)===n?e=a:t=a}return t}(s-d,s-c,c,e)}function Pn(e,t,n){const o=new Date(Date.UTC(e,t-1,n));return{year:o.getUTCFullYear(),month:o.getUTCMonth()+1,day:o.getUTCDate()}}function Dn(e,t,n){const o=Nn(t,n.timeZone);switch(e){case"hour":return t-t%1e3-1e3*(60*o.minute+o.second);case"day":return Fn(n.timeZone,o.year,o.month,o.day);case"week":{const e=(o.weekday-n.firstDay+7)%7,t=Pn(o.year,o.month,o.day-e);return Fn(n.timeZone,t.year,t.month,t.day)}case"month":return Fn(n.timeZone,o.year,o.month,1);case"year":return Fn(n.timeZone,o.year,1,1)}}function Hn(e,t,n,o){if("hour"===e)return t+n*zn;const a=Nn(t,o.timeZone);let{year:r,month:i,day:s}=a;if("day"===e)({year:r,month:i,day:s}=Pn(r,i,s+n));else if("week"===e)({year:r,month:i,day:s}=Pn(r,i,s+7*n));else{const t=Pn(r,i+("year"===e?12*n:n),1),o=Pn(t.year,t.month+1,0).day;({year:r,month:i}=t),s=Math.min(s,o)}return Fn(o.timeZone,r,i,s,a.hour,a.minute,a.second)}function Bn(e,t,n,o,a=1e4){const r=[Dn(e,t,o)];let i=1;for(;r[r.length-1]r[r.length-1]?t:r[r.length-1]+zn),i++}return 1===r.length&&r.push(Hn(e,r[0],1,o)),r}function In(e){try{const t=new Intl.Locale(e),n=t.getWeekInfo?.().firstDay??t.weekInfo?.firstDay;return"number"==typeof n&&n>=1&&n<=7?n:1}catch{return 1}}function Rn(e){const t=e?.locale,n=e?.config?.time_zone,o="server"===t?.time_zone&&function(e){if("string"!=typeof e||""===e)return!1;try{return On(e),!0}catch{return!1}}(n)?n:(new Intl.DateTimeFormat).resolvedOptions().timeZone||"UTC",a=Tn.indexOf(String(t?.first_weekday??"language"))+1;return{timeZone:o,firstDay:a>0?a:In(t?.language??e?.language??"en")}}const Vn=["hour","day","week","month"],qn=["day","week","month","year"],Wn=["now","end"],Zn={last:24,unit:"hour",offset:0,navigate:!1,until:"now"},Yn={hour:36e5,day:864e5,week:6048e5};function Gn(e,t,n){const o="string"==typeof e&&""!==e.trim()?Number(e):e;if("number"!=typeof o||!Number.isInteger(o)||o"number"==typeof e&&Number.isFinite(e)?e:null;function io(e,t){return(e??[]).map(e=>{const n="number"==typeof e.start?e.start:Date.parse(e.start);let o;return o="change"===t?ro(e.change):"state"===t?ro(e.state)??ro(e.mean):"min"===t||"max"===t?ro(e[t])??ro(e.mean)??ro(e.state):ro(e.mean)??ro(e.state),{t:n,v:o,min:e.min??null,max:e.max??null}})}function so(e){const t=(e.end-e.start)/864e5;return t<=2?"5minute":t<=35?"hour":t<=400?"day":"month"}function lo(e){return{period:so(e),types:["mean","min","max","state"],value:"mean"}}function co(e){return e.attribute?`${e.entity}|${e.attribute}`:e.entity}const ho=["hour","day","week","month"],uo=["mean","sum","min","max","first","last","change"];function mo(e){if(void 0!==e.aggregate&&!uo.includes(e.aggregate))throw new Error(`"aggregate" must be one of: ${uo.join(", ")}`);if(void 0!==e.group_by){if(!ho.includes(e.group_by))throw new Error(`"group_by" must be one of: ${ho.join(", ")}`);return{unit:e.group_by,aggregate:e.aggregate??"mean"}}}function po(e,t,n,o,a=!1){const r="change"===n?function(e){let t=null;return e.map(e=>{if(null===e.v)return null;if(null===t)return t=e.v,0;if(e.v>=t){const n=e.v-t;return t=e.v,n}return t-e.v>.1*Math.abs(t)?(t=e.v,Math.max(0,e.v)):0})}(e):[],i=[];let s=0;for(let l=0;l+1c&&m.push({v:p.v,dt:g-c,reading:!1,carried:!0,up:0});fnull!==e.v);if(0===y.length)continue;const v=y.map(e=>e.v);switch(a&&(u.min=Math.min(...v),u.max=Math.max(...v)),n){case"mean":{const e=y.reduce((e,t)=>e+t.dt,0);u.v=e>0?y.reduce((e,t)=>e+t.v*t.dt,0)/e:v.reduce((e,t)=>e+t,0)/v.length;break}case"min":u.v=Math.min(...v);break;case"max":u.v=Math.max(...v);break;case"first":u.v=v[0];break;case"last":u.v=v[v.length-1];break;case"sum":{const e=y.filter(e=>e.reading&&!e.carried);u.v=e.length?e.reduce((e,t)=>e+t.v,0):null;break}case"change":u.v=y.reduce((e,t)=>e+(t.reading&&!t.carried?t.up??0:0),0)}}return i}const fo={"5minute":3e5,hour:36e5,day:9e7,week:6912e5,month:27648e5},go={mean:{types:["mean"],value:"mean"},min:{types:["min"],value:"min"},max:{types:["max"],value:"max"},first:{types:["state","mean"],value:"state"},last:{types:["state","mean"],value:"state"},sum:{types:["change"],value:"change"},change:{types:["change"],value:"change"}};const yo={"5minute":3e5,hour:36e5,day:864e5,week:864e5,month:864e5};class vo{constructor(e){this.onChange=e,this.shape="",this.loadedFrom=1/0,this.loadedTo=-1/0,this.status="loading",this.data=new Map,this.seen=new Map,this.generation=0}get(e,t,n,o,a={}){const r=a.live??!0,i="statistics"===o?a.statistics??lo(n):void 0,s=`${o}|${t.map(co).join(",")}|${i?JSON.stringify(i):""}`,l=void 0!==this.pausedAt&&Date.now()-this.pausedAt>bo,c=void 0!==this.pausedAt&&"statistics"===o&&r;this.pausedAt=void 0;const d=Math.max(6e4,(n.end-n.start)/100),h=n.startthis.loadedTo+d);(s!==this.shape||h||l&&r||c)&&(this.shape=s,this.load(e,t,n,r,i)),"ready"===this.status&&"history"===o&&this.loadedTo===1/0&&this.appendLive(e,t);const u=new Map;for(const[m,p]of this.data)u.set(m,_o(p,n));return{status:this.status,data:u,error:this.error}}pause(){clearTimeout(this.timer),this.timer=void 0,this.pausedAt=Date.now()}dispose(){clearTimeout(this.timer),this.timer=void 0,this.shape="",this.loadedFrom=1/0,this.loadedTo=-1/0,this.generation++}load(e,t,n,o,a){const r=++this.generation;this.status="loading",this.loadedFrom=n.start,this.loadedTo=o?1/0:n.end,clearTimeout(this.timer);const i=a?async function(e,t,n,o=lo(n)){const a=new Map;if(!e.callWS||0===t.length)return a;const r=[...new Set(t.map(e=>e.entity))],i=await e.callWS({type:"recorder/statistics_during_period",start_time:new Date(n.start).toISOString(),end_time:new Date(n.end).toISOString(),statistic_ids:r,period:o.period,types:o.types});for(const s of t)a.set(co(s),io(i?.[s.entity],o.value));return a}(e,t,n,a):async function(e,t,n){const o=new Map;if(!e.callWS||0===t.length)return o;const a={type:"history/history_during_period",start_time:new Date(n.start).toISOString(),end_time:new Date(n.end).toISOString(),significant_changes_only:!1},r=[...new Set(t.filter(e=>!e.attribute).map(e=>e.entity))],i=[...new Set(t.filter(e=>e.attribute).map(e=>e.entity))],s=[];return r.length&&s.push(e.callWS({...a,entity_ids:r,minimal_response:!0,no_attributes:!0}).then(e=>{for(const t of r)o.set(co({entity:t}),ao(e?.[t]))})),i.length&&s.push(e.callWS({...a,entity_ids:i,no_attributes:!1}).then(e=>{for(const n of t.filter(e=>e.attribute))o.set(co(n),ao(e?.[n.entity],n.attribute))})),await Promise.all(s),o}(e,t,n);i.then(n=>{if(r===this.generation){this.data=n,this.status="ready",this.error=void 0,this.seen.clear();for(const n of t)this.seen.set(co(n),e.states[n.entity]);a&&o&&(this.timer=setTimeout(()=>{this.shape="",this.onChange()},yo[a.period])),this.onChange()}},e=>{r===this.generation&&(this.status="error",this.error=e instanceof Error?e.message:String(e?.message??e),this.onChange())})}appendLive(e,t){for(const n of t){const t=co(n),o=e.states[n.entity];if(!o||this.seen.get(t)===o)continue;this.seen.set(t,o);const a=Date.parse(o.last_updated??o.last_changed??"")||Date.now(),r=this.data.get(t)??[],i=r[r.length-1];i&&a<=i.t||(r.push({t:a,v:oo(n.attribute?o.attributes?.[n.attribute]:o.state)}),this.data.set(t,r))}}}function _o(e,t){let n=0;for(;ne.t<=t.end),a=e[n-1];return a?[{...a,t:t.start,carried:!0},...o]:o}const bo=6e4,xo=new WeakMap;function $o(e,t){let n=xo.get(e);return n||(n=new vo(t),xo.set(e,n)),n}const wo=["smooth","straight","step"],ko=e=>(Math.round(100*e)/100).toString();function Mo(e){return e.map(([e,t],n)=>`${n?"L":"M"}${ko(e)},${ko(t)}`).join("")}function Ao(e,t){return 0===e.length?"":1===e.length?`M${ko(e[0][0])},${ko(e[0][1])}h0`:"smooth"===t?function(e){const t=e.length;if(t<3)return Mo(e);const n=[],o=[];for(let i=0;i9){const r=3/Math.sqrt(n);a[i]=r*e*o[i],a[i+1]=r*t*o[i]}}let r=`M${ko(e[0][0])},${ko(e[0][1])}`;for(let i=0;i"object"==typeof e?e.curve:void 0)];for(const n of t)if(void 0!==n&&!wo.includes(n))throw new Error(`"curve" must be one of: ${wo.join(", ")}`)}function jo(e,t=Date.now(),n={}){const o=Jn(e.raw.period),a=e.raw.source??"history",r=Rn(e.hass),i=Qn(o,t,r,o.navigate?to(e.host,o):0),s=mo(e.raw),l={start:s?Dn(s.unit,i.start,r):i.start,end:i.end},c=e.items.map(e=>({entity:e.entityId,attribute:e.config.attribute})),d=$o(e.host,e.requestUpdate),h=s&&"statistics"===a?function(e,t,n,o=!1){const a=t.timeZone===n&&("week"!==e.unit||1===t.firstDay),r="hour"!==e.unit&&a?e.unit:"hour",{types:i,value:s}=go[e.aggregate];return{period:r,types:o?[...new Set([...i,"min","max"])]:i,value:s}}(s,r,e.hass.config?.time_zone,!0===n.range):void 0,u=h?{start:l.start-fo[h.period],end:l.end}:l,m=d.get(e.hass,c,u,a,{live:i.live,statistics:h}),p=s?Bn(s.unit,l.start,l.end,r):[],f=e.raw.curve??"smooth",g=Math.min(l.end,t),y=e.items.map((e,t)=>{const o=e.config;let r=m.data.get(co(c[t]))??[];const i=r[r.length-1];return s?r="statistics"===a?function(e,t,n,o=1/0,a=!1){const r=[];let i=0;for(let s=0;s+1e+t,0)/h.length;else if("sum"===n||"change"===n)p=h.reduce((e,t)=>e+t,0);else if("min"===n)p=Math.min(...h);else if("max"===n)p=Math.max(...h);else if("last"===n)p=h[h.length-1];else{const t=e.slice(0,i).reverse().find(e=>null!==e.v),n=e.slice(i,d).find(e=>e.carried&&null!==e.v);p=(t??n)?.v??h[0]}const f={t:l,end:c,v:p};a&&null!==p&&(f.min=Math.min(...u),f.max=Math.max(...m)),r.push(f)}return r}(r,p,s.aggregate,g,!0===n.range):po(r,p,n.aggregate?.(e)??s.aggregate,g,!0===n.range):"history"===a&&i&&null!==i.v&&i.t1===e?t:n;function No(e,t,n,o,a){const r=(e,t)=>at(a,e,t);if(e.calendar&&n<2)return r({day:["period_today","period_yesterday"],week:["period_this_week","period_last_week"],month:["period_this_month","period_last_month"],year:["period_this_year","period_last_year"]}[e.calendar][n]);if(!e.calendar&&0===n){const t=e.last;return r({hour:Oo(t,"period_last_hour","period_last_hours"),day:Oo(t,"period_last_day","period_last_days"),week:Oo(t,"period_last_week_rolling","period_last_weeks"),month:Oo(t,"period_last_month_rolling","period_last_months")}[e.unit],{n:t})}const i={timeZone:o.timeZone},s=Nn(t.start,o.timeZone).year===Nn(Date.now(),o.timeZone).year;let l;"year"===e.calendar?i.year="numeric":"month"===e.calendar?Object.assign(i,{month:"long",year:"numeric"}):(Object.assign(i,{day:"numeric",month:"short"}),s||(i.year="numeric"),"day"===e.calendar&&(i.weekday="short"),e.calendar||"hour"!==e.unit&&"day"!==e.unit||Object.assign(i,{hour:"numeric",minute:"2-digit"}));try{l=new Intl.DateTimeFormat(a?.locale?.language??a?.language??"en",i)}catch{l=new Intl.DateTimeFormat("en",i)}const c=e.calendar?t.end-1:t.end;return"day"===e.calendar||"month"===e.calendar||"year"===e.calendar?l.format(t.start):l.formatRange(t.start,c)}function Uo(e,t,n=Date.now()){const{period:o}=e;if(!o.navigate)return W;const a=to(t.host,o),r=e=>n=>{n.stopPropagation(),no(t.host,o,e),t.requestUpdate()},i=No(o,e.range,e.back,e.zone,t.hass);let s="";if(0!==a){const a=Qn(o,n,e.zone);s=No(o,a,a.back,e.zone,t.hass)}const l=e=>R``;return R`
- ${i} - ${0!==a?R``:W} -
`}const Fo=r` + `}const yn=r` .period-nav { display: flex; align-items: center; @@ -142,16 +142,31 @@ const et=pe(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 Po(e){!function(e){xo.get(e)?.pause()}(e)}function Do(e,t){const{show:n}=t.config;return n.name||n.value?R`
+`;function bn(e){!function(e){tn.get(e)?.pause()}(e)}function _n(e,t){const{show:o}=t.config;return o.name||o.value?R`
${e.series.map(e=>R`
- ${n.name?R`${e.entity.name}`:W} - ${n.value?R`${e.entity.display}`:W} + ${o.name?R`${e.entity.name}`:W} + ${o.value?R`${e.entity.display}`:W}
`)} -
`: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} +
`:W}function xn(e,t){return"loading"===e.status?R`
${_t(t.hass,"chart_loading")}
`:"error"===e.status?R`
+ ${_t(t.hass,"chart_error",{error:e.error??""})} +
`:e.series.every(e=>e.points.every(e=>null===e.v))?R`
${_t(t.hass,"chart_no_data")}
`:void 0}function wn(e,t,o=W,a=W){return R` + + + + + ${o} + + ${a} + ${e} + `}const $n=r` + ${yn} .series-header { display: flex; flex-wrap: wrap; @@ -239,50 +254,37 @@ const et=pe(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 Io(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),r=new Intl.NumberFormat(o,{minimumFractionDigits:t,maximumFractionDigits:t,useGrouping:a});let i=r.format(0===e?0:e);return 0===Number(e.toFixed(t))&&(i=r.format(0)),i}function Ro(e,t){return t?`${e} ${t}`:e}const Vo=new WeakMap;function qo(e){let t=Vo.get(e.host);if(!t){const n={width:e.host.clientWidth||400};t=n,Vo.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}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`
- ${h&&u?R`
${y}${p}
`:W} +`;function kn(e,t,o){const{locale:a,grouping:n}=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}}}(o),r=new Intl.NumberFormat(a,{minimumFractionDigits:t,maximumFractionDigits:t,useGrouping:n});let i=r.format(0===e?0:e);return 0===Number(e.toFixed(t))&&(i=r.format(0)),i}function Mn(e,t){return t?`${e} ${t}`:e}const zn=[[1,2,3,4,5,6,7,8,9],[1,2,5],[1]],An=[2,3,5,10,20,50,100],Sn=e=>Number(e.toPrecision(12)),En=e=>Sn(10**e),Cn=e=>Math.floor(Math.log10(e)+1e-9);function jn(e){return e.map(e=>{const t=e=>"number"==typeof e&&e<=0;return t(e.v)||t(e.min)||t(e.max)?{...e,v:t(e.v)?null:e.v,...void 0!==e.min?{min:t(e.min)?null:e.min}:{},...void 0!==e.max?{max:t(e.max)?null:e.max}:{}}:e})}function Nn(e,t,o,a=1){const n=[];for(let r=Cn(e);r<=Cn(t)+a;r+=a)for(const a of o){const o=Sn(a*10**r);o>=e*(1-1e-9)&&o<=t*(1+1e-9)&&n.push(o)}return n}function Ln(e,t,o){const a=Cn(e),n=Nn(En(a-2),En(a+2),t),r=o?n.filter(t=>t<=e*(1+1e-9)):n.filter(t=>t>=e*(1-1e-9));return o?r[r.length-1]:r[0]}function Tn(e,t){const o=Math.log10(e[0]),a=Math.log10(e[1]),[n,r]=t,i=a===o?0:(r-n)/(a-o);return e=>e>0?n+(Math.log10(e)-o)*i:NaN}const On=new WeakMap;function Fn(e){let t=On.get(e.host);if(!t){const o={width:e.host.clientWidth||400};t=o,On.set(e.host,o),"undefined"!=typeof ResizeObserver&&(o.observer=new ResizeObserver(t=>{const a=Math.round(t[t.length-1].contentRect.width);Math.round(a/70)!==Math.round(o.width/70)&&(o.width=a,e.requestUpdate())}),o.observer.observe(e.host))}return t.width}function Pn(e){On.get(e)?.observer?.disconnect(),On.delete(e)}function Dn(e){const t=To(e.raw);return Math.max(0,Fn(e)-32-(t.y&&"outside"===t.labels?40:0))}function Un(e,t,o,a,n=4){const r=To(e.raw),i="log"===r.scale&&"line"===e.raw.style,s=i?function(e,t,o){let[a,n]=e??[1,100];void 0!==t.min&&t.min>0&&(a=t.min),void 0!==t.max&&t.max>0&&(n=t.max),n>0||(n=a>0?10*a:100),a>0||(a=n/1e3);const r=void 0!==t.min&&t.min===a,i=void 0!==t.max&&t.max===n;n<=a&&(r?n=10*a:i?a=n/10:(a/=Math.sqrt(10),n*=Math.sqrt(10)));const s=Math.max(2,o+1),l=[...zn.map(e=>({mantissas:e,every:1})),...An.map(e=>({mantissas:[1],every:e}))];let c=l[l.length-1],d=[a,n],u=[];for(const f of l){const e=r?a:Ln(a,f.mantissas,!0),t=i?n:Ln(n,f.mantissas,!1),o=Nn(e,t,f.mantissas,f.every);if(c=f,d=[e,t],u=o,o.length<=s)break}const h=c.every>1?[1]:zn[0],p=9===c.mantissas.length?[]:Nn(d[0],d[1],h).filter(e=>!u.includes(e)).slice(0,200),m=u.map(e=>Math.max(0,-Cn(e)));return{domain:d,ticks:u,decimals:Math.max(0,...m),minor:p,tickDecimals:m}}(o,r,n):Do(o,r,n),l=Math.max(2,Math.min(8,Math.floor((a??Dn(e))/70))),{unit:c,ticks:d}=Vo(t,l,Zo(Yo(e.hass)));return{options:r,value:s,times:d,unit:c,range:t,scales:{x:$o([t.start,t.end],[0,cn]),y:(i?Tn:$o)(s.domain,[dn,0]),domain:s.domain}}}const Hn=e=>Math.round(1e4*e)/100+"%";function In(e,t,o){const a=e.ticks[t];return e.tickDecimals?function(e,t,o){if(Math.abs(e)<1e4)return kn(e,t,o);const a=o?.locale?.language??o?.language??"en";try{return new Intl.NumberFormat(a,{notation:"compact",maximumFractionDigits:1}).format(e)}catch{return new Intl.NumberFormat("en",{notation:"compact",maximumFractionDigits:1}).format(e)}}(a,e.tickDecimals[t],o.hass):kn(a,e.decimals,o.hass)}function Bn({axes:e,ctx:t,content:o,label:a,defs:n=W,overlay:r=W,hover:i,right:s,xLabel:l,yLabel:c,gestures:d,classes:u="",below:h=W}){const{options:p}=e,m="outside"===p.labels,f=p.y&&e.value.ticks.length>0,g=p.x&&e.times.length>0,v=f?function(e,t,o){const{scales:a,value:n}=e;return n.ticks.map((e,r)=>{const i=a.y(e)/dn;return R`.92?"bottom":""}" style="top:${Hn(i)}" + >${o?o(e):In(n,r,t)}`})}(e,t,c):[],y=f?R`
${v}
`:W,b=g?R`
${function(e,t,o){const{scales:a,range:n}=e,r=Zo(Yo(t.hass));return e.times.map(i=>{const s=a.x(i)/cn;return R`.95?"end":""}" style="left:${Hn(s)}" + >${o?o(i):e.label?e.label(i):ea(i,e.unit,n.end-n.start,t.hass,r)}`})}(e,t,l)}
`:W,_=f?R``:W,x=s&&p.y&&s.value.ticks.length>0?s:void 0,w=x?R``:W,$=x?R`
${m?w:W}${function(e,t){return e.value.ticks.map(o=>{const a=e.y(o)/dn;return R`.92?"bottom":""}" style="top:${Hn(a)}" + >${kn(o,e.value.decimals,t.hass)}`})}(x,t)}
`:W,k=`plot-wrap axes-${p.labels} ${f?"has-y":""} ${g?"has-x":""} ${x?"has-right":""} ${h===W?"":"has-overview"}`;return R`
+ ${m&&f?R`
${_}${v}
`:W}
- ${function(e,t,n=W,o=W){return R` - - - - - ${n} - - ${o} - ${e} - `}(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}`} + ${wn(o,a,n,function(e){const{options:t,scales:o}=e;return Z` + ${t.horizontal?(e.value.minor??[]).map(e=>Z``):W} + ${t.horizontal?e.value.ticks.map(e=>Z``):W} + ${t.vertical?e.times.map(e=>Z``):W} + `}(e))} ${m?W:R`${y}${$}${b}`} ${r}
- ${h?R`${b}${g}`:W} -
`}const Jo=r` + ${m?R`${$}${b}`:W} ${h} +
`}const Rn=r` .plot-wrap { display: grid; grid-template-columns: auto minmax(0, 1fr); @@ -298,6 +300,9 @@ const et=pe(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de stroke: var(--divider-color, rgba(127, 127, 127, 0.25)); stroke-width: 1; } + .plot .grid line.minor { + opacity: 0.45; + } .axis-label { position: absolute; font-size: 11px; @@ -392,9 +397,9 @@ const et=pe(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); } -`,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` +`,Zn=["above","over","hidden"];function qn(e){if(void 0!==e.compact&&"boolean"!=typeof e.compact)throw new Error('"compact" must be true or false');if(void 0!==e.header&&!Zn.includes(e.header))throw new Error(`"header" must be one of: ${Zn.join(", ")}`)}function Wn(e){return{compact:!0===e.compact,header:e.header??"above"}}function Vn(e,t,o){return R`
+ ${"above"===e.header?t:W}${o} +
`}function Yn(e){return e===W?W:R`
e.stopPropagation()}>${e}
`}const Gn=r` .header-over { position: absolute; top: 2px; @@ -447,26 +452,26 @@ const et=pe(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de .frame.compact .axes-outside .x-axis .axis-label { top: 3px; } -`,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`
+`,Xn=["none","normal","percent"];function Jn(e){const t=e.stack;if(void 0!==t&&!Xn.includes(t))throw new Error(`"stack" must be one of: ${Xn.join(", ")}`)}function Kn(e,t){let o=0,a=e.length;for(;o>1;e[n].t<=t?o=n+1:a=n}return 0===o?null:e[o-1].v}function Qn(e,t){const o=e[0]?.length??0,a=e.map(()=>[]);for(let n=0;ne+Math.abs(t[n]??0),0);o=t>0?100/t:0}let r=0,i=0;e.forEach((e,t)=>{const s=e[n];if(null==s)return void a[t].push(null);const l=s*o;l>=0?(a[t].push([r,r+l]),r+=l):(a[t].push([i,i+l]),i+=l)})}return a}function er(e){const t=e[0]?.length??0,o=[];for(let a=0;a=190?"left":e>.5?"pin-left":"pin-right"}const ar=3e3;function nr(e,t){if(0===e.length)return-1;let o=0,a=e.length-1;for(;o>1;e[n].t0&&t-e[o-1].t<=e[o].t-t?o-1:o}const rr=new WeakMap;function ir(e){return rr.get(e)?.t}function sr(e){const t=rr.get(e);void 0!==t?.timer&&clearTimeout(t.timer),rr.delete(e)}function lr(e){return!1!==e.tooltip}function cr(e){const t=e.tooltip;if(void 0!==t&&"boolean"!=typeof t)throw new Error('"tooltip" must be true or false')}function dr(e,t,o){const a=function(e){let t=rr.get(e);return t||(t={},rr.set(e,t)),t}(e.host),n=t=>{t!==a.t&&(a.t=t,e.requestUpdate())},r=()=>{void 0!==a.timer&&clearTimeout(a.timer),a.timer=void 0};return{move(e){r();const a=e.currentTarget.getBoundingClientRect(),i=a.width>0?Math.min(1,Math.max(0,(e.clientX-a.left)/a.width)):0;n(function(e,t){let o;for(const a of e){const e=nr(a,t);if(e<0)continue;const n=a[e].t;(void 0===o||Math.abs(n-t){a.timer=void 0,n(void 0)},ar):n(void 0)}}}const ur=e=>Math.round(1e4*e)/100+"%";function hr(e){const t=new Set(e.map(e=>e.entity.unit));return 1===t.size?[...t][0]:""}function pr(e,t,o,a){return R`
- ${at(e.hass,"chart_total")} - ${null===t?"—":Ro(Io(t,o,e.hass),n)} -
`}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``}function mr(e,t,o,a,n,r={}){if(void 0===a)return W;const i=o.x(a)/cn;if(!(i>=0&&i<=1))return W;const s=or(i,n),l=t.map(e=>{const t=nr(e.points,a);return{s:e,p:t<0?void 0:e.points[t]}});return R``}(i.series[2],A,a,e.hass):W}`)}function Xs(e,t,o,a,n,r){const i="time"===a?[e.series]:"bubble"===o?t.series.slice(0,3):[t.series[0],e.series];return ws({x:e.x/cn,y:e.y/dn,plotWidth:r,title:Qo(e.t,n.hass),dot:e.color,rows:i.map((t,o)=>({color:t===e.series?e.color:void 0,name:t.entity.name,value:Ys(e.v[o],t,n.hass)}))})}const Js=r` + ${$n} + ${Rn} + ${fr} + ${Gn} + ${$s} .plot .dots, .plot .bubble { fill: none; @@ -847,22 +1224,22 @@ const et=pe(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de .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` +`;function Ks(e){bn(e),Pn(e),sr(e),bs(e)}const Qs=Ei.map(e=>"x_axis"===e.name?{...e,name:"xy_x_axis"}:e);const el=(e,t,o=1)=>({number:{min:e,max:t,step:o,mode:"box",unit_of_measurement:"px"}}),tl={scatter:[...wi,{name:"against",path:["against"],default:"entity"},{name:"dot_size",path:["dot_size"],default:6},{name:"jitter",path:["jitter"],default:0},...hs,...Qs,...Ai],bubble:[...wi,{name:"min_size",path:["min_size"],default:6},{name:"max_size",path:["max_size"],default:32},{name:"jitter",path:["jitter"],default:0},...hs,...Qs,...Ai]};function ol(e){return t=>{const o="scatter"===e&&"time"===t.config.against,a=Pi(`${e}|${$i(t.config)}|${o}`,t.hass,()=>{return[...ki(t.hass,t.config),..."scatter"===e?[{name:"against",selector:xi(t.hass,"against",Os)},{type:"grid",name:"",schema:[{name:"dot_size",selector:el(Ps[0],Ps[1])},{name:"jitter",selector:el(Ds[0],Ds[1])}]}]:[{type:"grid",name:"",schema:[{name:"min_size",selector:el(Fs[0],Fs[1])},{name:"max_size",selector:el(Fs[0],Fs[1])}]},{name:"jitter",selector:el(Ds[0],Ds[1])}],...o?[]:ps,...(a=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:xi(a,"axis_labels",Mo)},{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:{}}}]),...Si(t.hass)];var a});return Di(t,"chart",a,tl[e])}}const al=[{name:"dot_color",path:["color"]}],nl=[{name:"dot_color",selector:{text:{}}}];function rl(){return{schema:nl,fields:al}}const il={layout:"chart",group:"distribution",height:270,styles:Js,thumbnail:()=>Z` - `,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`
`,validate:e=>Ws(e,"bubble"),editor:ol("bubble"),entityFields:()=>rl(),disconnect:Ks,render:e=>Gs(e,"bubble")},sl=["auto","none","hour","day","week","month"],ll=["auto","mean","sum","min","max","first","last","change"],cl={last:7,unit:"day"},dl={hour:1/24,day:1,week:7,month:30};function ul(e){if(e.calendar)return{day:"hour",week:"day",month:"day",year:"month"}[e.calendar];const t=e.last*dl[e.unit];return t<=2?"hour":t<=62?"day":t<=190?"week":"month"}function hl(e){if(e.config.attribute)return!1;const t=e.stateObj?.attributes?.state_class;return"total_increasing"===t||"total"===t}function pl(e,t){const o={...e,period:e.period??{...cl}};return"none"===e.group_by?delete o.group_by:void 0!==e.group_by&&"auto"!==e.group_by||(o.group_by=ul($a(o.period))),void 0!==e.aggregate&&"auto"!==e.aggregate||(o.aggregate=!0!==e.range&&t.length>0&&t.every(hl)?"change":"mean"),o}function ml(e,t){const o=[...new Set(e.flatMap(e=>e.map(e=>e.t)))].sort((e,t)=>e-t),a=e.map(e=>new Map(e.map(e=>[e.t,e]))),n=new Map;for(const i of e)for(const e of i)void 0!==e.end&&n.set(e.t,e.end);const r={times:[],ends:[],values:e.map(()=>[]),lows:e.map(()=>[]),highs:e.map(()=>[])};return o.forEach((i,s)=>{const l=Math.min(n.get(i)??o[s+1]??t,t);l>i&&(r.times.push(i),r.ends.push(l),e.forEach((e,t)=>{const o=a[t].get(i);r.values[t].push(o?o.v:Kn(e,i)),r.lows[t].push(o?.min??null),r.highs[t].push(o?.max??null)}))}),r}function fl(e,t,o,a){const n=t-e,r=.8*n/a,i=a>1?.12*r:0;return[e+.2*n/2+o*r+i/2,r-i]}function gl(e,t=!0){let o=t?0:1/0,a=t?0:-1/0;for(const n of e)null!==n&&Number.isFinite(n)&&(o=Math.min(o,n),a=Math.max(a,n));return o===1/0?void 0:[o,a]}const vl={hour:66,day:52,week:52,month:60};yt("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 yl={hour:"hour",day:"day",week:"day",month:"month"};function bl(e,t,o,a){if("hour"===o)return`${Qo(e,a)} – ${Ko(t,a)}`;const n="day"===o?{weekday:"short",day:"numeric",month:"short"}:"week"===o?{day:"numeric",month:"short"}:{month:"long",year:"numeric"};let r;try{r=new Intl.DateTimeFormat(a?.locale?.language??"en",{...n,timeZone:Yo(a)})}catch{r=new Intl.DateTimeFormat("en",n)}return"week"===o?r.formatRange(e,t-1):r.format(e)}const _l=(e,t)=>Math.round(e/t*1e4)/100+"%",xl=e=>Math.min(dn,Math.max(0,e));function wl(e,t,o,a,n,r,i,s){const l=xl(e.y(n)),c=xl(e.y(a));return R`
`}function vi(e,t,n,o,a,r){return R`
`}function $l(e,t,o,a,n,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`
${kn(a,n,e.hass)}`}function kl(e,t,o,a){const n=e.y(a);return n<0||n>dn?R``:R`
`}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` + style="left:${_l(t-.1*o,cn)};width:${_l(1.2*o,cn)};top:${_l(n,dn)}" + >
`}const Ml=wi.map(e=>"period_last"===e.name?{...e,default:cl.last}:"period_unit"===e.name?{...e,default:cl.unit}:"group_by"===e.name||"aggregate"===e.name?{...e,default:"auto"}:e),zl=[...Ml,{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},...Ei,...Ai];const Al=[{name:"column_color",path:["color"]}],Sl=r` .columns { position: absolute; inset: 0; @@ -922,53 +1299,229 @@ const et=pe(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de .axes-inside .x-axis { z-index: 1; } -`,ki={layout:"chart",group:"history",height:240,styles:r` - ${Bo} - ${Jo} - ${$a} - ${oa} - ${wi} - `,thumbnail:()=>V` +`,El={layout:"chart",group:"history",height:240,styles:r` + ${$n} + ${Rn} + ${fr} + ${Gn} + ${Sl} + `,thumbnail:()=>Z` - `,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`
`,validate:function(e){if(void 0!==e.group_by&&!(e=>sl.includes(e))(e.group_by))throw new Error(`"group_by" must be one of: ${sl.join(", ")}`);if(void 0!==e.aggregate&&!ll.includes(e.aggregate))throw new Error(`"aggregate" must be one of: ${ll.join(", ")}`);const t={...e};"auto"!==e.group_by&&"none"!==e.group_by||delete t.group_by,"auto"===e.aggregate&&delete t.aggregate,pn(t),Lo(e),cr(e),qn(e),Jn(e);for(const o of["range","rounded","labels"])if(void 0!==e[o]&&"boolean"!=typeof e[o])throw new Error(`"${o}" 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,o={...e.config,group_by:void 0},a=Pi(`column|${$i(o)}|${t}`,e.hass,()=>[...ki(e.hass,o).slice(0,-1),{type:"grid",name:"",schema:[{name:"group_by",selector:xi(e.hass,"group_by",sl)},...t?[]:[{name:"aggregate",selector:xi(e.hass,"aggregate",ll)}]]},{type:"grid",name:"",schema:[{name:"stack",selector:xi(e.hass,"stack",Xn)},{name:"color_by",selector:xi(e.hass,"color_by",["series","bands"])}]},{type:"grid",name:"",schema:[{name:"range",selector:{boolean:{}}},{name:"column_labels",selector:{boolean:{}}},{name:"rounded",selector:{boolean:{}}}]},...Ci(e.hass),...Si(e.hass)]);return Di({...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:cl.last,unit:cl.unit,...t}}:e}(t))},"chart",a,zl)},entityFields:e=>({schema:Pi("column-entity",e,()=>[{name:"column_color",selector:{text:{}}}]),fields:Al}),disconnect(e){bn(e),Pn(e),sr(e)},render:function(e){const t={...e,raw:pl(e.raw,e.items)},o=t.raw,a=!0===o.range,n=mn(t,Date.now(),{range:a}),r=vn(n,t),i=_n(n,t),s=r===W&&i===W?W:R`${r}${i}`,l=xn(n,t),c=Wn(o);if(l)return Vn("over"===c.header?{...c,header:"above"}:c,s,l);const d=a?"none":o.stack??"none",u=n.group,h=ml(n.series.map(e=>e.points),u?1/0:n.range.end),p=u&&h.ends.length?{...n.range,end:Math.max(n.range.end,...h.ends)}:n.range,m="none"===d?void 0:Qn(h.values,d),f=n.series.map(e=>e.entity.target).filter(e=>null!==e);let g;g=m?tr(m):a?gl([...h.lows.flat(),...h.highs.flat(),...h.values.flat(),...f],!1):gl([...h.values.flat(),...t.config.show.target?f:[]]);let v=o;"percent"===d?v={y_min:g[0]<0?-100:0,y_max:g[1]>0?100:0,...o}:!a&&g&&(v=function(e,t){const o={...t};return void 0===t.y_min&&e[0]>=0&&(o.y_min=0),void 0===t.y_max&&e[1]<=0&&(o.y_max=0),o}(g,o));let y=Un({...t,raw:v},p,g);const{scales:b}=y,_=h.times.map((e,t)=>(e+h.ends[t])/2),x=new Map(_.map((e,t)=>[e,t]));if(u&&_.length){const e=(w=_.length,$=Dn(t)/vl[u.unit],Math.max(1,Math.ceil(w/Math.max(1,$)))),o=yl[u.unit],a="day"===u.unit&&_.length<=8?6048e5:n.range.end-n.range.start;y={...y,unit:o,times:_.filter((t,o)=>o%e===0),label:e=>ea(h.times[x.get(e)],o,a,t.hass)}}var w,$;const k=function(e,t,o,a,n,r){const i=e.raw,s=t.series,l=s.length,c=!0===i.range,d="bands"===i.color_by,u=Dn(e)/cn,h=!0===i.labels&&"percent"!==n&&o.times.length*(r?1:l)<=60,p=e.config.show.target&&"percent"!==n,m=Math.min(Math.max(0,a.domain[0]),a.domain[1]),f=(e,t)=>d?Ce(Te(t,e.entity.bands).color):e.color,g={columns:[],labels:[],targets:[]},v=r?er(o.values):[];return o.times.forEach((t,n)=>{const i=a.x(t),d=a.x(o.ends[n]);if(r){const[t,l]=fl(i,d,0,1);let c=-1,m=-1;r.forEach((e,t)=>{const o=e[n];o&&o[1]>o[0]&&(c=t),o&&o[1]{const i=e[n];if(!i||i[0]===i[1])return;const d=o.values[r][n],u=r===c?"top":r===m?"bottom":"";g.columns.push(wl(a,t,l,Math.min(...i),Math.max(...i),f(s[r],d),u,r))});const y=v[n];if(h&&null!==y&&l*u>=24){const o=y>=0?Math.max(0,...r.map(e=>e[n]?.[1]??0)):Math.min(0,...r.map(e=>e[n]?.[1]??0)),i=Math.max(...s.map(e=>e.entity.decimals));g.labels.push($l(e,t+l/2,a.y(o),y,i,y<0))}const b=s[0].entity.target;return void(p&&null!==b&&g.targets.push(kl(a,t,l,b)))}s.forEach((t,r)=>{const s=o.values[r][n];if(null===s)return;const[v,y]=fl(i,d,r,l);if(c){const e=o.lows[r][n]??s,i=o.highs[r][n]??s;g.columns.push(wl(a,v,y,e,i,f(t,s),"both",r)),g.columns.push(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`
`)}else g.columns.push(wl(a,v,y,Math.min(m,s),Math.max(m,s),f(t,s),s>=m?"top":"bottom",r)),h&&y*u>=24&&g.labels.push($l(e,v+y/2,a.y(s),s,t.entity.decimals,s({...e,points:_.map((e,o)=>({t:e,v:h.values[t][o],min:h.lows[t][o],max:h.highs[t][o]}))})),z={dotY:()=>null,heading:e=>{const o=x.get(e);return void 0===o?Qo(e,t.hass):u?bl(h.times[o],h.ends[o],u.unit,t.hass):Qo(h.times[o],t.hass)}},A=(e,o)=>kn(e,o.entity.decimals,t.hass);if(a&&(z.text=(e,t)=>{if(!t||null===t.v)return"—";const o=t.min??t.v,a=t.max??t.v;return Mn(`${A(o,e)} – ${A(a,e)}`,e.entity.unit)}),m){const e=er(h.values),o=hr(M),a=Math.max(...M.map(e=>e.entity.decimals));z.footer=n=>pr(t,e[x.get(n[0]?.p?.t??NaN)??-1]??null,o,a),"percent"===d&&(z.text=(e,t)=>{const o=t?m[e.index][x.get(t.t)??-1]:null;return t&&null!==t.v&&o?`${Mn(A(t.v,e),e.entity.unit)} · ${Math.round(Math.abs(o[1]-o[0]))} %`:"—"})}const S=lr(o),E=S?ir(t.host):void 0,C=void 0===E?void 0:x.get(E),j=void 0===C?W:R`
`,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}
+ style="left:${_l(b.x(h.times[C]),cn)};width:${_l(b.x(h.ends[C])-b.x(h.times[C]),cn)}" + >
`,N=_t(t.hass,"chart_aria",{style:_t(t.hass,"editor_style_column"),names:n.series.map(e=>e.entity.name).join(", ")}),L=!1===o.rounded?0:4;return Vn(c,s,Bn({axes:y,ctx:t,content:Z``,label:N,overlay:R`
${j}${k.columns}${k.targets}
${k.labels}
- ${"over"===c.header?na(s):W} - ${E?xa(t,M,_,C,Zo(t),A):W}`,hover:E?ya(t,m,M.map(e=>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`e.points)):void 0}))}};yt("en",{zoom_reset:"Reset zoom",zoom_window:"Shown: {from} to {to}",zoom_window_label:"Part of the chart shown",editor_zoom:"Zoom and pan (pinch, Ctrl + scroll or drag)",editor_overview:"Overview strip below the chart"});const Cl={from:0,to:1};const jl=(e,t,o)=>Math.min(o,Math.max(t,e));function Nl(e,t){const o=jl(t,0,1);let a=jl(e.to-e.from,o,1);Number.isFinite(a)||(a=1);const n=jl(Number.isFinite(e.from)?e.from:0,0,1-a);return{from:n,to:n+a}}function Ll(e){return e.from>1e-9||e.to<1-1e-9}function Tl(e,t,o,a){const n=e.to-e.from,r=e.from+jl(t,0,1)*n,i=jl(n/o,a,1),s=r-jl(t,0,1)*i;return Nl({from:s,to:s+i},a)}function Ol(e,t){const o=e.to-e.from,a=jl(e.from+t,0,1-o);return{from:a,to:a+o}}function Fl(e,t,o,a){const n=e.to-e.from,r=e.from+jl(Math.min(t,o),0,1)*n,i=e.from+jl(Math.max(t,o),0,1)*n;if(i-r>=a)return{from:r,to:i};const s=(r+i)/2;return Nl({from:s-a/2,to:s+a/2},a)}function Pl(e,t){const o=e.end-e.start;return{start:e.start+t.from*o,end:e.start+t.to*o}}function Dl(e,t){if(0===e.length)return e;let o=0,a=e.length;for(;o>1;(e[n].end??e[n].t)0?Math.min(1,function(e){return e.group?2*{hour:36e5,day:864e5,week:6048e5,month:2592e6}[e.group.unit]:3e5}(t)/n):1,i=`${JSON.stringify(t.period)}|${t.back}|${t.group?.unit??""}|${t.source}`,s=function(e,t){let o=Ul.get(e);return o&&o.key===t||(o={key:t,window:Cl,pointers:new Map},Ul.set(e,o)),o}(e.host,i);o.zoom||o.overview||(s.window=Cl);const l=Nl(s.window,r),c=t=>{const o=Nl(t,r);o.from===s.window.from&&o.to===s.window.to||(s.window=o,e.requestUpdate())};return{options:o,window:l,zoomed:Ll(l),range:Pl(t.range,l),minSpan:r,current:()=>Nl(s.window,r),set:c,reset:()=>c(Cl)}}const Il=e=>{const t=e.currentTarget.getBoundingClientRect();return{f:t.width>0?jl((e.clientX-t.left)/t.width,0,1):0,width:t.width}};function Bl(e){try{e.currentTarget.setPointerCapture?.(e.pointerId)}catch{}}const Rl=e=>e?.leave({pointerType:"mouse"});function Zl(e,t,o){const a=Ul.get(e.host),n=()=>{a.gesture=void 0,a.select&&(a.select=void 0,e.requestUpdate())};return{down(e){const{f:n,width:r}=Il(e);if(a.pointers.set(e.pointerId,n),2===a.pointers.size){const[e,n]=[...a.pointers.values()];return a.gesture={kind:"pinch",w0:t.current(),start:e<=n?[e,n]:[n,e]},a.select=void 0,void Rl(o)}if(!(a.pointers.size>2)){if("mouse"===e.pointerType&&0===e.button){const o=t.current();a.gesture={kind:Ll(o)?"pan":"select",x0:n,w0:o,moved:!1,px:r},Bl(e)}o?.move(e)}},move(n){if(!a.pointers.has(n.pointerId))return void o?.move(n);const{f:r}=Il(n);a.pointers.set(n.pointerId,r);const i=a.gesture;if("pinch"===i?.kind){if(a.pointers.size<2)return;const[e,o]=[...a.pointers.values()];return void t.set(function(e,t,o,a){const n=e.to-e.from,r=e.from+t[0]*n,i=e.from+t[1]*n,s=o[1]-o[0];if(Math.abs(s)<.02||Math.abs(t[1]-t[0])<.02)return Ol(e,-((o[0]+o[1])/2-(t[0]+t[1])/2)*n);const l=jl((i-r)/s,a,1),c=r-o[0]*l;return Nl({from:c,to:c+l},a)}(i.w0,i.start,e<=o?[e,o]:[o,e],t.minSpan))}if(i&&("pan"===i.kind||"select"===i.kind))return!i.moved&&Math.abs(r-i.x0)*i.px<4?void o?.move(n):(i.moved||Rl(o),i.moved=!0,void("pan"===i.kind?t.set(Ol(i.w0,-(r-i.x0)*(i.w0.to-i.w0.from))):(a.select=[i.x0,r],e.requestUpdate())));o?.move(n)},up(e){const{f:o,width:r}=Il(e),i=a.gesture;if(a.pointers.delete(e.pointerId),"pinch"===i?.kind)return void(0===a.pointers.size&&(a.gesture=void 0));"select"===i?.kind&&i.moved&&t.set(Fl(i.w0,i.x0,o,t.minSpan));const s=!i||!("moved"in i)||!i.moved;if(n(),"mouse"!==e.pointerType&&s){const n=e.timeStamp||Date.now(),i=a.lastTap;i&&n-i.at<300&&Math.abs(o-i.x)*r<30?(a.lastTap=void 0,t.reset()):a.lastTap={at:n,x:o}}},cancel(e){a.pointers.delete(e.pointerId),"pinch"===a.gesture?.kind&&a.pointers.size>=2||(n(),o?.leave(e))},leave(e){a.gesture&&"mouse"===e.pointerType||(a.pointers.delete(e.pointerId),o?.leave(e))},wheel(e){const{f:o}=Il(e);if(e.ctrlKey||e.metaKey){e.preventDefault();const a=1===e.deltaMode?16*e.deltaY:e.deltaY;return void t.set(Tl(t.current(),o,Math.exp(-jl(a,-100,100)/200),t.minSpan))}const a=e.shiftKey&&0===e.deltaX?e.deltaY:e.deltaX,n=t.current();if(Ll(n)&&Math.abs(a)>Math.abs(e.shiftKey?0:e.deltaY)){e.preventDefault();const{width:o}=Il(e);t.set(Ol(n,a/Math.max(o,1)*(n.to-n.from)))}},dblclick(){t.reset()}}}function ql(e,t){const o=Ul.get(e.host),a=o?.select,n=a?R`
`:W,r=t.zoomed?R``:W;return n===W&&r===W?W:R`${n}${r}`}const Wl="M15.5,14H14.71L14.43,13.73C15.41,12.59 16,11.11 16,9.5A6.5,6.5 0 0,0 9.5,3A6.5,6.5 0 0,0 3,9.5A6.5,6.5 0 0,0 9.5,16C11.11,16 12.59,15.41 13.73,14.43L14,14.71V15.5L19,20.5L20.5,19L15.5,14M9.5,14C7,14 5,12 5,9.5C5,7 7,5 9.5,5C12,5 14,7 14,9.5C14,12 12,14 9.5,14M7,9H12V10H7V9Z";function Vl(e,t){const o=xo(e.map(e=>e.points));if(!o)return Z``;const[a,n]=wo(o),r=$o([t.start,t.end],[0,cn]),i=$o([a,n],[38,2]);return Z`${e.map(e=>{const t=ln(ta(e.points,200)).map(e=>1===e.length?`M${r(e[0].t).toFixed(1)},${i(e[0].v).toFixed(1)}h0.5`:`M${e.map(e=>`${r(e.t).toFixed(1)},${i(e.v).toFixed(1)}`).join("L")}`).join("");return Z``})}`}function Yl(e,t,o,a){const n=Ul.get(e.host),r=t.window,i=e=>Il(e),s=Pl(o,r),l=_t(e.hass,"zoom_window",{from:Qo(s.start,e.hass),to:Qo(s.end,e.hass)}),c=e=>Math.round(1e4*e)/100+"%";return R`
{if(e.button>0)return;e.stopPropagation();const{f:o,width:a}=i(e),r=t.current(),s=10/Math.max(a,1);let l="new";Ll(r)&&(Math.abs(o-r.from)<=s?l="left":Math.abs(o-r.to)<=s?l="right":o>r.from&&o{const o=n.gesture;if(!o||!("f0"in o))return;const{f:a}=i(e);if(!o.moved&&Math.abs(a-o.f0)*o.px<4)return;o.moved=!0;const{w0:r}=o;"move"===o.kind?t.set(Ol(r,a-o.f0)):"left"===o.kind?t.set({from:Math.min(a,r.to-t.minSpan),to:r.to}):"right"===o.kind?t.set({from:r.from,to:Math.max(a,r.from+t.minSpan)}):t.set(Fl(Cl,o.f0,a,t.minSpan))}} + @pointerup=${()=>{const e=n.gesture;if(e&&"f0"in e&&(n.gesture=void 0,!e.moved&&"new"===e.kind&&Ll(e.w0))){const o=e.w0.to-e.w0.from;t.set({from:e.f0-o/2,to:e.f0+o/2})}}} + @pointercancel=${()=>n.gesture=void 0} + @dblclick=${e=>{e.stopPropagation(),t.reset()}} + > + +
+
+
{const o=t.current(),a=o.to-o.from,n=.5,r={ArrowLeft:()=>Ol(o,-a/4),ArrowRight:()=>Ol(o,a/4),ArrowUp:()=>Tl(o,n,1.5,t.minSpan),ArrowDown:()=>Tl(o,n,1/1.5,t.minSpan),"+":()=>Tl(o,n,1.5,t.minSpan),"=":()=>Tl(o,n,1.5,t.minSpan),"-":()=>Tl(o,n,1/1.5,t.minSpan),Home:()=>Ol(o,-1),End:()=>Ol(o,1),Escape:()=>Cl}[e.key];r&&(e.preventDefault(),e.stopPropagation(),t.set(r()))}} + > + +
+
`}const Gl=r` + .plot-area.zoomable { + touch-action: pan-y; + } + .plot-area.zoomable.zoomed { + cursor: grab; + } + .plot-area.zoomable.zoomed:active { + cursor: grabbing; + } + .zoom-select { + position: absolute; + top: 0; + bottom: 0; + background: color-mix(in srgb, var(--primary-color) 15%, transparent); + border-left: 1px solid var(--primary-color); + border-right: 1px solid var(--primary-color); + pointer-events: none; + z-index: 1; + } + .zoom-reset { + position: absolute; + top: 2px; + right: 2px; + /* Under the tooltip: the box only covers it while the pointer is away from the corner. */ + z-index: 1; + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px 2px 6px; + font: inherit; + font-size: 11px; + line-height: 18px; + color: var(--primary-color); + background: color-mix(in srgb, var(--card-background-color, #fff) 85%, transparent); + border: 1px solid color-mix(in srgb, var(--primary-color) 40%, transparent); + border-radius: 12px; + cursor: pointer; + } + .zoom-reset svg { + width: 14px; + height: 14px; + fill: currentColor; + } + .zoom-reset:hover { + background: color-mix(in srgb, var(--primary-color) 12%, var(--card-background-color, #fff)); + } + .zoom-reset:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: 1px; + } + .frame.compact .zoom-reset span { + display: none; + } + .frame.compact .zoom-reset { + padding: 2px; + } + .overview { + position: relative; + grid-column: 2; + grid-row: 3; + height: 32px; + margin-top: 6px; + border-radius: 4px; + background: color-mix(in srgb, var(--primary-text-color) 4%, transparent); + touch-action: none; + cursor: crosshair; + user-select: none; + -webkit-user-select: none; + } + .frame.compact .overview { + height: 22px; + margin-top: 4px; + } + .overview-lines { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + } + .overview-lines path { + stroke-width: 1; + vector-effect: non-scaling-stroke; + opacity: 0.8; + } + .overview-shade { + position: absolute; + top: 0; + bottom: 0; + background: color-mix(in srgb, var(--card-background-color, #fff) 60%, transparent); + pointer-events: none; + } + .overview-window { + position: absolute; + top: 0; + bottom: 0; + box-sizing: border-box; + border: 1px solid color-mix(in srgb, var(--primary-color) 70%, transparent); + border-radius: 4px; + background: color-mix(in srgb, var(--primary-color) 8%, transparent); + cursor: grab; + } + .overview:not(.zoomed) .overview-window { + border-color: transparent; + background: none; + cursor: crosshair; + } + .overview-window:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: 1px; + } + .overview-handle { + position: absolute; + top: 50%; + width: 4px; + height: 14px; + border-radius: 2px; + background: var(--primary-color); + transform: translate(-50%, -50%); + cursor: ew-resize; + } + .overview-handle.left { + left: 0; + } + .overview-handle.right { + left: 100%; + } + .overview:not(.zoomed) .overview-handle { + display: none; + } + .plot-wrap.has-overview { + grid-template-rows: minmax(0, 1fr) auto auto; + height: 220px; + } + .frame.compact .plot-wrap.has-overview { + height: 140px; + } +`,Xl="chartdeck-sync";const Jl=new WeakMap;function Kl(e,t){const o=Jl.get(e);o&&window.dispatchEvent(new CustomEvent(Xl,{detail:{group:o.group,source:e,message:t}}))}function Ql(e){const t=Jl.get(e);t&&window.removeEventListener(Xl,t.listener),Jl.delete(e)}function ec(e,t,o){const a="string"==typeof(n=e.raw).sync&&""!==n.sync.trim()?n.sync.trim():void 0;var n;let r=Jl.get(e.host);if(!a)return void(r&&Ql(e.host));if(!r||r.group!==a){r&&Ql(e.host);const t={group:a,handlers:new Map,ctx:e,listener:()=>{}};t.listener=e=>function(e,t){if(!t||t.group!==e.group||t.source===e.ctx.host)return;const o=t.message;if("hover"===o.type){if(o.t===e.remote)return;e.remote=o.t,e.ctx.requestUpdate()}else if("period"===o.type){const t={period:JSON.parse(o.period)};if(o.period!==e.period||!t.period.navigate||Aa(e.ctx.host,t.period)===o.steps)return;Sa(e.ctx.host,t.period,o.steps),e.steps=o.steps,e.ctx.requestUpdate()}e.handlers.get(o.type)?.(o)}(t,e.detail),window.addEventListener(Xl,t.listener),Jl.set(e.host,t),r=t}r.ctx=e,o!==r.sent&&(r.sent=o,Kl(e.host,{type:"hover",t:o}));const i=(e=>JSON.stringify(e.period))(t),s=t.period.navigate?Aa(e.host,t.period):0;return r.period===i&&r.steps!==s&&Kl(e.host,{type:"period",period:i,steps:s}),r.period=i,r.steps=s,void 0===o?r.remote:void 0}const tc=["series","bands"];function oc(e,t,o){const a=e=>{const t=(dn-o.y(e))/dn;return Number.isFinite(t)?Math.min(1,Math.max(0,t)):0},n=[];return t.forEach((e,o)=>{const r=Number.isFinite(e.from)?a(e.from):0,i=o+1`)}),Z` + ${n} + `}function ac(e,t,o,a,n){const r=Ct(e.entity.config,n),i=ta(e.points,500),s=ln(i).map(o=>sn(o.map(e=>[t.x(e.t),t.y(e.v)]),e.curve)).join(""),l=r.dots?i.filter(e=>null!==e.v&&e.t!==a.start&&e.t!==a.end):[],c=l.length>0&&l.length<=200?Z`[t.x(e.t),t.y(e.v)]))} + d=${Nt(l.map(e=>[t.x(e.t),t.y(e.v)]))} fill="none" - stroke=${n} - style="stroke-width:${ut(r.width)}px" - >`:W;return V` + stroke=${o} + style="stroke-width:${Lt(r.width)}px" + >
`:W;return Z` ${c} - `}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`${Io(e.v,o.entity.decimals,n.hass)}`)})} -
`}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} + `}function nc(e,t,o,a,n,r,i){const{range:s}=e;return Mr(t.map(e=>({points:e.points.filter(e=>e.t>=o.start&&e.t<=o.end),decimals:e.entity.decimals,color:e.color})),n,{at:e=>[a.x(e.t)/cn,a.y(e.v)/dn],width:Dn(i),height:r?96:162,skip:t=>void 0===e.group&&(t.t===s.start||t.t===s.end)},i.hass)}function rc(e,t){const o="outside"===t.labels;return(e?110:180)-(o&&t.x?e?14:18:0)-(o&&t.y?6:0)}function ic(e,t,o){const a=e.series.reduce((e,t)=>e+t.entity.name.length+t.entity.display.length+6,0);return{l:0,t:0,r:Math.min(o,8*a),b:t?24:34}}yt("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}",chart_log_nothing:"Nothing above zero to draw on a log scale."});const sc={layout:"chart",group:"history",height:240,styles:r` + ${$n} + ${Rn} + ${fr} + ${Gn} + ${Gl} + ${pi} + ${zr} + ${Pr} + ${Wr} .style-line .line { stroke-linecap: round; stroke-linejoin: round; @@ -979,21 +1532,21 @@ const et=pe(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de .style-line .line.dashed { stroke-dasharray: 6 5; } - `,thumbnail:()=>V` + `,thumbnail:()=>Z` `,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} + stroke-width="2.5" stroke-linecap="round" stroke-dasharray="4 3" />`,validate(e){if(pn(e),Lo(e),cr(e),Ft(e),qn(e),function(e){for(const t of["zoom","overview"])if(void 0!==e[t]&&"boolean"!=typeof e[t])throw new Error(`"${t}" must be true or false`)}(e),oi(e),function(e){const t=e;if(void 0!==t.labels&&"boolean"!=typeof t.labels)throw new Error('"labels" must be true or false');if(void 0!==t.labels_show&&!gr.includes(t.labels_show))throw new Error(`"labels_show" must be one of: ${gr.join(", ")}`);if(void 0!==t.labels_position&&!vr.includes(t.labels_position))throw new Error(`"labels_position" must be one of: ${vr.join(", ")}`);if(void 0!==t.labels_overlap&&"boolean"!=typeof t.labels_overlap)throw new Error('"labels_overlap" must be true or false')}(e),function(e){const t=e.extremes;if(void 0!==t){if("object"!=typeof t||null===t||Array.isArray(t))throw new Error('"extremes" must have "min", "max" (true or false) and "average"');const e=t;for(const t of["min","max"])if(void 0!==e[t]&&"boolean"!=typeof e[t])throw new Error(`"extremes.${t}" must be true or false`);if(void 0!==e.average&&!Ar.includes(e.average))throw new Error(`"extremes.average" must be one of: ${Ar.join(", ")}`)}for(const o of e.entities??[]){if("object"!=typeof o)continue;const e=o.extremes;if(void 0!==e&&"boolean"!=typeof e)throw new Error(`"extremes" of ${o.entity} must be true or false`)}}(e),function(e){if(void 0!==e.sync&&("string"!=typeof e.sync||""===e.sync.trim()))throw new Error('"sync" must be a group name')}(e),function(e){if(void 0!==e.multiples&&"boolean"!=typeof e.multiples)throw new Error('"multiples" must be true or false');if(void 0!==e.multiples_scale&&!Zr.includes(e.multiples_scale))throw new Error(`"multiples_scale" must be one of: ${Zr.join(", ")}`);const t=e.multiples_columns;if(void 0!==t&&!(Number.isInteger(t)&&t>=1&&t<=6))throw new Error('"multiples_columns" must be a whole number from 1 to 6')}(e),void 0!==e.color_by&&!tc.includes(e.color_by))throw new Error(`"color_by" must be one of: ${tc.join(", ")}`)},editor:function(e){const t=Pi(`line|${$i(e.config)}|${o=e.config,!0===o?.labels?"labels":""}|${(e=>!0===e?.multiples?"multiples":"")(e.config)}`,e.hass,()=>{return[...ki(e.hass,e.config),{type:"grid",name:"",schema:[{name:"curve",selector:xi(e.hass,"curve",an)},{name:"color_by",selector:xi(e.hass,"color_by",["series","bands"])}]},{type:"grid",name:"",schema:[{name:"line_width",selector:Vt},{name:"dots",selector:{boolean:{}}}]},...Li(e.hass,e.config),...Ci(e.hass),{name:"y_scale",selector:xi(e.hass,"y_scale",ko)},...ji,...Ni,...(t=e.hass,[{type:"grid",name:"",schema:[{name:"extremes_max",selector:{boolean:{}}},{name:"extremes_min",selector:{boolean:{}}}]},{name:"extremes_average",selector:{select:{mode:"dropdown",options:Ar.map(e=>({value:e,label:_t(t,`editor_extremes_average_${e}`)}))}}}]),...Ti,...Si(e.hass),...Oi(e.hass,e.config)];var t});var o;return R`${Di(e,"chart",t,Ui)}${_i(e)}`},entityFields:(e,t)=>{const o=oo(e),a=Gt(!0===t.dots);return Nr(jr(t))?{schema:Ir(o,e),fields:Rr(a)}:{schema:o,fields:a}},watch:function(e){const t=new Set;for(const o of ai(e))for(const e of[o.value,o.from,o.to,"state"===o.type?o.entity:void 0])Jr(e)&&t.add(e.trim());return[...t]},disconnect(e){bn(e),Pn(e),sr(e),function(e){Ul.delete(e)}(e),function(e){const t=li.get(e);t&&t.generation++,li.delete(e)}(e),Ql(e)},render(e){const t=mn(e),o=ir(e.host),a=ec(e,t,o),n=vn(t,e),r=_n(t,e),i=n===W&&r===W?W:R`${n}${r}`,s=xn(t,e),l=Wn(e.raw);if(s)return Vn("over"===l.header?{...l,header:"above"}:l,i,s);const c=function(e){if(!0===e.multiples)return{scale:e.multiples_scale??"auto",columns:e.multiples_columns}}(e.raw);if(c){const r="log"===e.raw.y_scale?{...t,series:t.series.map(e=>({...e,points:jn(e.points)}))}:t;return Vn({...l,header:"above"},n,qr(e,r,c,o??a,(t,n,i)=>function(e,t,o,a,n,r,i=W,s=ir(e.host)){const{scales:l}=a,c=yr(e.raw),d=lr(e.raw),u="bands"===e.raw.color_by,h=u?Z`${o.map(e=>oc(`cd-bands-${e.index}${r}`,e.entity.bands,l))}`:W,p=jt(o,t=>Ct(t.entity.config,e.raw).onTop),m=Z`${p.map(o=>ac(o,l,u?`url(#cd-bands-${o.index}${r})`:o.color,t.range,e.raw))}`,f=_t(e.hass,"chart_aria",{style:_t(e.hass,"editor_style_line"),names:o.map(e=>e.entity.name).join(", ")});return Bn({axes:a,ctx:e,content:m,label:f,defs:h,overlay:R`${i} ${c?nc(t,o,t.range,l,c,!0,e):W} + ${d?mr(e,o,l,s,n):W}`,hover:d?dr(e,t.range,o.map(e=>e.points)):void 0})}(e,r,[t],n,i,`-${t.index}`,W,o??a)))}const d=Hl(e,t),u=d.range,h=d.zoomed?t.series.map(e=>({...e,points:Dl(e.points,u)})):t.series,p="log"===e.raw.y_scale,m=p?h.map(e=>({...e,points:jn(e.points)})):h,f=(g=e.raw,v=e.hass,ai(g).filter(e=>"line"===e.type).map(e=>ni(e.value,v)).filter(Xr)).filter(e=>!p||e>0).map(e=>({t:u.start,v:e}));var g,v;const y=xo([...m.map(e=>e.points),f]);if(p&&!y){const t=R`
${_t(e.hass,"chart_log_nothing")}
`;return Vn("over"===l.header?{...l,header:"above"}:l,i,t)}const b=Un(e,u,y),{scales:_}=b,x=lr(e.raw)&&!function(e){const t=Ul.get(e)?.gesture;return!!t&&("pinch"===t.kind||"moved"in t&&t.moved)}(e.host),w="bands"===e.raw.color_by,$=w?Z`${h.map(e=>oc(`cd-bands-${e.index}`,e.entity.bands,_))}`:W,k=jt(m,t=>Ct(t.entity.config,e.raw).onTop),M=Z`${k.map(o=>ac(o,_,w?`url(#cd-bands-${o.index})`:o.color,t.range,e.raw))}`,z=lr(e.raw)?dr(e,u,h.map(e=>e.points)):void 0,A=hi(e,t,_,Dn(e)),S=yr(e.raw),E=Dn(e),C=Fr({ctx:e,series:h,scales:_,range:u,group:t.group,timeWeighted:"history"===t.source&&void 0===t.group,width:E,height:rc(l.compact,b.options),until:Math.min(u.end,Date.now()),obstacles:"over"===l.header?[ic(t,l.compact,E)]:[]}),j=_t(e.hass,"chart_aria",{style:_t(e.hass,"editor_style_line"),names:t.series.map(e=>e.entity.name).join(", ")});return Vn(l,i,Bn({axes:b,ctx:e,content:Z`${A.behind}${C.lines}${M}${A.front}`,label:j,defs:$,overlay:R`${"over"===l.header?Yn(i):W} + ${S?nc(t,m,u,_,S,l.compact,e):W} + ${ql(e,d)} ${A.overlay} ${C.overlay} + ${x?mr(e,h,_,o??a,Dn(e)):W}`,hover:z,gestures:d.options.zoom?Zl(e,d,z):void 0,classes:d.options.zoom?"zoomable "+(d.zoomed?"zoomed":""):"",below:d.options.overview?Yl(e,d,t.range,Vl(t.series,t.range)):W}))}},lc=["line","column","area"],cc=["auto","left","right"];yt("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 dc=e=>e.config,uc=e=>dc(e).type??"line";function hc(e,t){const o={...e},a=t.some(e=>"column"===uc(e));return"none"!==e.group_by&&(void 0!==e.group_by&&"auto"!==e.group_by||a)?void 0!==e.group_by&&"auto"!==e.group_by||(o.group_by=ul($a(e.period))):delete o.group_by,"auto"===e.aggregate&&delete o.aggregate,o}function pc(e,t){const o=e.flatMap(e=>e.points.map(e=>e.v));return gl(o,t.includes("column"))}const mc=[...wi.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:At},{name:"dots",path:["dots"],default:!1},{name:"rounded",path:["rounded"],default:!0},...Ei,...Ai];const fc=[{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"]}],gc={layout:"chart",group:"history",height:240,styles:r` + ${$n} + ${Rn} + ${fr} + ${Gn} + ${Yi} + ${Sl} .lines-over { pointer-events: none; } @@ -1007,28 +1560,28 @@ const et=pe(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de .lines-over .line.dashed { stroke-dasharray: 6 5; } - `,thumbnail:()=>V` + `,thumbnail:()=>Z` `,validate:function(e){if(void 0!==e.group_by&&!ni.includes(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};if("auto"!==e.group_by&&"none"!==e.group_by||delete t.group_by,"auto"===e.aggregate&&delete t.aggregate,To(t),mo(t),sn(e),ga(e),ft(e),Qo(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&&!Ci.includes(e.type))throw new Error(`"type" of ${n.entity} must be one of: ${Ci.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&&!uo.includes(e.aggregate))throw new Error(`"aggregate" of ${n.entity} must be one of: ${uo.join(", ")}`)}},editor:function(e){const t="none"===e.config.group_by,n={...e.config,group_by:void 0},o=Ta(`combined|${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)}]]},{name:"curve",selector:wa(e.hass,"curve",wo)},{type:"grid",name:"",schema:[{name:"line_width",selector:kt},{name:"dots",selector:{boolean:{}}},{name:"rounded",selector:{boolean:{}}}]},...za(e.hass),...Ea(e.hass)]);return ja(e,"chart",o,Ni)},entityFields:e=>({schema:Ta("combined-entity",e,()=>[{type:"grid",name:"",schema:[{name:"series_type",selector:wa(e,"series_type",Ci)},{name:"series_axis",selector:wa(e,"series_axis",zi)},{name:"series_color",selector:{text:{}}},{name:"series_aggregate",selector:wa(e,"series_aggregate",["auto",...uo])}]},{type:"grid",name:"",schema:[{name:"curve",selector:wa(e,"curve",wo)},{name:"line_width",selector:kt},{name:"dash",selector:{boolean:{}}},{name:"negative_color",selector:{text:{}}}]}]),fields:Ui}),disconnect(e){Po(e),Wo(e),pa(e)},render:function(e){const t={...e,raw:ji(e.raw,e.items)},n=t.raw,o=jo(t,Date.now(),{aggregate:t=>function(e,t){const n=t.aggregate;return Li(e).aggregate??(n&&"auto"!==n?n:si(e)?"change":"mean")}(t,e.raw)}),a=Uo(o,t),r=Do(o,t),i=a===W&&r===W?W:R`${a}${r}`,s=Ho(o,t),l=ea(n);if(s)return ta("over"===l.header?{...l,header:"above"}:l,i,s);const c=o.series.map(e=>Ti(e.entity)),d=function(e){const t=e[0]?.unit,n=e.map(e=>Li(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),m=h?Math.max(o.range.end,...o.series.flatMap(e=>e.points.map(e=>e.end??0))):0,p=h?{...o.range,end:m}:o.range,f=e=>u.filter((t,n)=>d[n]===e),g=e=>c.filter((t,n)=>d[n]===e),y=Oi(f("left"),g("left"));let v=n;y&&g("left").includes("column")?v={...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")&&(v=Na(y,n));const _=Yo({...t,raw:v},p,y),b=_.scales,x=f("right"),$=Oi(x,g("right")),w={};$&&(g("right").includes("column")||g("right").includes("area"))&&($[0]>=0&&$[0]<=$[1]/2&&(w.min=0),$[1]<=0&&$[1]>=$[0]/2&&(w.max=0));const k=x.length?un($,w,4):void 0,M=k?{x:b.x,y:Kt(k.domain,[Co,0]),domain:k.domain}:void 0,A=e=>"right"===d[e.index]&&M?M:b,S=u.filter(e=>"area"===c[e.index]).map(e=>Da(e,e.points,A(e),n,{range:p})),E=u.filter(e=>"line"===c[e.index]).map(e=>Ai({...e,points:e.points},A(e),e.color,p,n)),C=u.filter(e=>"column"===c[e.index]),z=ci(o.series.filter(e=>"column"===c[e.index]).map(e=>e.points),h?1/0:o.range.end),L=!1===n.rounded?0:4,T=[];z.times.forEach((e,t)=>{const n=b.x(e),o=b.x(z.ends[t]);C.forEach((e,a)=>{const r=z.values[a][t];if(null===r)return;const i=A(e),s=Math.min(Math.max(0,i.domain[0]),i.domain[1]),[l,c]=di(n,o,a,C.length);T.push(yi(i,l,c,Math.min(s,r),Math.max(s,r),e.color,r>=s?"top":"bottom",e.index))})});const j=z.times.map((e,t)=>(e+z.ends[t])/2),O=u.map(e=>{if("column"!==c[e.index]||h)return e;const t=C.indexOf(e);return{...e,points:j.map((e,n)=>({t:e,v:z.values[t][n]}))}}),N=new Map;for(const P of o.series)for(const e of P.points)void 0!==e.end&&N.set((e.t+e.end)/2,[e.t,e.end]);const U=fa(n),F=at(t.hass,"chart_aria",{style:at(t.hass,"editor_style_combined"),names:o.series.map(e=>e.entity.name).join(", ")});return ta(l,i,Xo({axes:_,ctx:t,right:k&&M?{value:k,y:M.y}:void 0,content:V`${S.map(e=>e.body)}`,defs:V`${S.map(e=>e.defs)}`,label:F,overlay:R`
${T}
-
${N}
+ - ${"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`
+ ${"over"===l.header?Yn(i):W} + ${F?mr(t,T,_,ir(t.host),Dn(t),{dotY:(e,t)=>"column"===c[e.index]||null===t.v?null:z(e).y(t.v),heading:e=>{const o=O.get(e);return o&&u?bl(o[0],o[1],u.unit,t.hass):Qo(e,t.hass)}}):W}`,hover:F?dr(t,m,T.map(e=>e.points)):void 0}))}};function vc(e,t){const o=Math.round(12+88*t);return o>=100?e:`color-mix(in srgb, ${e} ${o}%, var(--card-background-color, #fff))`}const yc=Math.sqrt(3)/2,bc=["hours","calendar"],_c=["scale","bands"],xc=["square","rounded","hexagon"],wc=10,$c=1e4,kc={hours:{last:4,unit:"week",source:"history"},calendar:{last:12,unit:"month",source:"statistics"}};yt("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 Mc=e=>"number"==typeof e&&Number.isFinite(e);function zc(e){return e.grid??"hours"}function Ac(e){const t=zc(e),o=kc[t],a=e.period??{};return{...e,group_by:"hours"===t?"hour":"day",source:e.source??o.source,period:void 0!==a.calendar?a:{...a,last:a.last??o.last,unit:a.unit??o.unit}}}const Sc=new Map;function Ec(e,t,o){const a=e?.locale?.language??e?.language??"en",n=`${a}|${t}|${JSON.stringify(o)}`;let r=Sc.get(n);if(!r){try{r=new Intl.DateTimeFormat(a,{...o,timeZone:t})}catch{r=new Intl.DateTimeFormat("en",{...o,timeZone:"UTC"})}Sc.set(n,r)}return r}function Cc(e,t){return Ec(t,"UTC",{weekday:"short"}).format(Date.UTC(2024,0,e))}function jc(e,t,o=!1){return Ec(t,"UTC",{hour:"numeric",...o?{minute:"2-digit"}:{},hour12:Go(t)}).format(Date.UTC(2024,0,1,e))}function Nc(e,t,o,a,n="cell"){const[r,i]=e.centre(t.col,t.row),s=a?`fill:${a}`:"",l=a?"":"empty";if("hexagon"===o)return Z`{const n=(60*o-90)*Math.PI/180;return`${Math.round(100*(e+a*Math.cos(n)))/100},${Math.round(100*(t+a*Math.sin(n)))/100}`});return`M${n.join("L")}Z`}(r,i,8.8)} style=${s}>`;const c=8.8;return Z``}const Lc=e=>Math.round(1e4*e)/100+"%";function Tc(e,t,o,a){const{zone:n}=t,r=e=>(n.firstDay-1+e)%7+1,i=function(e,t){const o=new Array(168).fill(0),a=new Array(168).fill(0);for(const n of e){if(null===n.v)continue;const e=sa(n.t,t.timeZone),r=(e.weekday-t.firstDay+7)%7*24+e.hour;o[r]+=n.v,a[r]+=1}return o.map((e,t)=>({row:Math.floor(t/24),hour:t%24,v:a[t]?e/a[t]:null,count:a[t]}))}(e.points,n).map(e=>({col:e.hour,row:e.row,v:e.v,count:e.count,title:()=>`${Cc(r(e.row),o)} ${jc(e.hour,o,!0)} – ${jc((e.hour+1)%24,o,!0)}`})),s=a?3:6;return{cells:i,cols:24,rows:7,rowLabels:[0,1,2,3,4,5,6].map(e=>({row:e,text:Cc(r(e),o)})),colLabels:Array.from({length:24/s},(e,t)=>({col:t*s,text:jc(t*s,o)}))}}function Oc(e,t,o){const{zone:a}=t,n=function(e,t){if(0===e.length)return{cells:[],weeks:0,months:[]};const o=e=>{const o=sa(e,t.timeZone);return Math.round(Date.UTC(o.year,o.month-1,o.day)/864e5)},a=o(ua("week",e[0].t,t)),n=[],r=[];let i=-1;for(const s of e){const e=sa(s.t,t.timeZone),l=Math.floor((o(s.t)-a)/7);n.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:n,weeks:n[n.length-1].col+1,months:r}}(e.points,a),r=Ec(o,a.timeZone,{weekday:"short",day:"numeric",month:"short",year:"numeric"}),i=Ec(o,a.timeZone,{month:"short"}),s=e=>(a.firstDay-1+e)%7+1;return{cells:n.cells.map(e=>({col:e.col,row:e.row,v:e.v,title:()=>r.format(e.t)})),cols:Math.max(1,n.weeks),rows:7,rowLabels:[0,1,2,3,4,5,6].map(e=>({row:e,text:Cc(s(e),o)})),colLabels:n.months.map(e=>({col:e.col,text:i.format(e.t)}))}}function Fc(e,t,o,a){const n=o.raw,r=zc(o.raw),i=n.cell_shape??"rounded",s="bands"===n.color_by,l=Fn(o),c="hours"===r?Tc(e,t,o.hass,l>=400):Oc(e,t,o.hass),d=function(e,t,o){if("hexagon"===o){const o=wc/Math.sqrt(3),a=wc*yc,n=e*wc+5,r=(t-1)*a+2*o;return{width:n,height:r,centre:(e,t)=>[e*wc+5+t%2*5,o+t*a],at(i,s){const l=Math.min(t-1,Math.max(0,Math.round((s*r-o)/a))),c=Math.floor((i*n-l%2*5)/wc);return[Math.min(e-1,Math.max(0,c)),l]}}}return{width:e*wc,height:t*wc,centre:(e,t)=>[e*wc+5,t*wc+5],at:(o,a)=>[Math.min(e-1,Math.floor(o*e)),Math.min(t-1,Math.floor(a*t))]}}(c.cols,c.rows,i),u=Math.max(0,l-72)/d.width*wc,h=Math.ceil(13/Math.max(1,u*("hexagon"===i?yc:1))),p=c.rowLabels.filter(e=>e.row%h===0);let m=-1/0;const f=c.colLabels.filter(e=>!((e.col-m)*u<32)&&(m=e.col,!0)),g=function(e,t={}){const o=e.filter(e=>null!==e);if(0===o.length&&(void 0===t.min||void 0===t.max))return;let a=t.min??Math.min(...o),n=t.max??Math.max(...o);return n<=a&&(void 0===t.max?n=a+1:a=n-1),[a,n]}(c.cells.map(e=>e.v),{min:Mc(n.color_min)?n.color_min:void 0,max:Mc(n.color_max)?n.color_max:void 0}),v=t=>{if(null!==t)return s?Ce(Te(t,e.entity.bands).color):g?vc(e.color,function(e,[t,o]){return Math.min(1,Math.max(0,(e-t)/(o-t)))}(t,g)):e.color},y=ys(o.host),b=void 0!==y&&Math.floor(y/$c)===e.index?y%$c:void 0,_=new Map(c.cells.map((e,t)=>[8*e.col+e.row,t])),x=lr(o.raw)?_s(o,(t,o)=>{const[a,n]=d.at(t,o),r=_.get(8*a+n);return void 0===r?void 0:e.index*$c+r}):void 0,w=void 0===b?void 0:c.cells[b];let $=W;if(w){const[t,a]=d.centre(w.col,w.row),n=null===w.v?_t(o.hass,"heatmap_empty"):Mn(kn(w.v,e.entity.decimals,o.hass),e.entity.unit);$=ws({x:t/d.width,y:a/d.height,plotWidth:Math.max(0,l-80),title:w.title(),rows:[{color:v(w.v)??"var(--cd-track)",name:e.entity.name,value:n},...void 0!==w.count&&null!==w.v?[{name:_t(o.hass,"heatmap_hours"),value:String(w.count)}]:[]]})}const{show:k}=o.config,M=k.name||k.value||a?R`
${s?W:R``} - ${k.name||o?R`${e.entity.name}`:W} + ${k.name||a?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`
+
`:W,z=_t(o.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} + + ${c.cells.map(e=>Nc(d,e,i,v(e.v)))} + ${w?Nc(d,w,i,"none","cell picked"):W} - ${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`kn(e,n.decimals,a.hass);return o?R`
+ ${Oe(n.bands,r,n.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} + + ${Mn(r(t[1]),n.unit)} +
`:W}(e,g,s,o)} +
`}const Pc=new Map;const Dc={layout:"chart",group:"distribution",height:220,styles:r` + ${$n} + ${fr} .hm + .hm { margin-top: 12px; } @@ -1157,14 +1710,14 @@ const et=pe(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de 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} + `,thumbnail:()=>Z` + ${[0,1,2,3].map(e=>[0,1,2,3,4,5,6].map(t=>Z``))}`,validate:function(e){const t=e,o=(e,o)=>{const a=t[e];if(void 0!==a&&!o.includes(a))throw new Error(`"${e}" must be one of: ${o.join(", ")}`)};o("grid",bc),o("color_by",_c),o("cell_shape",xc);for(const a of["color_min","color_max"])if(void 0!==t[a]&&!Mc(t[a]))throw new Error(`"${a}" must be a number`);if(Mc(t.color_min)&&Mc(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');pn(Ac(e)),cr(e)},editor:function(e){const t=zc(e.config),o=Pi(`heatmap|${$i({...e.config,group_by:void 0})}`,e.hass,()=>[{name:"grid",selector:xi(e.hass,"grid",bc)},...ki(e.hass,{...e.config,group_by:void 0}).filter(e=>!JSON.stringify(e).includes('"group_by"')),{name:"aggregate",selector:xi(e.hass,"aggregate",qa)},{type:"grid",name:"",schema:[{name:"color_by",selector:xi(e.hass,"color_by",_c)},{name:"cell_shape",selector:xi(e.hass,"cell_shape",xc)}]},{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 Di(e,"chart",o,function(e){let t=Pc.get(e);if(!t){const o=kc[e],a={period_last:o.last,period_unit:o.unit,source:o.source};t=[{name:"grid",path:["grid"],default:"hours"},...wi.filter(e=>"group_by"!==e.name).map(e=>e.name in a?{...e,default:a[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}],Pc.set(e,t)}return t}(t))},entityFields:()=>({schema:[{name:"series_color",selector:{text:{}}}],fields:[{name:"series_color",path:["color"]}]}),disconnect(e){bn(e),Pn(e),bs(e)},render(e){const t=mn({...e,raw:Ac(e.raw)}),o=vn(t,e),a=xn(t,e);if(a)return R`
${o}${a}
`;const n=t.series.length>1;return R`
${o}${t.series.map(o=>Fc(o,t,e,n))}
`}},Uc=["time","readings"],Hc=100,Ic=e=>Number(e.toPrecision(12));const Bc=6e4,Rc=36e5;const Zc=["percent","total"],qc=["side","overlap"],Wc=["series","bands"];yt("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 Vc=e=>"number"==typeof e&&Number.isFinite(e);function Yc(e,t,o){if(void 0!==e[t]&&!o.includes(e[t]))throw new Error(`"${t}" must be one of: ${o.join(", ")}`)}function Gc(e,t){const o=mn(e,t),a={count:(n=e.raw).count??"time",showAs:n.show_as??"percent",bars:n.bars??"side",bands:"bands"===n.color_by,bins:Vc(n.bins)?n.bins:void 0,width:Vc(n.bin_width)?n.bin_width:void 0};var n;const r=Math.min(o.range.end,t),i=o.series.map(e=>function(e,t){const{mode:o,until:a,statistics:n}=t;if(0===e.length)return[];if(void 0!==e[0].end)return e.map(e=>null===e.v?0:"readings"===o?1:Math.max(0,Math.min(e.end,a)-e.t));if(n){if("readings"===o)return e.map(e=>null===e.v||e.carried?0:1);const t=e.slice(1).map((t,o)=>t.t-e[o].t).filter(e=>e>0).sort((e,t)=>e-t),a=t.length?t[Math.floor(t.length/2)]:36e5;return e.map(e=>null===e.v||e.carried?0:a)}const r=e.length-1;return e.map((t,n)=>{if(null===t.v)return 0;if("readings"===o){const o=n===r&&n>0&&t.t>=a&&e[n-1].v===t.v;return t.carried||o?0:1}const i=ne.points.forEach((e,o)=>{null!==e.v&&i[t][o]>0&&(s=Math.min(s,e.v),l=Math.max(l,e.v))}));const c=ms(e.raw),d="side"===a.bars&&o.series.length>1,u=Math.max(5,Math.min(24,Math.round(Dn(e)/(d?40:28)))),h=s===1/0?[]:function(e,t,o={},a=12){const n=o.min??e,r=o.max??t;if(void 0!==o.bins&&void 0===o.width){let[e,t]=[n,r];t>e||([e,t]=[e-.5,e+.5]);const a=(t-e)/o.bins;return Array.from({length:o.bins+1},(n,r)=>r===o.bins?t:Ic(e+r*a))}const i=r-n;let s=o.width??(i>0?Oo(i,a):Oo(Math.max(Math.abs(n),1),10));for(;i/s>Hc;)s=Oo(i,50);const l=o.min??Ic(Math.floor(n/s+1e-9)*s);let c=o.max??Ic(Math.ceil(r/s-1e-9)*s);c>l||(c=Ic(l+s));const d=[l];for(let u=1;d[d.length-1]{const o=function(e,t,o){const a=new Array(Math.max(0,o.length-1)).fill(0);return e.forEach((e,n)=>{if(null===e.v||!(t[n]>0))return;const r=function(e,t){const o=t.length-1;if(o<1||et[o])return-1;if(e===t[o])return o-1;let a=0,n=o-1;for(;a>1;t[o]<=e?a=o:n=o-1}return a}(e.v,o);r>=0&&(a[r]+=t[n])}),a}(e.points,i[t],h),n=o.reduce((e,t)=>e+t,0),r=o.map(e=>"percent"===a.showAs?n>0?e/n*100:0:"time"===a.count?e/36e5:e);return{series:e,totals:o,heights:r,whole:n}});return{frame:o,opts:a,edges:h,data:p,side:d}}function Xc(e,t,o){return"time"===t?function(e){if(e0?"< 1 min":"0 min";const t=Math.round(e/Bc);if(t<60)return`${t} min`;if(e<864e5){const e=Math.floor(t/60),o=t%60;return o?`${e} h ${o} min`:`${e} h`}const o=Math.round(e/Rc),a=Math.floor(o/24),n=o%24;return n?`${a} d ${n} h`:`${a} d`}(e):1===e?_t(o.hass,"histogram_reading"):_t(o.hass,"histogram_readings",{n:kn(e,0,o.hass)})}const Jc={layout:"chart",group:"distribution",height:240,styles:r` + ${$n} + ${Rn} + ${fr} + ${Gn} + ${$s} .style-histogram .bin { shape-rendering: crispEdges; } @@ -1173,7 +1726,7 @@ const et=pe(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de stroke-width: 1.5px; vector-effect: non-scaling-stroke; } - `,thumbnail:()=>V` + `,thumbnail:()=>Z` @@ -1182,7 +1735,7 @@ const et=pe(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de - `,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` + `,validate(e){if(pn(e),Lo(e),cr(e),qn(e),us(e),Yc(e,"count",Uc),Yc(e,"show_as",Zc),Yc(e,"bars",qc),Yc(e,"color_by",Wc),void 0!==e.bins&&!(Number.isInteger(e.bins)&&e.bins>=1&&e.bins<=Hc))throw new Error('"bins" must be a whole number from 1 to 100');if(void 0!==e.bin_width&&!(Vc(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,o=Pi(`histogram|${$i(e.config)}`,t,()=>[...ki(t,e.config),{type:"grid",name:"",schema:[{name:"count",selector:xi(t,"count",Uc)},{name:"show_as",selector:xi(t,"show_as",Zc)}]},{type:"grid",name:"",schema:[{name:"bins",selector:{number:{min:1,max:Hc,mode:"box"}}},{name:"bin_width",selector:{number:{min:0,mode:"box",step:"any"}}}]},...ps,{type:"grid",name:"",schema:[{name:"bars",selector:xi(t,"bars",qc)},{name:"color_by",selector:xi(t,"color_by",Wc)}]},...Ci(t).map(e=>"grid"===e.type?{...e,schema:e.schema.map(e=>"x_axis"===e.name?{...e,name:"hist_x_axis"}:e)}:e),...Si(t)]);return Di(e,"chart",o,td)},entityFields:()=>({schema:Qc,fields:Kc}),disconnect(e){bn(e),Pn(e),bs(e)},render(e){const t=Date.now(),{frame:o,opts:a,edges:n,data:r,side:i}=Gc(e,t),s=vn(o,e),l=_n(o,e),c=s===W&&l===W?W:R`${s}${l}`,d=Wn(e.raw),u=xn(o,e)??(n.length<2?xn({...o,series:[]},e):void 0);if(u)return Vn("over"===d.header?{...d,header:"above"}:d,c,u);const h=Math.max(0,...r.flatMap(e=>e.heights)),p=fs(e,[n[0],n[n.length-1]],[0,h],{x:{min:n[0],max:n[n.length-1]},yFloor:0}),{scales:m}=p,f=m.y(Math.max(0,p.value.domain[0])),g=r.length,v="overlap"===a.bars&&g>1,y=Z`${r.map((e,t)=>{const o=e.heights.map((o,r)=>{if(!(o>0))return W;const s=m.x(n[r]),l=m.x(n[r+1]),c=Math.min(4,.08*(l-s)),d=(l-s-2*c)/(i?g:1),u=s+c+(i?d*t:0),h=m.y(o),p=a.bands&&e.series.entity.bands.length?Ce(Te((n[r]+n[r+1])/2,e.series.entity.bands).color):e.series.color;return Z``});return Z`${o}`})}`,b=function(e){if(e.length<2)return 0;const t=e[1]-e[0],o=e.every(e=>Math.abs(e-Number(e.toFixed(Fo(t))))<1e-6*t);return o?Fo(t):Math.min(4,Fo(t)+1)}(n),_=lr(e.raw),x=_?ys(e.host):void 0;let w=W;if(void 0!==x&&x{const o=t.whole>0?t.totals[x]/t.whole*100:0;return{color:t.series.color,name:t.series.entity.name,value:`${kn(o,o>0&&o<1?1:0,e.hass)}% · ${Xc(t.totals[x],a.count,e)}`}})})}const $=_t(e.hass,"chart_aria",{style:_t(e.hass,"editor_style_histogram"),names:o.series.map(e=>e.entity.name).join(", ")});return Vn(d,c,Bn({axes:p,ctx:e,content:Z`${y}`,label:$,xLabel:t=>kn(t,gs(p),e.hass),yLabel:t=>"percent"===a.showAs?`${kn(t,p.value.decimals,e.hass)}%`:"time"===a.count?`${kn(t,p.value.decimals,e.hass)} h`:kn(t,p.value.decimals,e.hass),overlay:R`${"over"===d.header?Yn(c):W}${w}`,hover:_?_s(e,e=>{const t=n[0]+e*(n[n.length-1]-n[0]),o=n.findIndex((e,o)=>o=e&&t<=n[o+1]);return o<0?void 0:o}):void 0}))}},Kc=[{name:"bar_color",path:["color"]}],Qc=[{name:"bar_color",selector:{text:{}}}],ed=Ei.map(e=>"x_axis"===e.name?{...e,name:"hist_x_axis"}:e),td=[...wi,{name:"count",path:["count"],default:"time"},{name:"show_as",path:["show_as"],default:"percent"},{name:"bins",path:["bins"]},{name:"bin_width",path:["bin_width"]},...hs,{name:"bars",path:["bars"],default:"side"},{name:"color_by",path:["color_by"],default:"series"},...ed,...Ai];function od(e){let t=0;for(const o of e)t+=o.codePointAt(0)>=11904?1/.58:1;return t}function ad(e,t=76,o=12,a=8,n=11){const r=e.trim();let i=[r];if(od(r)>n){let e=od(r);for(let t=r.indexOf(" ");t>0;t=r.indexOf(" ",t+1)){const o=Math.max(od(r.slice(0,t)),od(r.slice(t+1)));oString(Math.round(1e3*e)/1e3+0);function cd(e,t,o,a,n,r=11){const{lines:i,size:s}=ad(e,t,o,a,r),l=n-1.2*(i.length-1)*s;return Z`${i.map((e,t)=>Z`${e}`)}`}const dd={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; @@ -1223,6 +1776,9 @@ const et=pe(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de :host(:not([ready])) .needle-gauge .needle { transform: rotate(-90deg) !important; } + :host(:not([ready])) .needle-gauge.fitted .needle { + transform: rotate(var(--needle-start)) !important; + } .needle-gauge .hub { fill: var(--primary-text-color); } @@ -1237,6 +1793,15 @@ const et=pe(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de font-weight: 500; fill: var(--primary-text-color); } + .needle-gauge .tick-label { + font-size: 9px; + fill: var(--secondary-text-color); + } + .needle-gauge .center-text { + font-size: 15px; + font-weight: 500; + fill: var(--primary-text-color); + } .needle-gauge .na { font-size: 12px; fill: var(--secondary-text-color); @@ -1258,39 +1823,53 @@ const et=pe(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((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`
+ `,render(e,{show:t,hass:o}){const a=e.config.gauge,n=Je(sd,a),r=e=>Qe(n,e),i=function(e,t,o){const a=o-t,n=[];return a>0?(e.forEach((r,i)=>{const s=0===i?t:Math.max(r.from,t),l=Math.min(i+1s&&n.push({from:(s-t)/a,to:(l-t)/a,color:r.color})}),n):n}(e.bands,e.min,e.max),s=i.map((e,t)=>{const o=r(e.from)+(t>0?1:0),a=r(e.to)-(to?Z``:W}),l=e=>{const[t,o]=We(nd,rd,36,e),[a,n]=We(nd,rd,40,e);return Z``},c=a?.tickLabels?function(e,t,o=5){if(!(Number.isFinite(e)&&Number.isFinite(t)&&t>e))return[];const a=ot(t-e,o),n=[];for(let r=Math.ceil(e/a)*a;re+.4*a&&n.push(Number(r.toPrecision(12)));return[e,...n,t]}(e.min,e.max):[],d=e.max-e.min,u=c.length?c.map(t=>l(r((t-e.min)/d))):Array.from({length:11},(e,t)=>l(r(t/10))),h=ot(d),p=t.target&&null!==e.targetPosition,m=p?67:60,f=Ke(a)||c.length>0,g=f?Math.max(94,Math.ceil(tt(nd,rd,id,n[0],n[1],6).y1)+14):94,v=c.map(t=>{const a=r((t-e.min)/d),[n,i]=We(nd,rd,m,a),{anchor:s,dy:l}=nt(a);return Z`${kn(t,at(t,h),o)}`});let y=W;if(t.target&&null!==e.targetPosition&&e.available){const t=r(e.targetPosition),[o,a]=We(nd,rd,57,t),[n,i]=We(nd,rd,64,t-3),[s,l]=We(nd,rd,64,t+3);y=Z``}const b=!t.value,_=e.available?rt(e,a):{kind:"value"};let x={x:0,y:0,w:130,h:b?82:104};if(f){const t=function(e,t,o,a,n,r){const i=[tt(nd,rd,id,t[0],t[1],n.hasTarget?15:7),tt(nd,rd,43,t[0],t[1],0),{x0:57,y0:58,x1:73,y1:74},n.compact?{x0:2,y0:40,x1:128,y1:82}:{x0:2,y0:n.valueY-16,x1:128,y1:n.valueY+6}],s=e.max-e.min;for(const c of o){const o=Qe(t,(c-e.min)/s),[l,d]=We(nd,rd,n.labelR,o),{anchor:u,dy:h}=nt(o),p=9*kn(c,at(c,a),r).length*.6,m="start"===u?l:"end"===u?l-p:l-p/2,f=d+9*h;i.push({x0:m,y0:f-9,x1:m+p,y1:f+2})}const l=et(...i);return{x0:Math.floor(l.x0-2),y0:Math.floor(l.y0-2),x1:Math.ceil(l.x1+2),y1:Math.ceil(l.y1+2)}}(e,n,c,h,{hasTarget:p,labelR:m,valueY:g,compact:b},o);x={x:t.x0,y:t.y0,w:t.x1-t.x0,h:t.y1-t.y0}}const w=f?`width: min(${ld(x.w)}px, 100%); aspect-ratio: ${ld(x.w)} / ${ld(x.h)}`:void 0;return R`
+ + ${t.name&&"name"!==a?.center||t.icon?R`
${t.icon?R``:W} - ${t.name?R`${e.name}`:W} + ${t.name&&"name"!==a?.center?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`
+
`;var $,k,M,z}};function ud(e){return e.map(e=>e.available&&e.value>0?e.value:0)}function hd(e,t=100){const o=e.reduce((e,t)=>e+Math.max(0,t),0);if(o<=0)return e.map(()=>0);const a=e.map(e=>Math.max(0,e)/o*t),n=a.map(Math.floor);let r=t-n.reduce((e,t)=>e+t,0);const i=a.map((e,t)=>({i:t,rest:e-n[t]})).sort((e,t)=>t.rest-e.rest||e.i-t.i);for(const{i:s}of i){if(r<=0)break;n[s]+=1,r-=1}return n}function pd(e,t){return t>0?e/t*(180/Math.PI):0}function md(e){const t=Math.max(0,e.end-e.start);return`--a:${(e.start-90).toFixed(3)}deg;--len:${t.toFixed(3)}`}function fd(e,t,o,a,n="primary"){return"bands"===a?Ce(e.band.color):"shades"===a?function(e,t,o){return o<=1||t<=0?e:`color-mix(in srgb, ${e} ${Math.round(100-Math.min(t,o-1)/(o-1)*65)}%, var(--card-background-color, #fff))`}(Ce(n),t,o):Ce(function(e){const t=e.config.color;return"string"==typeof t&&""!==t.trim()?t:void 0}(e)??un[t%un.length])}function gd(e){const t=new Set(e.filter(e=>e.available).map(e=>e.unit));return 1===t.size?[...t][0]:""}function vd(e,t,o,a=e.map(e=>e.available?e.value:0)){const n=e.map((e,t)=>({e:e,v:a[t]})).filter(({e:e})=>e.available);if(!n.length)return;const r=n.reduce((e,{v:t})=>e+t,0),i="total"===t?r:r/n.length,s=Math.max(...n.map(({e:e})=>e.decimals));return{value:i,text:kn(i,s,o),unit:gd(e)}}function yd(e){return Ae(e.config)?0:W}function bd(e,t){const{show:o}=t.config;return!e.length||!o.name&&!o.value&&e.every(e=>void 0===e.share)?W:R`
${e.map(e=>R`
- ${n.name?R`${e.entity.name}`:W} - ${n.value?R`${e.entity.display}`:W} + ${o.name?R`${e.entity.name}`:W} + ${o.value?R`${e.entity.display}`:W} ${void 0===e.share?W:R`${e.share}`}
`)} -
`}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 _d(e,t){return Mn(kn(e,0,t),"%")}function xd(e,t){return _t(e.hass,"parts_aria",{style:_t(e.hass,t),names:e.items.map(e=>e.name).join(", ")})}function wd(e,t,o){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` + ${o?R`
${o}
`:W} +
`}function $d(e,t,o){const a=e[t];if(void 0!==a&&!o.includes(a))throw new Error(`"${t}" must be one of: ${o.join(", ")}`)}function kd(e,t,o,a){const n=e[t];if(void 0!==n&&("number"!=typeof n||!Number.isFinite(n)||na))throw new Error(`"${t}" must be a number from ${o} to ${a}`)}function Md(e,t){const o=e[t];if(void 0!==o&&"boolean"!=typeof o)throw new Error(`"${t}" must be true or false`)}function zd(e,t){const o=e[t];if(void 0!==o&&"string"!=typeof o)throw new Error(`"${t}" must be text`)}yt("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 Ad=r` .parts { display: flex; flex-direction: column; @@ -1414,26 +1993,26 @@ const et=pe(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de font-weight: 500; white-space: nowrap; } -`;nt("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 Js=["series","bands"];function Ks(e,t,n,o){const{show:a}=n.config,r=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,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}0))return{count:0,per:1};const n=void 0!==o&&o>0?o:a<=20?1:a/10,r=Math.ceil(a/n-1e-9);return r>100?{count:100,per:a/100}:{count:r,per:n}}(e.min,e.max,a.perIcon),i=e.available?function(e,t,o,a){const n=Math.min(o.count,Math.max(0,(e-t)/o.per)),r=a?n:Math.floor(n+1e-9);return Array.from({length:o.count},(e,t)=>Math.min(1,Math.max(0,r-t)))}(e.value,e.min,r,a.partial):r.count?Array(r.count).fill(0):[],s=fd(e,t,o.items.length,a.colorBy),l=e.config.icon??a.unitIcon??(e.stateObj?void 0:"mdi:checkbox-blank-circle"),c=r.count>20?20:28,d=e.available?R`${e.valueText}/ ${Ro(Io(e.max,Number.isInteger(e.max)?0:e.decimals,n.hass),e.unit)}/ ${Mn(kn(e.max,Number.isInteger(e.max)?0:e.decimals,o.hass),e.unit)}`:e.display;return R`
- ${a.name?R`${e.name}`:W} + ${n.name?R`${e.name}`:W} - ${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` + ${n.value?R`${d}`:W} +
`}const Cd=[{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 jd={layout:"chart",group:"totals",height:180,styles:r` .pictograph { display: flex; flex-direction: column; @@ -1519,12 +2098,12 @@ const et=pe(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:()=>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} + `,thumbnail:()=>Z` + ${[0,1,2,3,4].map(e=>Z` + `)}`,validate(e){zd(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(kd(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');$d(e,"color_by",Sd),Md(e,"partial")},editor:function(e){const t=Pi("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:xi(e.hass,"color_by",Sd)}]},{name:"partial",selector:{boolean:{}}}]);return Di(e,"chart",t,Cd)},render:function(e){const t={perIcon:(o=e.raw).per_icon,unitIcon:o.unit_icon||void 0,groupSize:o.group_size??5,partial:!1!==o.partial,colorBy:o.color_by??"series"};var o;return R`
+ ${e.items.map((o,a)=>Ed(o,a,e,t))} +
`}};yt("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 Nd=["donut","pie"],Ld=["total","none"],Td=["series","bands","shades"],Od=48;function Fd(e,t){const o=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),a=o.filter(e=>e>0).length,n="donut"===t.shape?41.5:24;let r=0;return"donut"===t.shape&&(a>1&&(r+=pd(t.gap,n)),t.rounded&&(r+=pd(13,n))),function(e,t=0,o=360){const a=o-t;let n=t;return e.map(e=>{const t=n;return n+=e*a,{start:t,end:n}})}(o).map((e,t)=>o[t]>0?function(e,t){const o=e.end-e.start,a=Math.min(Math.max(0,t),o)/2;return{start:e.start+a,end:e.end-a}}(e,r):void 0)}const Pd=[{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 Dd={layout:"chart",group:"totals",height:200,styles:r` + ${Ad} .style-pie .track { stroke: var(--cd-track); } @@ -1541,18 +2120,18 @@ const et=pe(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de :host([static]) .style-pie .separator { transition: none; } - `,thumbnail:()=>V` + `,thumbnail:()=>Z` `,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` - + pathLength="100" stroke-dasharray="22 78" stroke-dashoffset="-76" transform="rotate(-90 32 20)" />`,validate(e){$d(e,"shape",Nd),$d(e,"center",Ld),$d(e,"color_by",Td),kd(e,"gap",0,10),Md(e,"rounded"),Md(e,"gradient"),Md(e,"show_percent"),zd(e,"shade_color"),zd(e,"center_label")},editor:function(e){const t=Pi("pie",e.hass,()=>[{type:"grid",name:"",schema:[{name:"shape",selector:xi(e.hass,"shape",Nd)},{name:"color_by",selector:xi(e.hass,"color_by",Td)},{name:"center",selector:xi(e.hass,"center",Ld)},{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 Di(e,"chart",t,Pd)},render:function(e){const t={shape:(o=e.raw).shape??"donut",center:o.center??"total",colorBy:o.color_by??"series",shadeColor:o.shade_color||"primary",gap:o.gap??2,rounded:!0===o.rounded,gradient:!0===o.gradient,percent:!1!==o.show_percent,centerLabel:o.center_label??""};var o;const a=e.items,n=ud(a),r=a.map((e,o)=>fd(e,o,a.length,t.colorBy,t.shadeColor)),i=Fd(n,t),s="donut"===t.shape,l=s?41.5:24,c=s?13:Od,d=s?35/Od:0,u=i.filter(Boolean).length,h=t.gradient?Z`${a.map((e,t)=>i[t]?function(e,t,o){return Z` + - `}(`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``}(`cd-pie-${t}`,r[t],d):W)}`:W,p=a.map((e,o)=>{const a=i[o];if(!a)return W;const n=t.gradient?`url(#cd-pie-${o})`:r[o];return Z` - `}),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`
+ `}),m=!s&&t.gap>0&&u>1?i.map(e=>e?Z``:W):W,f=0===u?Z``:W,g=hd(n),v=a.map((o,a)=>({index:a,entity:o,color:r[a],share:t.percent&&n[a]>0?_d(g[a],e.hass):void 0})),y=vd(a,"total",e.hass,n),b=s&&"total"===t.center&&y?wd(y.text,"",t.centerLabel||gd(a)):W;return R`
- - ${u}${f}${m}${p} + + ${h}${f}${p}${m} - ${_} + ${b}
- ${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` + ${bd(v,e)} +
`}};let Ud=0;const Hd=new WeakMap;const Id=60,Bd=60,Rd=e=>String(Math.round(1e3*e)/1e3+0);function Zd(e){return Math.min(22,Math.floor(124/Math.max(1,e.length)))}function qd(e){const t=function(e,t){const o=e/2;return[-o+t[0]*e,-o+t[1]*e]}(e,[0,1]),o=Ve(Id,Bd,46,t[0],t[1]),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; @@ -1643,6 +2222,10 @@ const et=pe(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de font-weight: 500; fill: var(--primary-text-color); } + .radial .center-text { + font-weight: 500; + fill: var(--primary-text-color); + } .radial .unit { font-size: 11px; fill: var(--secondary-text-color); @@ -1666,6 +2249,13 @@ const et=pe(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de transform: translate(-50%, -50%); --mdc-icon-size: 22.58cqw; } + /* A custom sweep crops --y0 drawing units off the top: move the icon up by as much. */ + .radial .dial.fitted .icon { + top: calc(25cqw - var(--y0) * 0.8065cqw); + } + .radial .dial.fitted .icon.centre { + top: calc(48.39cqw - var(--y0) * 0.8065cqw); + } .radial .name { max-width: min(124px, 100%); font-size: 12px; @@ -1674,44 +2264,50 @@ const et=pe(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: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``:W}
- ${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} + ${n.name&&"name"!==s?.center?R`${e.name}`:W} +
`}}}const Wd=qd(360),Vd=qd(270);yt("en",{editor_style_rings:"Rings",editor_rings_start_angle:"Start angle (0 is the top)",editor_end_angle:"End angle",editor_rounded_ends:"Rounded ends"});const Yd=["average","total","none"],Gd=["series","bands"];function Xd(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 Jd=[{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 Kd={layout:"chart",group:"totals",height:200,styles:r` + ${Ad} .style-rings .centre-box { position: absolute; inset: 0 0 auto 0; @@ -1729,7 +2325,7 @@ const et=pe(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de transform-box: view-box; transform-origin: 50px 50px; } - `,thumbnail:()=>V` + `,thumbnail:()=>Z` `,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``,validate(e){kd(e,"start_angle",-360,360),kd(e,"end_angle",-360,360);const{start:t,end:o}=Xd(e);if(o<=t||o-t>360)throw new Error('"end_angle" must be after "start_angle", and at most 360 degrees after it');$d(e,"center",Yd),$d(e,"color_by",Gd),Md(e,"rounded_ends")},editor:function(e){const t=Pi("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:xi(e.hass,"center",Yd)},{name:"color_by",selector:xi(e.hass,"color_by",Gd)}]},{name:"rounded_ends",selector:{boolean:{}}}]);return Di(e,"chart",t,Jd,{start_angle:"editor_rings_start_angle"})},render:function(e){const t=Xd(e.raw),o=e.items,{radii:a,width:n}=function(e){if(e<=0)return{radii:[],width:0};const t=Math.min(9,28/(1.33*e)),o=1.33*t;return{radii:Array.from({length:e},(e,a)=>48-t/2-a*o),width:t}}(o.length),r=o.map((e,a)=>fd(e,a,o.length,t.colorBy)),i=t.rounded?"round":"butt",s=md({start:t.start,end:t.end}),l=o.map((e,o)=>{const l=e.available?Z``:W;return Z` - ${l} - `}),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`
-
- + `}),c="none"===t.center?void 0:vd(o,t.center,e.hass),d=c?wd(c.text,c.unit,_t(e.hass,`parts_${t.center}`)):W,u=o.map((e,t)=>({index:t,entity:e,color:r[t]})),h=function(e,t){const o=e=>-Math.cos(e*Math.PI/180),a=360*Math.ceil((e-180)/360)+180<=t?1:Math.max(o(e),o(t));return Math.min(100,Math.max(66,50+48*a+6))}(t.start,t.end);return R`
+
+ ${l} -
${d}
+
${d}
- ${Is(h,e)} -
`}},xl={layout:"chart",group:"distribution",height:240,styles:Wr,thumbnail:()=>V` + ${bd(u,e)} +
`}},Qd={layout:"chart",group:"distribution",height:240,styles:Js,thumbnail:()=>Z` @@ -1762,7 +2358,7 @@ const et=pe(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de - `,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` + `,validate:e=>Ws(e,"scatter"),editor:ol("scatter"),entityFields:()=>rl(),disconnect:Ks,render:e=>Gs(e,"scatter")};function eu(e){const[t,o]=e;return Array.from({length:10},(e,a)=>{const n=(a+.5)/10;return n>t&&n<=o})}const tu={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 { @@ -1878,12 +2474,12 @@ const et=pe(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?$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`
+ `,render(e,{show:t,hass:o}){const a=e.available?eu(e.span):eu([0,0]),n=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``:W}
${t.name||r?R`
@@ -1891,11 +2487,11 @@ const et=pe(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de ${r?R`${e.display}`:W}
`:W}
- ${o.map((e,t)=>R``)} + ${a.map((e,t)=>R``)}
${t.value?R`${e.available?e.display:""}`:W} -
`}},kl={layout:"tiles",group:"bars",height:230,tilesPerRow:4,minColumnWidth:80,styles:r` +
`}},ou={layout:"tiles",group:"bars",height:230,tilesPerRow:4,minColumnWidth:80,styles:r` .style-tank .entity { display: flex; flex-direction: column; @@ -1957,12 +2553,12 @@ const et=pe(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})=>R` + `,render:(e,{show:t,hass:o})=>R` ${t.icon?R``:W} ${t.value||!e.available?R`${e.display}`:W}
@@ -1970,7 +2566,7 @@ const et=pe(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de ${t.target&&null!==e.targetPosition&&e.available?R`
`:W}
${t.name?R`${e.name}`:W} - `},Ml={layout:"rows",group:"bars",height:44,minColumnWidth:140,styles:r` + `},au={layout:"rows",group:"bars",height:44,minColumnWidth:140,styles:r` .style-thin .entity { display: flex; align-items: center; @@ -2036,38 +2632,38 @@ const et=pe(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de background: var(--primary-text-color); transition: left var(--cd-duration) var(--cd-easing); } - `,render(e,{show:t,hass:n}){const o=t.value||!e.available;return R` + `,render(e,{show:t,hass:o}){const a=t.value||!e.available;return R` ${t.icon?R``:W}
- ${t.name||o?R`
+ ${t.name||a?R`
${t.name?R`${e.name}`:W} - ${o?R`${e.display}`:W} + ${a?R`${e.display}`:W}
`:W}
${t.target&&null!==e.targetPosition&&e.available?R`
`:W}
- `}};nt("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 Al=["one","each"],Sl=["series","bands"];function El(e){if(1===e.length){const t=e[0];return t.available?[Ns([t.position,1-t.position])[0]]:[0]}return Ns(Os(e),100)}function Cl(e,t){const{col:n,row:o}=function(e){return{col:e%10,row:9-Math.floor(e/10)}}(e);return V``}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` + >`}function lu(e,t,o,a){let n=0;const r=e.map((e,a)=>{const r=n;if(n+=e,!e)return W;const i=o[a];return Z` + ${Array.from({length:e},(e,o)=>su(r+o,t[a]))} + `}),i=Array.from({length:100-n},(e,t)=>su(n+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} + `}const cu=[{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 du={layout:"chart",group:"totals",height:210,styles:r` + ${Ad} .waffle-box { flex: none; width: 180px; @@ -2154,38 +2750,3998 @@ const et=pe(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de color: var(--secondary-text-color); font-size: 0.75em; } - `,thumbnail:()=>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`
Z` + ${Array.from({length:25},(e,t)=>{const o=t%5,a=4-Math.floor(t/5);return Z``})}`,validate(e){$d(e,"waffles",nu),$d(e,"color_by",ru),Md(e,"headline"),Md(e,"show_percent")},editor:function(e){const t=Pi("waffle",e.hass,()=>[{type:"grid",name:"",schema:[{name:"waffles",selector:xi(e.hass,"waffles",nu)},{name:"color_by",selector:xi(e.hass,"color_by",ru)}]},{name:"headline",selector:{boolean:{}}},{name:"show_percent",selector:{boolean:{}}}]);return Di(e,"chart",t,cu)},render(e){const t={waffles:(o=e.raw).waffles??"one",colorBy:o.color_by??"series",headline:!1!==o.headline,percent:!1!==o.show_percent};var o;return"each"===t.waffles?function(e,t){const{show:o}=e.config;return R`
+ ${e.items.map((a,n)=>{const r=fd(a,n,e.items.length,t.colorBy),[i]=iu([a]),s=_d(i,e.hass),l=o.value&&t.percent&&a.available&&a.display!==s;return R`
- ${zl([i],[r],[o],o.label)} - ${n.name?R`${o.name}`:W} - ${o.available&&t.percent?s:o.display} - ${l?R`${o.display}`:W} + ${lu([i],[r],[a],a.label)} + ${o.name?R`${a.name}`:W} + ${a.available&&t.percent?s:a.display} + ${l?R`${a.display}`:W}
`})} -
`}(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)} +
`}(e,t):function(e,t){const o=e.items,a=o.map((e,a)=>fd(e,a,o.length,t.colorBy)),n=iu(o),r=e=>n[e]>0||1===o.length&&o[e].available,i=o.map((i,s)=>({index:s,entity:i,color:a[s],share:!t.percent||!r(s)||t.headline&&1===o.length?void 0:_d(n[s],e.hass)})),s=o[0],l=t.headline&&s&&r(0)?R`
+ ${_d(n[0],e.hass)} ${s.name}
`: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"]}}; +
${lu(n,a,o,xd(e,"editor_style_waffle"))}
+
${l}${bd(i,e)}
+
`}(e,t)}};function uu(e,t){return Ce(un[e%un.length])}function hu(e,t){const o=Math.round(Math.min(100,Math.max(0,t)));return o>=100?e:`color-mix(in srgb, ${e} ${o}%, var(--card-background-color, #fff))`}const pu=["funnel","pyramid"],mu=["first","previous","none"],fu=["series","bands"];function gu(e,t){const o=e=>Math.round(100*e)/100,a=o((100-e)/2),n=o((100-t)/2);return`${a},0 ${o(100-a)},0 ${o(100-n)},10 ${n},10`}yt("en",{editor_style_funnel:"Funnel",editor_funnel_shape:"Shape",editor_funnel_shape_funnel:"Funnel (first stage at the top)",editor_funnel_shape_pyramid:"Pyramid (first stage at the bottom)",editor_funnel_percent_of:"Percentages",editor_funnel_percent_of_first:"Share of the first stage",editor_funnel_percent_of_previous:"Share of the stage before",editor_funnel_percent_of_none:"Don't show",editor_funnel_color_by:"Colour",editor_funnel_color_by_series:"One colour per entity",editor_funnel_color_by_bands:"By colour band",funnel_aria:"{style} of {names}"});const vu=[{name:"funnel_shape",path:["shape"],default:"funnel"},{name:"funnel_percent_of",path:["percent_of"],default:"first"},{name:"funnel_color_by",path:["color_by"],default:"series"}];function yu(e,t,o){const a=e[t];if(void 0!==a&&!o.includes(a))throw new Error(`"${t}" must be one of: ${o.join(", ")}`)}const bu={layout:"chart",group:"totals",height:240,styles:r` + .style-funnel .funnel { + display: flex; + flex-direction: column; + gap: 4px; + padding: 4px 0; + } + .style-funnel .stage { + display: grid; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + align-items: center; + gap: 12px; + } + .style-funnel .funnel.no-labels .stage { + grid-template-columns: minmax(0, 1fr); + } + .style-funnel .shape { + position: relative; + height: 44px; + } + .style-funnel .shape svg { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + overflow: visible; + transform: scaleX(0); + transform-origin: 50% 50%; + transition: transform var(--cd-duration) var(--cd-easing); + } + :host([ready]) .style-funnel .shape svg { + transform: scaleX(1); + } + .style-funnel polygon.empty { + fill: none; + stroke: var(--secondary-text-color); + stroke-width: 1; + stroke-dasharray: 4 3; + vector-effect: non-scaling-stroke; + } + .style-funnel .percent { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + font-weight: 500; + color: #fff; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.35); + pointer-events: none; + } + .style-funnel .percent.dark { + color: #1d1f24; + text-shadow: none; + } + .style-funnel .percent.theme { + color: var(--primary-text-color); + text-shadow: none; + } + .style-funnel .label { + display: flex; + flex-direction: column; + min-width: 0; + line-height: 1.25; + } + .style-funnel .name { + color: var(--secondary-text-color); + font-size: 0.875em; + overflow: hidden; + overflow-wrap: anywhere; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + } + .style-funnel .value { + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .style-funnel .unavailable .value { + color: var(--secondary-text-color); + font-weight: 400; + } + `,thumbnail:()=>Z` + + + `,validate(e){yu(e,"shape",pu),yu(e,"percent_of",mu),yu(e,"color_by",fu)},editor:function(e){const t=Pi("funnel",e.hass,()=>[{name:"funnel_shape",selector:xi(e.hass,"funnel_shape",pu)},{type:"grid",name:"",schema:[{name:"funnel_percent_of",selector:xi(e.hass,"funnel_percent_of",mu)},{name:"funnel_color_by",selector:xi(e.hass,"funnel_color_by",fu)}]}]);return Di(e,"chart",t,vu)},render(e){const{items:t,raw:o,hass:a,config:n}=e,r=o.shape??"funnel",i="bands"===o.color_by,s=t.map(e=>e.available?e.value:null),l=function(e){const t=Math.max(0,...e.map(e=>null!==e&&e>0?e:0));return e.map(e=>t>0&&null!==e&&e>0?e/t*100:0)}(s),c=function(e,t){return e.map((o,a)=>{if(o<=0)return{top:0,bottom:0};const n=e[a+1],r=a===e.length-1;return"pyramid"===t?{top:r?0:n>0?n:.8*o,bottom:o}:{top:o,bottom:!r&&n>0?n:.8*o}})}(l,r),d=function(e,t){return"none"===t?e.map(()=>null):e.map((o,a)=>{if(null===o)return null;if("previous"===t&&0===a)return 100;const n="first"===t?e[0]:e[a-1];return null!=n&&n>0?o/n*100:null})}(s,o.percent_of??"first"),{show:u}=n,h=u.name||u.value,p=t.map((e,t)=>{const o=function(e,t,o){if(o)return e.band.color;const a=e.config.color;return"string"==typeof a&&a?a:un[t%un.length]}(e,t,i),n=d[t],r=null===n?"":`${kn(n,0,a)}%`,s=l[t]<22?"theme":Le(o),p=Ae(e.config),{top:m,bottom:f}=c[t],g=l[t]>0;return R``});"pyramid"===r&&p.reverse();const m=_t(a,"funnel_aria",{style:_t(a,`editor_funnel_shape_${r}`).replace(/ \(.*\)$/,""),names:t.map(e=>e.name).join(", ")});return R`
${p}
`}},_u=["floor","area","device","label"];function xu(e){if(null==e||""===e)return[];const t=Array.isArray(e)?e:[e];if(t.some(e=>!_u.includes(e)))throw new Error(`"group_by" must be one or more of: ${_u.join(", ")}`);return _u.filter(e=>t.includes(e))}function wu(e){xu(e.group_by),(e.entities??[]).forEach((e,t)=>{const o="object"==typeof e&&e?e.group:void 0;if(null!=o&&"string"!=typeof o)throw new Error(`Entity ${t+1}: "group" must be text, such as "Kitchen" or "Ground floor / Kitchen"`)})}function $u(e){return"string"!=typeof e?[]:e.split("/").map(e=>e.trim()).filter(Boolean)}function ku(e){const t=e.replace(/_/g," ").trim();return t.charAt(0).toUpperCase()+t.slice(1)}function Mu(e,t,o,a){const n=$u(e.config.group);return n.length?n:t.map(t=>function(e,t,o){const a=o,n=a.entities?.[e],r=n?.device_id?a.devices?.[n.device_id]:void 0,i=n?.area_id??r?.area_id??void 0;switch(t){case"area":return i?a.areas?.[i]?.name??ku(i):void 0;case"floor":{const e=i?a.areas?.[i]?.floor_id:void 0;return e?a.floors?.[e]?.name??ku(e):void 0}case"device":return r?r.name_by_user??r.name??void 0:void 0;case"label":{const e=[...n?.labels??[],...r?.labels??[]][0];return e?a.labels?.[e]?.name??ku(e):void 0}}}(e.entityId,t,o)??a)}function zu(e,t,o){const a={name:o,path:[],value:0,depth:0,children:[]};e.forEach((e,o)=>{let n=a;for(const a of t[o]??[]){let e=n.children.find(e=>void 0===e.entity&&e.name===a);e||(e={name:a,path:[...n.path,a],value:0,depth:n.depth+1,children:[]},n.children.push(e)),n=e}n.children.push({name:e.name,path:n.path,value:0,depth:n.depth+1,children:[],entity:e,index:o})});const n=e=>{e.value=e.entity?function(e){return e.available&&null!==e.value&&e.value>0?e.value:0}(e.entity):e.children.reduce((e,t)=>e+n(t),0);const t=new Map(e.children.map((e,t)=>[e,t]));return e.children.sort((e,o)=>o.value-e.value||t.get(e)-t.get(o)),e.value};return n(a),a}function Au(e,t){let o=e;for(const a of t)if(o=o.children.find(e=>void 0===e.entity&&e.name===a),!o)return;return o}function Su(e){return e.children.length?1+Math.max(...e.children.map(Su)):0}function Eu(e){const t=[],o=(e,a,n,r)=>{if(t.push({node:e,level:a,x0:n,x1:r}),e.value<=0)return;let i=n;for(const t of e.children){if(t.value<=0)continue;const s=(r-n)*t.value/e.value;o(t,a+1,i,i+s),i+=s}};return o(e,0,0,1),t}const Cu=2*Math.PI;function ju(e,t){return[Nu(e*Math.sin(t)),Nu(-e*Math.cos(t))]}function Nu(e){return Math.round(100*e)/100||0}function Lu(e,t,o,a){if(t<=0||a<=o)return"";const n=Math.max(0,e);if(a-o>=Cu-1e-6){const e=(e,t)=>{const[o,a]=ju(e,0),[n,r]=ju(e,Math.PI);return`M${o} ${a}A${e} ${e} 0 1 ${t} ${n} ${r}A${e} ${e} 0 1 ${t} ${o} ${a}Z`};return n>0?`${e(t,1)}${e(n,0)}`:e(t,1)}const r=a-o>Math.PI?1:0,[i,s]=ju(t,o),[l,c]=ju(t,a),d=`M${i} ${s}A${t} ${t} 0 ${r} 1 ${l} ${c}`;if(0===n)return`${d}L0 0Z`;const[u,h]=ju(n,a),[p,m]=ju(n,o);return`${d}L${u} ${h}A${n} ${n} 0 ${r} 0 ${p} ${m}Z`}function Tu(e,t){return t>0?e*Cu/t:0}function Ou(e){return e.map(([e,t])=>`${e},${t}`).join(" ")}function Fu(e){const t=Math.sin(e);return Math.abs(t)<.2?"middle":t>0?"start":"end"}function Pu(e,t,o,a,n=void 0){const r=e.config.entities??[],i=r.length,s=Pi(`${t}-${i}`,e.hass,()=>r.map((e,t)=>({name:`e${t}`,selector:a}))),l=e=>{const t=r[e];return"object"==typeof t?t[o]:void 0},c={};for(let d=0;d +
${_t(e.hass,`editor_${t}`)}
+ ${0===i?R`
+ ${_t(e.hass,"editor_entity_options_none")} +
`:R`{const o=Mt(r[Number(t.name.slice(1))]),a=e.hass?.states[o]?.attributes?.friendly_name;return"string"==typeof a&&a?a:o}} + @value-changed=${t=>{t.stopPropagation();const a=t.detail?.value??{};let r=e.config;for(let e=0;e`} +
`}yt("en",{editor_entity_options_none:"Add an entity above first."}),yt("en",{editor_hierarchy_group_by:"Group by",editor_group_by_floor:"Floor",editor_group_by_area:"Area",editor_group_by_device:"Device",editor_group_by_label:"Label",editor_entity_groups:'Own group (wins over "Group by"; "Ground floor / Kitchen" for two levels)'});const Du=[{name:"group_by",path:["group_by"],default:[]}];function Uu(e){return{name:"group_by",selector:{select:{multiple:!0,mode:"list",options:_u.map(t=>({value:t,label:_t(e,`editor_group_by_${t}`)}))}}}}function Hu(e){const t=e.config.group_by;if(void 0===t||Array.isArray(t))return e;let o;try{o=xu(t)}catch{return e}return{...e,config:{...e.config,group_by:o}}}function Iu(e){return Pu(e,"entity_groups","group",{text:{}})}const Bu={group_by:"editor_hierarchy_group_by"};yt("en",{editor_style_radar:"Radar",editor_radar_scale:"Scale",editor_radar_scale_limits:"Each axis from its entity's minimum to maximum",editor_radar_scale_shared:"One scale for all, from 0 to the largest value",editor_radar_fill:"Fill the shapes",editor_radar_axis:"Axis name (entities with the same axis name line up across groups)",radar_aria:"Radar chart of {names}",radar_other:"Other"});const Ru=["limits","shared"],Zu={aqi:"AQI",carbon_dioxide:"CO₂",carbon_monoxide:"CO",pm1:"PM1",pm10:"PM10",pm25:"PM2.5",volatile_organic_compounds:"VOC",volatile_organic_compounds_parts:"VOC"};function qu(e,t,o){return e.available&&null!==e.value?"limits"===t?e.position:o>0?Math.min(1,Math.max(0,e.value/o)):0:null}function Wu(e,t,o){const a=xu(t.group_by),n=a.length>0||e.some(e=>$u(e.config.group).length),r=t.scale??"limits",i=e.filter(e=>e.available&&null!==e.value).map(e=>e.value),s=i.length?Math.max(...i):0,l=e=>{const t=e.config.axis;return"string"==typeof t&&t.trim()?t.trim():void 0};if(!n)return{axes:e.map(e=>({name:l(e)??e.name,entity:e})),series:[{name:"",color:uu(0),points:e.map((e,t)=>({index:t,axis:t,r:qu(e,r,s)}))}]};const c=_t(o,"radar_other"),d=[],u=[];return e.forEach((e,t)=>{const n=e.stateObj?.attributes?.device_class,i=l(e)??("string"==typeof n?function(e){if(Zu[e])return Zu[e];const t=e.replace(/_/g," ");return t.charAt(0).toUpperCase()+t.slice(1)}(n):e.name);let h=d.findIndex(e=>e.name===i);-1===h&&(h=d.push({name:i})-1);const p=Mu(e,a,o,c).join(" / ")||c;let m=u.find(e=>e.name===p);m||(m={name:p,color:uu(u.length),points:[]},u.push(m)),m.points.push({index:t,axis:h,r:qu(e,r,s)})}),{axes:d,series:u}}const Vu=[{name:"radar_scale",path:["scale"],default:"limits"},{name:"radar_fill",path:["fill"],default:!0},...Du];const Yu={layout:"chart",group:"compare",height:280,styles:r` + .radar-plot { + display: block; + width: 100%; + height: 240px; + overflow: visible; + } + .radar-plot .grid { + fill: none; + stroke: var(--divider-color, rgba(127, 127, 127, 0.25)); + stroke-width: 1; + } + .radar-plot .axis-label { + fill: var(--secondary-text-color); + font-size: 12px; + } + .radar-plot .axis-value { + fill: var(--primary-text-color); + font-weight: 500; + } + .radar-plot .axis-value.muted { + fill: var(--secondary-text-color); + font-weight: 400; + } + .radar-plot .area { + stroke-width: 2; + stroke-linejoin: round; + fill-opacity: 0; + } + .radar-plot .area.filled { + fill-opacity: 0.2; + } + .radar-plot .dot { + fill: var(--card-background-color, #fff); + stroke-width: 2; + } + .radar-plot .hit { + fill: transparent; + } + .radar-plot .marker { + cursor: pointer; + outline: none; + } + .radar-plot .marker:focus-visible .dot { + stroke-width: 4; + } + .radar-grow { + transform-box: fill-box; + transform-origin: center; + transform: scale(0); + transition: transform var(--cd-duration) var(--cd-easing); + } + :host([ready]) .radar-grow { + transform: scale(1); + } + :host([static]) .radar-grow { + transition: none; + } + .radar-legend { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 4px 16px; + padding-top: 4px; + font-size: 0.875em; + color: var(--secondary-text-color); + } + .radar-legend-item { + display: flex; + align-items: center; + gap: 6px; + } + .radar-legend .swatch { + width: 10px; + height: 10px; + border-radius: 50%; + } + `,thumbnail:()=>Z` + + `,validate(e){if(wu(e),void 0!==e.scale&&!Ru.includes(e.scale))throw new Error(`"scale" must be one of: ${Ru.join(", ")}`);if(void 0!==e.fill&&"boolean"!=typeof e.fill)throw new Error('"fill" must be true or false');(e.entities??[]).forEach((e,t)=>{const o="object"==typeof e&&e?e.axis:void 0;if(null!=o&&"string"!=typeof o)throw new Error(`Entity ${t+1}: "axis" must be text`)})},editor:function(e){const t=Pi("radar",e.hass,()=>[{name:"radar_scale",selector:xi(e.hass,"radar_scale",Ru)},{name:"radar_fill",selector:{boolean:{}}},Uu(e.hass)]);return R`${Di(Hu(e),"chart",t,Vu,Bu)} ${Iu(e)} + ${Pu(e,"radar_axis","axis",{text:{}})}`},render(e){const t=Wu(e.items,e.raw,e.hass),o=t.axes.length,a=1===t.series.length&&""===t.series[0].name,n=!1!==e.raw.fill,r=_t(e.hass,"radar_aria",{names:(a?e.items.map(e=>e.name):t.series.map(e=>e.name)).join(", ")});return R` + ${function(e){const t=Array.from({length:4},(e,t)=>84*(t+1)/4);return Z`${t.map(t=>e>=3?Z`ju(t,Tu(a,e))))}>`:Z``)} + ${Array.from({length:e},(t,o)=>{const[a,n]=ju(84,Tu(o,e));return Z``})}`}(o)} ${function(e,t,o){const a=e.axes.length,n=o&&t.config.show.value;return Z`${e.axes.map((e,o)=>{const r=Tu(o,a),[i,s]=ju(96,r),l=Fu(r),c=Math.cos(r)<-.2,d=n&&e.entity?2:1,u=c?10:Math.abs(Math.cos(r))<=.2?4-6*(d-1):12*-(d-1);return Z` + ${t.config.show.name||!n?Z`${e.name}`:W} + ${n&&e.entity?Z`${e.entity.display}`:W} + `})}`}(t,e,a)} + + + ${t.series.map(t=>function(e,t,o,a){const n=e.points.filter(e=>null!==e.r).sort((e,t)=>e.axis-t.axis).map(e=>({p:e,xy:ju(84*e.r,Tu(e.axis,t))}));return Z` + ${n.length>1?Z`e.xy))} + stroke=${e.color} fill=${a?e.color:"none"}>`:W} + ${n.map(({p:t,xy:a})=>Z` + + + `)} + `}(t,o,e.items,n))} + + + ${a?W:R`
+ ${t.series.map(e=>R`${e.name}`)} +
`}`}};yt("en",{editor_style_polar_area:"Polar area",editor_polar_scale:"Scale",editor_polar_scale_shared:"One scale for all, from 0 to the largest value",editor_polar_scale_limits:"Each slice from its entity's minimum to maximum",editor_polar_color_by:"Colour",editor_polar_color_by_series:"One colour per entity",editor_polar_color_by_shades:"Shades of one colour, darker for more",editor_polar_color_by_bands:"By colour band",editor_polar_color:"Colour for shades",polar_area_aria:"Polar area chart of {names}"});const Gu=["shared","limits"],Xu=["series","shades","bands"];const Ju=[{name:"polar_scale",path:["scale"],default:"shared"},{name:"polar_color_by",path:["color_by"],default:"series"},{name:"polar_color",path:["color"],default:"primary"}],Ku=Object.keys(Ee);const Qu={layout:"chart",group:"compare",height:260,styles:r` + .polar-plot { + display: block; + width: 100%; + height: 240px; + overflow: visible; + } + .polar-plot .ring { + fill: none; + stroke: var(--divider-color, rgba(127, 127, 127, 0.25)); + stroke-dasharray: 3 3; + } + .polar-plot .ring:last-of-type { + stroke-dasharray: none; + } + .polar-plot .ring-label { + fill: var(--secondary-text-color); + font-size: 10px; + paint-order: stroke; + stroke: var(--card-background-color, #fff); + stroke-width: 3px; + stroke-linejoin: round; + } + .polar-plot .slice-label { + fill: var(--secondary-text-color); + font-size: 12px; + } + .polar-plot .slice-value { + fill: var(--primary-text-color); + font-weight: 500; + } + .polar-plot .slice-label.muted .slice-value { + fill: var(--secondary-text-color); + font-weight: 400; + } + .polar-plot .slice { + cursor: pointer; + outline: none; + stroke: var(--card-background-color, #fff); + stroke-width: 0.5; + } + .polar-plot .slice:focus-visible { + stroke: var(--primary-text-color); + stroke-width: 2; + } + .polar-grow { + transform-box: fill-box; + transform-origin: center; + transform: scale(0); + transition: transform var(--cd-duration) var(--cd-easing); + } + :host([ready]) .polar-grow { + transform: scale(1); + } + :host([static]) .polar-grow { + transition: none; + } + `,thumbnail:()=>Z` + + + + + + + `,validate(e){if(void 0!==e.scale&&!Gu.includes(e.scale))throw new Error(`"scale" must be one of: ${Gu.join(", ")}`);if(void 0!==e.color_by&&!Xu.includes(e.color_by))throw new Error(`"color_by" must be one of: ${Xu.join(", ")}`);if(void 0!==e.color&&("string"!=typeof e.color||!e.color))throw new Error('"color" must be a colour name, such as "primary", or any CSS colour')},editor:function(e){const t=Pi("polar_area",e.hass,()=>[{name:"polar_scale",selector:xi(e.hass,"polar_scale",Gu)},{type:"grid",name:"",schema:[{name:"polar_color_by",selector:xi(e.hass,"polar_color_by",Xu)},{name:"polar_color",selector:{select:{mode:"dropdown",custom_value:!0,options:Ku.map(t=>({value:t,label:_t(e.hass,`editor_color_${t}`)}))}}}]}]);return Di(e,"chart",t,Ju)},render(e){const t=e.raw.scale??"shared",o=e.raw.color_by??"series",a=e.raw.color??"primary",n=e.items.length,r=e.items.filter(e=>e.available&&null!==e.value).map(e=>e.value),i=r.length?Math.max(0,...r):0,s=e.items.map((e,r)=>{const s=function(e,t,o){return e.available&&null!==e.value?"limits"===t?e.position:o>0?Math.min(1,Math.max(0,e.value/o)):0:null}(e,t,i);if(null===s||s<=0)return W;const[l,c]=function(e,t){if(t<=1)return[0,2*Math.PI];const o=Math.PI/t,a=Tu(e,t);return[a-o+.02,a+o-.02]}(r,n),d=6+86*s;return Z``}),l=_t(e.hass,"polar_area_aria",{names:e.items.map(e=>e.name).join(", ")});return R` + ${Z`${Array.from({length:3},(e,t)=>Z``)}`} + + + ${s} + + ${function(e,t,o){const a=e.items.find(e=>e.available);if("shared"!==t||!a||o<=0)return Z``;const n=e.items.length>1?Math.PI/e.items.length:0;return Z`${Array.from({length:3},(t,r)=>{const i=92*(r+1)/3,[s,l]=ju(i,n),c=Mn(kn(o*(r+1)/3,a.decimals,e.hass),a.unit);return Z`${c}`})}`}(e,t,i)} ${function(e){const t=e.items.length,{show:o}=e.config;return o.name||o.value?Z`${e.items.map((e,a)=>{const n=Tu(a,t),[r,i]=ju(104,n),s=Math.cos(n)<-.2,l=o.name&&o.value?2:1,c=s?10:Math.abs(Math.cos(n))<=.2?4-6*(l-1):13*-(l-1);return Z` + ${o.name?Z`${e.name}`:W} + ${o.value?Z`${e.display}`:W} + `})}`:Z``}(e)} + `}};yt("en",{editor_drill_down:"Tap a group to show what makes it up",drill_into:"{label}. Show what makes it up",drill_back:"Back to {name}",drill_top:"All"});const eh=new WeakMap;function th(e){return!1!==e.drill_down}function oh(e){if(void 0!==e.drill_down&&"boolean"!=typeof e.drill_down)throw new Error('"drill_down" must be true or false')}function ah(e,t){eh.set(e.host,t),e.requestUpdate()}function nh(e,t){return th(e.raw)?function(e,t){let o=e;const a=[];for(const n of t){const e=Au(o,[n]);if(!e||e.value<=0)break;o=e,a.push(n)}return{node:o,path:a}}(t,(o=e.host,eh.get(o)??[])):{node:t,path:[]};var o}function rh(e,t,o){if(!th(e.raw)||t.entity||t.value<=0)return;const a=()=>ah(e,t.path);return{label:_t(e.hass,"drill_into",{label:o}),click:e=>{e.stopPropagation(),a()},keydown:e=>{"Enter"!==e.key&&" "!==e.key||(e.preventDefault(),a())}}}function ih(e,t,o,a){if(!o.path.length)return W;const n=o.path.slice(0,-1),r=n.length&&Au(t,n)?.name||_t(e.hass,"drill_top");return R`
+ +
+ ${o.node.name} + ${a?R`${a}`:W} +
+
`}const sh=r` + .drill-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding-bottom: 8px; + min-width: 0; + } + .drill-back { + display: inline-flex; + align-items: center; + gap: 2px; + min-width: 0; + max-width: 55%; + padding: 4px 12px 4px 6px; + border: none; + border-radius: 16px; + background: color-mix(in srgb, var(--primary-color, #03a9f4) 12%, transparent); + color: var(--primary-color, #03a9f4); + font: inherit; + font-size: 0.875em; + font-weight: 500; + cursor: pointer; + } + .drill-back span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .drill-back:focus-visible { + outline: 2px solid var(--primary-color, #03a9f4); + outline-offset: 2px; + } + .drill-current { + display: flex; + flex-direction: column; + align-items: flex-end; + min-width: 0; + line-height: 1.2; + } + .drill-name { + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 100%; + } + .drill-total { + color: var(--secondary-text-color); + font-size: 0.875em; + } + .drillable { + cursor: pointer; + outline: none; + } +`;function lh(e,t){const o=e.reduce((e,t)=>e+t,0);if(o<=0||t<=0)return 1/0;const a=Math.max(...e),n=Math.min(...e),r=t*t;return Math.max(r*a/(o*o),o*o/(r*n))}function ch(e,t){const o=Math.min(t,e.w/2,e.h/2);return{x:e.x+o,y:e.y+o,w:e.w-2*o,h:e.h-2*o}}yt("en",{editor_style_treemap:"Treemap",editor_treemap_color_by:"Colour",editor_treemap_color_by_group:"One colour per group",editor_treemap_color_by_bands:"By colour band",treemap_aria:"Treemap of {names}",treemap_total:"{value} in all",treemap_empty:"Nothing to show: no entity has a value above zero.",treemap_no_reading:"No reading:",treemap_other:"Other"});const dh=["group","bands"],uh=240;function hh(e,t,o,a){const n=[],r=[],i=(e,t,o,s)=>{const l=e.children.filter(e=>e.value>0),c=function(e,t){const o=e.map(()=>({x:t.x,y:t.y,w:0,h:0})),a=e.map((e,t)=>({v:Number.isFinite(e)&&e>0?e:0,i:t})).filter(e=>e.v>0).sort((e,t)=>t.v-e.v||e.i-t.i),n=a.reduce((e,t)=>e+t.v,0);if(n<=0||t.w<=0||t.h<=0)return o;const r=t.w*t.h/n;let{x:i,y:s,w:l,h:c}=t,d=[];const u=()=>{const e=d.reduce((e,t)=>e+t.area,0);if(l>=c){const t=e/c;let a=s;for(const e of d){const n=e.area/t;o[e.i]={x:i,y:a,w:t,h:n},a+=n}i+=t,l-=t}else{const t=e/l;let a=i;for(const e of d){const n=e.area/t;o[e.i]={x:a,y:s,w:n,h:t},a+=n}s+=t,c-=t}d=[]};for(const h of a){const e=h.v*r,t=Math.min(l,c),o=d.map(e=>e.area);0===d.length||lh([...o,e],t)<=lh(o,t)||u(),d.push({area:e,i:h.i})}return d.length&&u(),o}(l.map(e=>e.value),t);l.forEach((e,t)=>{const l=c[t],d=s&&void 0===a?t:o;if(e.entity)return void r.push({node:e,rect:ch(l,1.5),series:d,rank:t});const u=ch(l,1.5),h=u.h>=44&&u.w>=56;n.push({node:e,rect:u,series:d,head:h});const p=ch(h?{...u,y:u.y+22-3,h:u.h-22+3}:u,3);i(e,p,d,!1)})};return i(e,{x:0,y:0,w:t,h:o},a??0,!0),{groups:n,leaves:r}}function ph(e,t,o){const a=t.items.find(e=>e.available)?.decimals??1;return Mn(kn(e,a,t.hass),o)}const mh=new WeakMap,fh=new WeakMap;const gh=(e,t)=>e/t*100+"%";function vh(e,t){return`left:${gh(e.x,t)};top:${gh(e.y,uh)};width:${gh(e.w,t)};height:${gh(e.h,uh)}`}function yh(e,t,o,a,n){const r=e.node.entity,i=function(e,t){const o=e.node.entity;if("bands"===t)return o.band.color;const a=o.config.color;return"string"==typeof a&&a?a:un[e.series%un.length]}(e,a),s="group"===a&&e.node.path.length?hu(Ce(i),Math.max(70,100-12*(e.rank??0))):Ce(i),l=Le(i),{w:c,h:d}=e.rect,{show:u}=t.config,h=c>=120&&d>=70&&null!==r.value&&r.value>=.25*n,p=d>=20&&c>=44,m=u.value&&d>=36;return R``}const bh=[{name:"treemap_color_by",path:["color_by"],default:"group"},...Du,{name:"drill_down",path:["drill_down"],default:!0}];const _h={layout:"chart",group:"totals",height:280,styles:r` + ${sh} + .treemap-head { + display: flex; + justify-content: flex-end; + align-items: baseline; + gap: 8px; + min-height: 20px; + padding-bottom: 6px; + color: var(--secondary-text-color); + font-size: 0.875em; + } + .treemap { + position: relative; + height: ${uh}px; + } + .treemap .group, + .treemap .tile { + position: absolute; + box-sizing: border-box; + border-radius: 6px; + overflow: hidden; + } + .treemap .group-head { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 6px; + height: ${22}px; + padding: 3px 8px 0; + box-sizing: border-box; + font-size: 12px; + white-space: nowrap; + } + .treemap .group-name { + font-weight: 500; + color: var(--primary-text-color); + overflow: hidden; + text-overflow: ellipsis; + } + .treemap .group-total { + color: var(--secondary-text-color); + } + .treemap .tile { + display: flex; + flex-direction: column; + padding: 5px 8px; + gap: 1px; + cursor: pointer; + outline: none; + opacity: 0; + transform: scale(0.96); + transition: + opacity var(--cd-duration) var(--cd-easing), + transform var(--cd-duration) var(--cd-easing); + } + :host([ready]) .treemap .tile { + opacity: 1; + transform: none; + } + :host([static]) .treemap .tile { + transition: none; + } + .treemap .tile:focus-visible { + box-shadow: inset 0 0 0 2px var(--primary-text-color); + } + .treemap .tile.text-light { + color: #fff; + } + .treemap .tile.text-dark { + color: rgba(0, 0, 0, 0.87); + } + .treemap .tile.text-theme { + color: var(--primary-text-color); + } + .treemap .tile-name, + .treemap .tile-value { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + line-height: 1.25; + } + .treemap .tile-name { + font-size: 13px; + font-weight: 500; + } + .treemap .tile-value { + font-size: 12px; + opacity: 0.9; + } + .treemap .tile.big .tile-value { + font-size: 22px; + font-weight: 500; + opacity: 1; + } + .treemap-message { + display: flex; + align-items: center; + justify-content: center; + height: ${uh}px; + color: var(--secondary-text-color); + font-size: 0.875em; + text-align: center; + } + .treemap-missing { + display: flex; + flex-wrap: wrap; + gap: 4px 12px; + padding-top: 8px; + font-size: 0.875em; + color: var(--secondary-text-color); + } + .treemap-missing .entity { + cursor: pointer; + border-radius: 4px; + } + .treemap-missing .entity:focus-visible { + outline: 2px solid var(--primary-color); + } + `,thumbnail:()=>Z` + + + + `,validate(e){if(wu(e),oh(e),void 0!==e.color_by&&!dh.includes(e.color_by))throw new Error(`"color_by" must be one of: ${dh.join(", ")}`)},editor:function(e){const t=Pi("treemap",e.hass,()=>[{name:"treemap_color_by",selector:xi(e.hass,"treemap_color_by",dh)},Uu(e.hass),{name:"drill_down",selector:{boolean:{}}}]);return R`${Di(Hu(e),"chart",t,bh,Bu)} ${Iu(e)}`},disconnect:function(e){fh.get(e)?.disconnect(),fh.delete(e)},render(e){const t=function(e){const t=e.host;if(!fh.has(t)&&"function"==typeof ResizeObserver){const o=new ResizeObserver(()=>{const o=t.shadowRoot?.querySelector(".treemap"),a=o?.clientWidth??0;a>0&&Math.abs(a-(mh.get(t)??0))>=1&&(mh.set(t,a),e.requestUpdate())});o.observe(t),fh.set(t,o)}return mh.get(t)??400}(e),o=function(e){const t=xu(e.raw.group_by),o=_t(e.hass,"treemap_other"),a=e.items.map(a=>Mu(a,t,e.hass,o));return zu(e.items,a,"")}(e),a=nh(e,o),n=a.node,r=e.raw.color_by??"group",i=function(e){const t=new Set(e.filter(e=>e.available).map(e=>e.unit));return 1===t.size?[...t][0]:void 0}(e.items),s=a.path.length?o.children.filter(e=>e.value>0).findIndex(e=>e.name===a.path[0]):void 0,{groups:l,leaves:c}=hh(n,t,uh,s),d=e.items.map((e,t)=>({e:e,index:t})).filter(({e:e})=>!e.available),u=void 0!==i&&e.config.show.value&&n.value>0?ph(n.value,e,i):"",h=u&&!a.path.length?_t(e.hass,"treemap_total",{value:u}):"",p=_t(e.hass,"treemap_aria",{names:(n.children.length?n.children:[]).map(e=>e.name).join(", ")}),m=c.length?R``:R`
${_t(e.hass,"treemap_empty")}
`;return R`${ih(e,o,a,u)} + ${h?R`
${h}
`:W} ${m} + ${d.length?R`
+ ${_t(e.hass,"treemap_no_reading")} + ${d.map(({e:e,index:t})=>R`${e.name} (${e.display})`)} +
`:W}`}};yt("en",{editor_style_sunburst:"Sunburst",editor_style_icicle:"Icicle",editor_sunburst_shape:"Shape",editor_sunburst_shape_full:"Full circle",editor_sunburst_shape_half:"Half circle",editor_hierarchy_color_by:"Colour",editor_hierarchy_color_by_group:"One colour per group",editor_hierarchy_color_by_bands:"By colour band",hierarchy_total:"Total",hierarchy_other:"Other",hierarchy_empty:"Nothing to show: no entity has a value above zero.",hierarchy_no_reading:"No reading:",sunburst_aria:"Sunburst of {names}",icicle_aria:"Icicle chart of {names}"});const xh=["full","half"],wh=["group","bands"],$h=240;function kh(e){return Math.max(45,100-20*(e-1))}function Mh(e){const t=xu(e.raw.group_by),o=_t(e.hass,"hierarchy_other"),a=e.items.map(a=>Mu(a,t,e.hass,o));return zu(e.items,a,_t(e.hass,"hierarchy_total"))}function zh(e){const t=new Set(e.filter(e=>e.available).map(e=>e.unit));return 1===t.size?[...t][0]:void 0}function Ah(e,t){const o=new Map,a=new Map;let n=0;for(const{node:r,level:i}of e){if(0===i)continue;const e=r.entity?r.entity.config.color:void 0;let s;s=1===i?"string"==typeof e&&e?e:un[n++%un.length]:a.get(r)??"grey";for(const t of r.children)a.set(t,s);if("bands"===t){o.set(r,r.entity?{fill:Ce(r.entity.band.color),text:Le(r.entity.band.color)}:{fill:"var(--cd-track)",text:"theme"});continue}const l=kh(i);o.set(r,{fill:hu(Ce(s),l),text:l>=100?Le(s):"theme"})}return o}function Sh(e,t,o){const a=t.items.find(e=>e.available)?.decimals??1;return Mn(kn(e,a,t.hass),o)}function Eh(e,t,o){return e.entity?e.entity.label:void 0===o?e.name:`${e.name}, ${Sh(e.value,t,o)}`}function Ch(e){const t=e.items.map((e,t)=>({e:e,index:t})).filter(({e:e})=>!e.available);return t.length?R`
+ ${_t(e.hass,"hierarchy_no_reading")} + ${t.map(({e:e,index:t})=>R`${e.name} (${e.display})`)} +
`:W}const jh=[{name:"hierarchy_color_by",path:["color_by"],default:"group"},...Du,{name:"drill_down",path:["drill_down"],default:!0}],Nh=[{name:"sunburst_shape",path:["shape"],default:"full"},...jh];function Lh(e,t){if(wu(e),oh(e),void 0!==e.color_by&&!wh.includes(e.color_by))throw new Error(`"color_by" must be one of: ${wh.join(", ")}`);if(t&&void 0!==e.shape&&!xh.includes(e.shape))throw new Error(`"shape" must be one of: ${xh.join(", ")}`)}function Th(e,t,o=t){if(t.value<=0)return R`
${_t(e.hass,"hierarchy_empty")}
`;const a=e.raw.shape??"full",n=e.raw.color_by??"group",r=zh(e.items),i=Eu(t),s=Ah(Eu(o),n),l=function(e,t){const o=72/Math.max(1,t);return{start:"half"===e?-Math.PI/2:0,sweep:"half"===e?Math.PI:2*Math.PI,radii:e=>[40+(e-1)*o,40+e*o],viewBox:"half"===e?"-116 -116 232 124":"-116 -116 232 232"}}(a,Su(t)),c=i.filter(e=>e.level>0).map(t=>{const[o,a]=l.radii(t.level),n=Lu(o,a,l.start+t.x0*l.sweep,l.start+t.x1*l.sweep),i=Eh(t.node,e,r),c=s.get(t.node).fill;return t.node.entity?Z`${i}`:function(e,t,o,a,n){const r=rh(e,t,o);return Z`${o}`}(e,t.node,i,n,c)}),d=void 0!==r&&e.config.show.value?Sh(t.value,e,r):"",u=t.children.filter(e=>e.value>0).map(e=>e.name);return R`
+ + ${c} + +
${d?R`${d}`:W}
+
`}function Oh(e,t,o=t){const a=e.raw.color_by??"group",n=zh(e.items),r=Ah(Eu(o),a),i=t.children.filter(e=>e.value>0);return R`
+ ${i.map(t=>{const o=R``,a=e.config.show.value&&void 0!==n?R`${Sh(t.value,e,n)}`:W;return t.entity?R`${o}${t.name} ${a}`:function(e,t,o,a,n){const r=rh(e,t,Eh(t,e,n));return R`${o}${t.name} ${a}`}(e,t,o,a,n)})} +
`}function Fh(e,t,o=t){if(t.value<=0)return R`
${_t(e.hass,"hierarchy_empty")}
`;const a=e.raw.color_by??"group",n=zh(e.items),r=Eu(t),i=Ah(Eu(o),a),s=Su(t)+1,{show:l}=e.config,c=t.children.filter(e=>e.value>0).map(e=>e.name);return R``}const Ph=r` + ${sh} + .hierarchy-message { + display: flex; + align-items: center; + justify-content: center; + height: ${$h}px; + color: var(--secondary-text-color); + font-size: 0.875em; + text-align: center; + } + .hierarchy-missing, + .hierarchy-legend { + display: flex; + flex-wrap: wrap; + gap: 4px 14px; + padding-top: 8px; + font-size: 0.875em; + color: var(--secondary-text-color); + } + .hierarchy-legend { + justify-content: center; + } + .hierarchy-legend .legend-item { + display: inline-flex; + align-items: center; + gap: 6px; + } + .hierarchy-legend .legend-value { + color: var(--primary-text-color); + font-weight: 500; + } + .hierarchy-legend .swatch { + width: 10px; + height: 10px; + border-radius: 50%; + } + .hierarchy-missing .entity, + .hierarchy-legend .entity { + cursor: pointer; + border-radius: 4px; + } + .hierarchy-missing .entity:focus-visible, + .hierarchy-legend .entity:focus-visible { + outline: 2px solid var(--primary-color); + } +`,Dh={layout:"chart",group:"totals",height:290,styles:r` + ${Ph} + .sunburst-wrap { + position: relative; + height: ${$h}px; + } + .sunburst-wrap.half { + height: ${144}px; + } + .sunburst { + display: block; + width: 100%; + height: 100%; + overflow: visible; + } + .sunburst .arc { + stroke: var(--card-background-color, #fff); + stroke-width: 1.5; + stroke-linejoin: round; + } + .sunburst .arc.leaf { + cursor: pointer; + outline: none; + } + .sunburst .arc.leaf:focus-visible { + stroke: var(--primary-text-color); + stroke-width: 2.5; + } + .sunburst-grow { + transform: scale(0.6) rotate(-30deg); + opacity: 0; + transition: + transform var(--cd-duration) var(--cd-easing), + opacity var(--cd-duration) var(--cd-easing); + } + :host([ready]) .sunburst-grow { + transform: none; + opacity: 1; + } + :host([static]) .sunburst-grow { + transition: none; + } + .sunburst-centre { + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + display: flex; + flex-direction: column; + align-items: center; + max-width: 72px; + text-align: center; + line-height: 1.2; + pointer-events: none; + } + .sunburst-wrap.half .sunburst-centre { + top: auto; + bottom: 4px; + transform: translateX(-50%); + } + .sunburst-centre > * { + pointer-events: auto; + } + .centre-value { + font-size: 1.15em; + font-weight: 500; + /* A long total ("339.4 kWh") wraps its unit under the number rather than over the rings. */ + overflow-wrap: normal; + } + `,thumbnail:()=>Z` + + + + + + `,validate:e=>Lh(e,!0),editor:function(e){const t=Pi("sunburst",e.hass,()=>[{type:"grid",name:"",schema:[{name:"sunburst_shape",selector:xi(e.hass,"sunburst_shape",xh)},{name:"hierarchy_color_by",selector:xi(e.hass,"hierarchy_color_by",wh)}]},Uu(e.hass),{name:"drill_down",selector:{boolean:{}}}]);return R`${Di(Hu(e),"chart",t,Nh,Bu)} + ${Iu(e)}`},render(e){const t=Mh(e),o=nh(e,t);return R`${ih(e,t,o,"")} ${Th(e,o.node,t)} + ${o.node.value>0?Oh(e,o.node,t):W} ${Ch(e)}`}},Uh={layout:"chart",group:"totals",height:280,styles:r` + ${Ph} + .icicle { + position: relative; + height: ${$h}px; + } + .icicle .cell { + position: absolute; + box-sizing: border-box; + padding: 1.5px; + } + .icicle .cell-fill { + box-sizing: border-box; + width: 100%; + height: 100%; + border-radius: 5px; + padding: 3px 8px; + overflow: hidden; + display: flex; + flex-direction: column; + line-height: 1.25; + opacity: 0; + transform: translateX(-8px); + transition: + opacity var(--cd-duration) var(--cd-easing), + transform var(--cd-duration) var(--cd-easing); + } + :host([ready]) .icicle .cell-fill { + opacity: 1; + transform: none; + } + :host([static]) .icicle .cell-fill { + transition: none; + } + .icicle .text-root .cell-fill { + background: color-mix(in srgb, var(--primary-text-color, #212121) 78%, var(--card-background-color, #fff)); + color: var(--card-background-color, #fff); + } + .icicle .text-light { + color: #fff; + } + .icicle .text-dark { + color: rgba(0, 0, 0, 0.87); + } + .icicle .text-theme { + color: var(--primary-text-color); + } + .icicle .cell-name, + .icicle .cell-value { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .icicle .cell-name { + font-size: 13px; + font-weight: 500; + } + .icicle .cell-value { + font-size: 12px; + opacity: 0.9; + } + .icicle .leaf { + cursor: pointer; + outline: none; + } + .icicle .leaf:focus-visible .cell-fill { + box-shadow: inset 0 0 0 2px var(--primary-text-color); + } + `,thumbnail:()=>Z` + + + + + + `,validate:e=>Lh(e,!1),editor:function(e){const t=Pi("icicle",e.hass,()=>[{name:"hierarchy_color_by",selector:xi(e.hass,"hierarchy_color_by",wh)},Uu(e.hass),{name:"drill_down",selector:{boolean:{}}}]);return R`${Di(Hu(e),"chart",t,jh,Bu)} ${Iu(e)}`},render(e){const t=Mh(e),o=nh(e,t);return R`${ih(e,t,o,"")} ${Fh(e,o.node,t)} ${Ch(e)}`}};function Hh(e,t,o=5){if(!Number.isFinite(e)||!Number.isFinite(t))return[];e>t&&([e,t]=[t,e]),e===t&&(e-=.5,t+=.5);const a=function(e,t=5){if(!Number.isFinite(e)||e<=0||t<1)return 1;const o=e/t,a=10**Math.floor(Math.log10(o)),n=o/a;return(n<=1?1:n<=2?2:n<=2.5?2.5:n<=5?5:10)*a}(t-e,o),n=Math.floor(e/a+1e-9)*a,r=Math.ceil(t/a-1e-9)*a,i=[];for(let s=0,l=n;l<=r+a/2&&s<100;s++,l=n+s*a)i.push(Number(l.toPrecision(12)));return i}function Ih(e){let t=1/0,o=-1/0;for(const a of e)"number"==typeof a&&Number.isFinite(a)&&(ao&&(o=a));return t===1/0?void 0:[t,o]}function Bh(e,t){let o=null;for(const a of e){if(a.t>t)break;o=a.v}return o}const Rh=["grouped","stacked","percent"],Zh=["series","bands"],qh=un;function Wh(e,t,o="none"){const a=new Map,n=[];for(const l of e){let e=a.get(l.group);e||(e={key:l.group,label:l.group,segments:[],total:null},a.set(l.group,e)),n.includes(l.series)||n.push(l.series);const t=e.segments.length;e.segments.push({index:l.index,series:l.series,value:l.value,from:0,to:0,slot:t}),null!==l.value&&(e.total=(e.total??0)+l.value)}const r=[...a.values()];for(const l of r){const e=l.segments.reduce((e,t)=>e+Math.abs(t.value??0),0);let o=0,a=0;for(const n of l.segments){if(null===n.value)continue;const r="percent"===t?0===e?0:n.value/e:n.value;"grouped"===t?n.to=r:r>=0?(n.from=o,n.to=o+=r):(n.from=a,n.to=a+=r)}}const i=r.flatMap(e=>e.segments.flatMap(e=>null===e.value?[]:[e.from,e.to])),s=function([e,t]){return[Math.min(0,e),Math.max(0,t)]}(Ih(i)??[0,1]);if(s[0]===s[1]&&(s[1]=1),"none"!==o){const e="ascending"===o?1:-1;r.sort((t,o)=>null===t.total!=(null===o.total)?null===t.total?1:-1:((t.total??0)-(o.total??0))*e)}return{rows:r,domain:s,series:n}}function Vh(e,t){if("grouped"!==t)return 36;const o=Math.max(1,e.segments.length);return 12*o+3*(o-1)+16}function Yh(e){return"string"==typeof e&&""!==e.trim()?e.trim():"number"==typeof e?String(e):void 0}function Gh(e,t){return`${kn(100*Math.abs(e),0,t)} %`}yt("en",{editor_style_grouped_bars:"Grouped bars",editor_mode:"Bars",editor_mode_grouped:"Side by side",editor_mode_stacked:"Stacked",editor_mode_percent:"Stacked to 100 %",editor_bars_color_by:"Colour",editor_bars_color_by_series:"One colour per series",editor_bars_color_by_bands:"By colour band",editor_bar_group:"Row",editor_bar_series:"Series",editor_bar_color:"Bar colour",grouped_bars_aria:"Bar chart of {names}"});const Xh=e=>`${(100*e).toFixed(3)}%`;const Jh=[{name:"mode",path:["mode"],default:"grouped"},{name:"bars_color_by",path:["color_by"],default:"series"}];const Kh=[{name:"bar_group",path:["group"]},{name:"bar_series",path:["series"]},{name:"bar_color",path:["color"]}];const Qh={layout:"chart",group:"compare",height:200,styles:r` + .bars { + padding: 4px 0; + } + .bar-rows { + position: relative; + transition: height var(--cd-duration) var(--cd-easing); + } + .bar-row { + position: absolute; + left: 0; + right: 0; + top: 0; + display: flex; + align-items: center; + transition: transform var(--cd-duration) var(--cd-easing); + } + .row-name { + flex: none; + width: var(--name-w); + padding-right: 8px; + box-sizing: border-box; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 0.875em; + } + .row-plot { + position: relative; + flex: 1; + min-width: 0; + align-self: stretch; + margin: 0 var(--value-w) 0 var(--neg-w); + } + .bars .zero { + position: absolute; + top: 0; + bottom: 0; + width: 1px; + background: var(--divider-color, rgba(127, 127, 127, 0.3)); + } + .bar-seg { + position: absolute; + box-sizing: border-box; + border-radius: 4px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + transform-origin: left center; + transition: + left var(--cd-duration) var(--cd-easing), + width var(--cd-duration) var(--cd-easing), + transform var(--cd-duration) var(--cd-easing); + } + .bar-seg.negative { + transform-origin: right center; + } + .mode-stacked .bar-seg, + .mode-percent .bar-seg { + border-radius: 2px; + border-right: 1px solid var(--card-background-color, #fff); + } + :host(:not([ready])) .bar-seg { + transform: scaleX(0); + } + .bar-seg:focus-visible { + outline: 2px solid var(--primary-text-color); + outline-offset: 1px; + } + .seg-text { + font-size: 11px; + line-height: 1; + white-space: nowrap; + color: #fff; + pointer-events: none; + } + .seg-text.dark { + color: rgba(0, 0, 0, 0.8); + } + .seg-text.theme { + color: var(--primary-text-color); + } + .bar-value { + position: absolute; + display: flex; + align-items: center; + padding: 0 4px; + font-size: 11px; + line-height: 1; + white-space: nowrap; + color: var(--primary-text-color); + transition: + left var(--cd-duration) var(--cd-easing), + right var(--cd-duration) var(--cd-easing); + } + .bar-value.unavailable { + color: var(--secondary-text-color); + } + .bar-value.total { + font-size: 12px; + font-weight: 500; + } + :host([static]) .bar-row, + :host([static]) .bar-rows, + :host([static]) .bar-value { + transition: none; + } + .bar-legend { + display: flex; + flex-wrap: wrap; + gap: 4px 16px; + padding-top: 8px; + font-size: 0.875em; + color: var(--secondary-text-color); + } + .bar-legend-item { + display: inline-flex; + align-items: center; + gap: 6px; + } + .bar-legend .swatch { + width: 10px; + height: 10px; + border-radius: 50%; + } + `,thumbnail:()=>Z` + + + + + `,validate:function(e){if(void 0!==e.mode&&!Rh.includes(e.mode))throw new Error(`"mode" must be one of: ${Rh.join(", ")}`);if(void 0!==e.color_by&&!Zh.includes(e.color_by))throw new Error(`"color_by" must be one of: ${Zh.join(", ")}`);(e.entities??[]).forEach((e,t)=>{if("object"==typeof e&&null!==e)for(const o of["group","series"]){const a=e[o];if(null!=a&&"string"!=typeof a&&"number"!=typeof a)throw new Error(`Entity ${t+1}: "${o}" must be text`)}})},editor:function(e){const t=Pi("grouped_bars",e.hass,()=>[{type:"grid",name:"",schema:[{name:"mode",selector:xi(e.hass,"mode",Rh)},{name:"bars_color_by",selector:xi(e.hass,"bars_color_by",Zh)}]}]);return Di(e,"chart",t,Jh)},entityFields:e=>({schema:Pi("grouped_bars_entity",e,()=>[{type:"grid",name:"",schema:[{name:"bar_group",selector:{text:{}}},{name:"bar_series",selector:{text:{}}}]},{name:"bar_color",selector:{text:{}}}]),fields:Kh}),render:function(e){const{hass:t}=e,o=e.raw.mode??"grouped",a="bands"===e.raw.color_by,n=Wh(function(e){return function(e){const t=e.config.entities;return e.items.map((e,o)=>({e:e,index:o,at:t.indexOf(e.config)})).sort((e,t)=>(-1===e.at?1/0:e.at)-(-1===t.at?1/0:t.at)||e.index-t.index)}(e).map(({e:e,index:t})=>{const o=e.config;return{index:t,group:Yh(o.group)??`\0${t}`,series:Yh(o.series)??"",value:e.available?e.value:null}})}(e),o,e.config.sort),[r,i]=n.domain,s=e=>(e-r)/(i-r),l=s(0),c=t=>{const o=e.items[t.index],r=o.config.color;return a?o.band.color:"string"==typeof r&&r?r:qh[n.series.indexOf(t.series)%qh.length]},{show:d}=e.config,u=!a&&n.series.some(e=>""!==e)&&!1!==e.raw.show?.legend,h=e.items.map(e=>e.display.length),p=d.value&&"percent"!==o?Math.min(12,Math.max(4,...h)):0,m=p?Math.round(6.5*p+8):4,f=Math.max(0,...n.rows.map(t=>(t.label.startsWith("\0")?e.items[t.segments[0].index].name:t.label).length));let g=0;const v=n.rows.map(e=>{const t=g;return g+=Vh(e,o),{row:e,top:t}}),y=_t(t,"grouped_bars_aria",{names:e.items.map(e=>e.name).join(", ")}),b=r<0;return R``}};function ep(e,t,o,a=""){const n=Ae(e.config);return R`
+ ${o} +
`}const tp=r` + .compare .entity.interactive { + cursor: pointer; + } + .compare .entity:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: 2px; + border-radius: 4px; + } + .compare .name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + } + .compare .value { + font-weight: 500; + white-space: nowrap; + } + .compare .entity.unavailable .value { + color: var(--secondary-text-color); + font-weight: 400; + font-size: 0.875em; + white-space: normal; + } + .compare .sub { + color: var(--secondary-text-color); + font-size: 0.8em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } +`,op=["shades","colours"];function ap(e,t,o,a){const{show:n}=o.config,r=function(e){const t=[];return e.bands.forEach((o,a)=>{const n=e.bands[a+1],r=Number.isFinite(o.from)?Xi(o.from,e.min,e.max):0,i=n?Xi(n.from,e.min,e.max):1;i<=r||t.push({from:t.length?r:0,to:i,color:o.color,level:0})}),t.forEach((e,t)=>e.level=t),t}(e),i=n.target?function(e,t){if(null!==e.target)return _t(t,"compare_target",{target:Mn(kn(e.target,e.decimals,t),e.unit)})}(e,o.hass):void 0,s=n.value||!e.available,l=R` + ${n.name?R`
+ ${e.name}${i?R`${i}`:W} +
`:W} +
+ ${r.map(e=>R`
`)} + ${e.available?R`
`:W} + ${n.target&&e.available&&null!==e.targetPosition?R`
`:W} +
+ ${s?R`${e.display}`:R``} + `;return ep(e,t,l,"bullet-row")}yt("en",{editor_style_bullet:"Bullet",editor_ranges:"Ranges behind the bar",editor_ranges_shades:"Shades of grey",editor_ranges_colours:"Colour band colours",compare_target:"target {target}",chart_aria:"{style} chart of {names}"});const np={layout:"chart",group:"compare",height:120,styles:r` + ${tp} + .style-bullet .bullets { + display: grid; + grid-template-columns: minmax(0, auto) minmax(96px, 1fr) fit-content(8em); + column-gap: 12px; + } + .style-bullet .bullets.no-name { + grid-template-columns: minmax(96px, 1fr) fit-content(8em); + } + .style-bullet .bullet-row { + display: grid; + grid-column: 1 / -1; + grid-template-columns: subgrid; + align-items: center; + padding: 6px 0; + } + .style-bullet .label { + display: flex; + flex-direction: column; + min-width: 0; + max-width: 14em; + } + .style-bullet .track { + position: relative; + height: 28px; + border-radius: 6px; + overflow: hidden; + background: var(--cd-track); + } + .style-bullet .range { + position: absolute; + top: 0; + bottom: 0; + } + .style-bullet .fill { + position: absolute; + top: 30%; + bottom: 30%; + left: calc(var(--start) * 100%); + width: calc((var(--end) - var(--start)) * 100%); + border-radius: 4px; + background: var(--c-start); + background-image: linear-gradient(90deg, var(--c-start), color-mix(in srgb, var(--c-start) 80%, #fff)); + transition: + left var(--cd-duration) var(--cd-easing), + width var(--cd-duration) var(--cd-easing); + } + .style-bullet .target { + position: absolute; + top: 3px; + bottom: 3px; + width: 3px; + border-radius: 2px; + left: calc(var(--target) * (100% - 3px)); + background: var(--primary-text-color); + transition: left var(--cd-duration) var(--cd-easing); + } + .style-bullet .value { + text-align: right; + min-width: calc(var(--cd-value-ch, 4) * 0.62em); + } + `,thumbnail:()=>Z` + + + + + + + + `,validate(e){if(void 0!==e.ranges&&!op.includes(e.ranges))throw new Error(`"ranges" must be one of: ${op.join(", ")}`)},editor(e){const t=Pi("bullet",e.hass,()=>[{name:"ranges",selector:xi(e.hass,"ranges",op)}]);return Di(e,"chart",t,[{name:"ranges",path:["ranges"],default:"shades"}])},render(e){const t=e.raw.ranges??"shades",o=_t(e.hass,"chart_aria",{style:_t(e.hass,"editor_style_bullet"),names:e.items.map(e=>e.name).join(", ")});return R`
+ ${e.items.map((o,a)=>ap(o,a,e,t))} +
`}},rp=["vertical","horizontal"];function ip(e,t,o){const{show:a}=t.config;return R`
+
+ ${e.available?R`
+ ${a.target&&null!==e.targetPosition?R`
`:W} +
+ ${o&&a.value?R`${e.display}`:W}`:o?R`${e.display}`:W} +
+
`}function sp(e,t,o){const{show:a}=o.config;return ep(e,t,R`${ip(e,o,!0)} ${a.name?R`${e.name}`:W}`,"pop")}function lp(e,t,o){const{show:a}=o.config,n=a.value||!e.available;return ep(e,t,R`${a.name?R`${e.name}`:W} ${ip(e,o,!1)} + ${n?R`${e.display}`:R``}`,"pop")}yt("en",{editor_style_lollipop:"Lollipop",editor_orientation:"Direction",editor_orientation_vertical:"Upright",editor_orientation_horizontal:"Across",chart_aria:"{style} chart of {names}"});const cp={layout:"chart",group:"compare",height:200,styles:r` + ${tp} + .style-lollipop .lane { + position: relative; + } + .style-lollipop .scale { + position: absolute; + } + .style-lollipop .stem { + position: absolute; + background: var(--c-start); + border-radius: 2px; + } + .style-lollipop .dot { + position: absolute; + width: 14px; + height: 14px; + border-radius: 50%; + background: var(--c-start); + background-image: radial-gradient( + circle at 35% 30%, + color-mix(in srgb, var(--c-start) 70%, #fff), + var(--c-start) + ); + } + .style-lollipop .target { + position: absolute; + width: 8px; + height: 8px; + border-radius: 50%; + border: 2px solid var(--primary-text-color); + background: var(--card-background-color, #fff); + box-sizing: content-box; + } + + /* Upright: one column per entity over a shared baseline. */ + .style-lollipop .vertical { + display: flex; + align-items: stretch; + } + .style-lollipop .vertical .pop { + flex: 1 1 0; + min-width: 0; + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + } + .style-lollipop .vertical .lane { + align-self: stretch; + height: 150px; + border-bottom: 1px solid var(--divider-color, rgba(127, 127, 127, 0.3)); + } + .style-lollipop .vertical .scale { + left: 50%; + top: 26px; + bottom: 0; + } + .style-lollipop .vertical .stem { + left: -1.5px; + width: 3px; + bottom: calc(var(--start) * 100%); + height: calc((var(--end) - var(--start)) * 100%); + transition: + bottom var(--cd-duration) var(--cd-easing), + height var(--cd-duration) var(--cd-easing); + } + .style-lollipop .vertical .dot { + left: -7px; + bottom: calc(var(--pos) * 100% - 7px); + transition: bottom var(--cd-duration) var(--cd-easing); + } + .style-lollipop .vertical .target { + left: -6px; + bottom: calc(var(--target) * 100% - 6px); + } + .style-lollipop .vertical .tip { + position: absolute; + left: 0; + bottom: calc(var(--pos) * 100% + 10px); + transform: translateX(-50%); + font-size: 0.875em; + line-height: 1.2; + padding: 0 3px; + border-radius: 3px; + background: var(--card-background-color, #fff); + z-index: 1; + transition: bottom var(--cd-duration) var(--cd-easing); + } + .style-lollipop .vertical .name { + max-width: 100%; + color: var(--secondary-text-color); + font-size: 0.875em; + padding: 0 2px; + } + .style-lollipop .vertical .entity.unavailable .value { + font-size: 0.75em; + white-space: nowrap; + } + + /* Across: one row per entity, stems growing to the right. */ + .style-lollipop .horizontal { + display: grid; + grid-template-columns: minmax(0, auto) minmax(96px, 1fr) fit-content(8em); + column-gap: 12px; + } + .style-lollipop .horizontal.no-name { + grid-template-columns: minmax(96px, 1fr) fit-content(8em); + } + .style-lollipop .horizontal .pop { + display: grid; + grid-column: 1 / -1; + grid-template-columns: subgrid; + align-items: center; + padding: 6px 0; + } + .style-lollipop .horizontal .name { + max-width: 14em; + } + .style-lollipop .horizontal .lane { + height: 20px; + border-left: 1px solid var(--divider-color, rgba(127, 127, 127, 0.3)); + } + .style-lollipop .horizontal .scale { + top: 50%; + left: 0; + right: 7px; + } + .style-lollipop .horizontal .stem { + top: -1.5px; + height: 3px; + left: calc(var(--start) * 100%); + width: calc((var(--end) - var(--start)) * 100%); + transition: + left var(--cd-duration) var(--cd-easing), + width var(--cd-duration) var(--cd-easing); + } + .style-lollipop .horizontal .dot { + top: -7px; + left: calc(var(--pos) * 100% - 7px); + transition: left var(--cd-duration) var(--cd-easing); + } + .style-lollipop .horizontal .target { + top: -6px; + left: calc(var(--target) * 100% - 6px); + } + .style-lollipop .horizontal .value { + text-align: right; + } + `,thumbnail:()=>Z` + + + + + + + `,validate(e){if(void 0!==e.orientation&&!rp.includes(e.orientation))throw new Error(`"orientation" must be one of: ${rp.join(", ")}`)},editor(e){const t=Pi("lollipop",e.hass,()=>[{name:"orientation",selector:xi(e.hass,"orientation",rp)}]);return Di(e,"chart",t,[{name:"orientation",path:["orientation"],default:"vertical"}])},render(e){const t=e.raw.orientation??"vertical",o=_t(e.hass,"chart_aria",{style:_t(e.hass,"editor_style_lollipop"),names:e.items.map(e=>e.name).join(", ")}),a="vertical"===t?sp:lp;return R`
+ ${e.items.map((t,o)=>a(t,o,e))} +
`}},dp=["change","current"],up=["vertical","horizontal"];function hp(e){const t=e.find(e=>null!==e.v);return t?t.v:null}yt("en",{editor_style_waterfall:"Waterfall",editor_steps:"Each step is",editor_steps_change:"How much it changed in the period",editor_steps_current:"Its current value",editor_orientation:"Direction",editor_orientation_vertical:"Upright",editor_orientation_horizontal:"Across",editor_start_from_first:"Start from the first entity's reading at the start of the period",editor_total:"Show the total at the end",editor_waterfall_invert:"Counts down (a rise in these is a drop in the total)",waterfall_start:"Start",waterfall_end:"End",waterfall_total:"Total",waterfall_no_readings:"No readings",waterfall_aria:"Waterfall chart: {bars}"});const pp={vertical:.14,horizontal:.24};function mp(e,t){const{items:o,raw:a,hass:n}=e,r=a.steps??"change",i=!0===a.start_from_first,s=e=>t?.series[e]?.points??[],l=(e,t)=>{const o="change"===r?function(e){const t=e.filter(e=>null!==e.v);return 0===t.length?null:t[t.length-1].v-t[0].v}(s(t)):e.available?e.value:null;return null===o?null:function(e){return!0===e.config.invert}(e)?-o:o},c=i&&o.length?{value:hp(s(0)),index:0}:null,d=o.map((e,t)=>({value:l(e,t),index:t})).slice(i?1:0),{bars:u}=function(e,t,o){const a=[];let n=0;if(e){const t=e.value??0;a.push({kind:"start",from:0,to:t,delta:t,index:e.index,missing:null===e.value}),n=t}for(const s of t){const e=s.value??0;a.push({kind:"step",from:n,to:n+e,delta:e,index:s.index,missing:null===s.value}),n+=e}o&&a.push({kind:"total",from:0,to:n,delta:n,missing:!1});let r=0,i=0;for(const s of a)r=Math.min(r,s.from,s.to),i=Math.max(i,s.from,s.to);return r===i&&(i=r+1),{bars:a,domain:[r,i]}}(c,d,!1!==a.total),h=o[0]?.decimals??1;return u.map(e=>{const t=void 0===e.index?void 0:o[e.index],a=t?.decimals??h;let r,i;return"start"===e.kind?(r=_t(n,"waterfall_start"),i=e.missing?_t(n,"waterfall_no_readings"):kn(e.to,a,n)):"total"===e.kind?(r=_t(n,c?"waterfall_end":"waterfall_total"),i=kn(e.to,h,n)):(r=t.name,i=e.missing?t.available?_t(n,"waterfall_no_readings"):t.display:function(e,t,o){const a=kn(Math.abs(e),t,o);return 0===Number(e.toFixed(t))?kn(0,t,o):`${e>0?"+":"−"}${a}`}(e.delta,a,n)),{...e,name:r,label:i,entity:t}})}function fp(e,t){const o="horizontal"===e.raw.orientation,a=t.flatMap(e=>[e.from,e.to]);let n=Math.min(0,...a),r=Math.max(0,...a);n===r&&(r=n+1);const i=(r-n)*pp[o?"horizontal":"vertical"];r+=i,n<0&&(n-=i);const s=e=>(e-n)/(r-n)*100,l=t.length,c=100/l,d=o?.64:.6,u=e=>c*e+c*(1-d)/2,h=e=>u(e)+c*d,p=e.items[0]?.unit??"",m=_t(e.hass,"waterfall_aria",{bars:t.map(e=>`${e.name} ${e.missing?e.label:Mn(e.label,e.entity?.unit??p)}`).join(", ")}),f=e=>void 0===e.index?"":"entity",g=t.map((e,t)=>{const a=`--a0:${s(Math.min(e.from,e.to))}%;--a1:${s(Math.max(e.from,e.to))}%;--c0:${u(t)}%;--c1:${h(t)}%;--cm:${(u(t)+h(t))/2}%;--color:${function(e){return"step"!==e.kind?Ce("primary"):Ce(e.delta<0?"red":"green")}(e)}`,n=t+1
+ ${e.missing&&!o?"–":e.label} + ${n?R`
`:W}`}),v=t.map(e=>R`
+ ${e.name} +
`);return R`
+
${v}
+ +
`}const gp=[...Mi,{name:"steps",path:["steps"],default:"change"},{name:"orientation",path:["orientation"],default:"vertical"},{name:"start_from_first",path:["start_from_first"],default:!1},{name:"total",path:["total"],default:!0}];function vp(e,t){if(void 0!==e&&"boolean"!=typeof e)throw new Error(`"${t}" must be true or false`)}const yp={layout:"chart",group:"totals",height:240,styles:r` + ${$n} + .wf { + display: grid; + gap: 6px; + } + .wf.vertical { + grid-template-rows: 180px auto; + } + .wf.vertical .names { + grid-row: 2; + display: grid; + grid-template-columns: repeat(var(--n), minmax(0, 1fr)); + } + .wf.vertical .wf-plot { + grid-row: 1; + } + .wf.horizontal { + grid-template-columns: minmax(0, max-content) minmax(0, 1fr); + max-width: 100%; + } + .wf.horizontal .names { + display: grid; + grid-template-rows: repeat(var(--n), 30px); + max-width: 140px; + } + .wf.horizontal .wf-plot { + height: calc(var(--n) * 30px); + } + .wf-plot { + position: relative; + min-width: 0; + } + .bar-name { + min-width: 0; + display: flex; + align-items: center; + font-size: 0.8125em; + color: var(--secondary-text-color); + } + .bar-name span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .wf.vertical .bar-name { + justify-content: center; + padding: 0 2px; + } + .bar-name.entity { + cursor: pointer; + border-radius: 4px; + } + .bar { + position: absolute; + box-sizing: border-box; + background: linear-gradient(var(--color), color-mix(in srgb, var(--color) 80%, #fff)); + border-radius: 4px; + transition: transform var(--cd-duration) var(--cd-easing); + } + .bar.entity { + cursor: pointer; + } + .bar.missing { + background: none; + border: 1.5px dashed var(--secondary-text-color); + opacity: 0.6; + } + .wf.vertical .bar { + left: var(--c0); + width: calc(var(--c1) - var(--c0)); + bottom: var(--a0); + height: max(calc(var(--a1) - var(--a0)), 2px); + transform-origin: 50% 100%; + transform: scaleY(0); + } + .wf.horizontal .bar { + top: var(--c0); + height: calc(var(--c1) - var(--c0)); + left: var(--a0); + width: max(calc(var(--a1) - var(--a0)), 2px); + transform-origin: 0 50%; + transform: scaleX(0); + } + .wf.vertical .bar.missing { + height: 16px; + bottom: calc(var(--a0) - 8px); + } + .wf.horizontal .bar.missing { + width: 16px; + left: calc(var(--a0) - 8px); + } + :host([ready]) .wf .bar { + transform: none; + } + .bar-label { + position: absolute; + font-size: 0.8125em; + font-weight: 500; + line-height: 1; + white-space: nowrap; + pointer-events: none; + color: var(--primary-text-color); + } + .bar-label:not(.start):not(.total):not(.missing) { + color: var(--color); + } + .bar-label.missing { + color: var(--secondary-text-color); + font-weight: 400; + } + .wf.horizontal .bar-label.missing { + margin-left: 10px; + } + .wf.vertical .bar-label { + left: var(--cm); + bottom: calc(var(--a1) + 4px); + transform: translateX(-50%); + } + .wf.horizontal .bar-label { + left: calc(var(--a1) + 6px); + top: var(--cm); + transform: translateY(-50%); + } + .connector { + position: absolute; + pointer-events: none; + border: 0 dashed var(--secondary-text-color); + opacity: 0.6; + } + .wf.vertical .connector { + left: var(--c0); + width: calc(var(--c1) - var(--c0)); + bottom: var(--a); + border-top-width: 1px; + } + .wf.horizontal .connector { + top: var(--c0); + height: calc(var(--c1) - var(--c0)); + left: var(--a); + border-left-width: 1px; + } + /* Narrow cards: smaller text so neighbouring columns' labels don't run into each other. */ + @container (max-width: 400px) { + .wf .bar-label, + .wf .bar-name { + font-size: 0.6875em; + } + .wf.horizontal .names { + max-width: 96px; + } + } + .zero { + position: absolute; + pointer-events: none; + background: var(--divider-color, rgba(127, 127, 127, 0.3)); + } + .wf.vertical .zero { + left: 0; + right: 0; + bottom: var(--a); + height: 1px; + } + .wf.horizontal .zero { + top: 0; + bottom: 0; + left: var(--a); + width: 1px; + } + `,thumbnail:()=>Z` + + + + + `,validate(e){if(pn(e),void 0!==e.steps&&!dp.includes(e.steps))throw new Error(`"steps" must be one of: ${dp.join(", ")}`);if(void 0!==e.orientation&&!up.includes(e.orientation))throw new Error(`"orientation" must be one of: ${up.join(", ")}`);vp(e.start_from_first,"start_from_first"),vp(e.total,"total");for(const t of e.entities??[])"object"==typeof t&&vp(t.invert,"invert")},editor:function(e){const t=Pi("waterfall",e.hass,()=>[...zi(e.hass),{type:"grid",name:"",schema:[{name:"steps",selector:xi(e.hass,"steps",dp)},{name:"orientation",selector:xi(e.hass,"orientation",up)}]},{name:"start_from_first",selector:{boolean:{}}},{name:"total",selector:{boolean:{}}}]);return R`${Di(e,"chart",t,gp)} + ${Pu(e,"waterfall_invert","invert",{boolean:{}},!1)}`},disconnect:bn,render(e){let t;if("change"===((o=e.raw).steps??"change")||!0===o.start_from_first){t=mn(e);const o=xn(t,e);if(o)return o}var o;return fp(e,mp(e,t))}},bp=["value","change"],_p=["direction","group"],xp=["bad","good"];yt("en",{editor_style_slope:"Slope",editor_measure:"Compare",editor_measure_value:"The reading then and now",editor_measure_change:"How much it rose in each period (meters)",editor_color_by_direction:"Up or down",editor_color_by_group:"By group",editor_increase:"A rise is",editor_increase_bad:"Bad (red)",editor_increase_good:"Good (green)",editor_group:"Group",slope_now:"Now",slope_ago:"{n} {unit} ago",slope_ago_one:"A {unit} ago",slope_previous:"Previous {n} {unit}",slope_previous_one:"Previous {unit}",slope_last:"Last {n} {unit}",slope_last_one:"Last {unit}",slope_unit_hour:"hour",slope_unit_hour_other:"hours",slope_unit_day:"day",slope_unit_day_other:"days",slope_unit_week:"week",slope_unit_week_other:"weeks",slope_unit_month:"month",slope_unit_month_other:"months",slope_entity_aria:"{name}, {from} to {to}",slope_no_reading:"no reading"});const wp={"5minute":3e5,hour:36e5,day:864e5,month:26784e5};function $p(e,t,o){const a=function(e,t,o=1e-9){return Math.abs(t-e)<=o?"flat":t>e?"up":"down"}(e,t);return"flat"===a?"grey":"up"===a==("bad"===o)?"red":"green"}function kp(e,t,o){const a=1===o.last,n=_t(e,`slope_unit_${o.unit}${a?"":"_other"}`);return _t(e,a?`slope_${t}_one`:`slope_${t}`,{n:o.last,unit:n})}function Mp(e,t,o){const a=function(e,t){if(null!==e&&null!==t&&0!==e)return(t-e)/Math.abs(e)}(e,t);if(void 0===a)return"";const n=Math.round(100*a);return`${n>0?"+":n<0?"−":""}${kn(Math.abs(n),0,o)} %`}function zp(e,t,o,a){const n="statistics"===e.source?wp[Ia(e.range)]:0,r="group"===t.raw.color_by,i="good"===t.raw.increase?"good":"bad",s=new Map,l=e.series.map(e=>{const t=e.entity.available?e.entity.value:null,[l,c]=function(e,t,o,a,n=0){const r=t.find(e=>null!==e.v),i=Bh(t,o.start)??(r&&r.t-o.start<=n?r.v:null);if("value"===e)return[i,a];const s=Bh(t,o.middle);return[null===i||null===s?null:s-i,null===s||null===a?null:a-s]}(o,e.points,a,t,n),d=function(e){const t=e.config.group;return"string"==typeof t&&""!==t.trim()?t.trim():void 0}(e.entity),u=e.entity.config.color;let h;if(r){const t=d??`#${e.index}`;s.has(t)||s.set(t,un[s.size%un.length]),h=u??s.get(t)}else h=null!==l&&null!==c?$p(l,c,i):"grey";return{index:e.index,entity:e.entity,from:l,to:c,color:Ce(h),group:d}});return l}const Ap=[...Mi,{name:"measure",path:["measure"],default:"value"},{name:"color_by",path:["color_by"],default:"direction"},{name:"increase",path:["increase"],default:"bad"}],Sp=[{name:"group",path:["group"]}],Ep={layout:"chart",group:"compare",height:230,styles:r` + ${$n} + .style-slope .slope-head, + .style-slope .slope-plot { + --lw: min(36%, 180px); + } + .slope-head { + display: grid; + grid-template-columns: var(--lw) 1fr var(--lw); + gap: 0 8px; + padding: 4px 0 6px; + font-size: 0.875em; + color: var(--secondary-text-color); + } + .slope-head .from { + grid-column: 1; + text-align: right; + } + .slope-head .to { + grid-column: 3; + } + .slope-head .unit { + grid-column: 2; + text-align: center; + } + .slope-plot { + position: relative; + margin: 8px 0; + } + .slope-plot .axis { + position: absolute; + top: -6px; + bottom: -6px; + border-left: 1px dashed var(--divider-color, rgba(127, 127, 127, 0.3)); + } + .slope-plot .axis.from { + left: calc(var(--lw) + 8px); + } + .slope-plot .axis.to { + right: calc(var(--lw) + 8px); + } + .slope-plot .entity { + position: absolute; + inset: 0; + pointer-events: none; + outline: none; + opacity: 1; + transition: opacity var(--cd-duration) var(--cd-easing); + } + :host(:not([ready])) .slope-plot .entity { + opacity: 0; + } + .slope-plot .entity > * { + pointer-events: auto; + cursor: pointer; + } + .slope-plot .entity:focus-visible .lbl { + outline: 2px solid var(--primary-color); + outline-offset: 1px; + border-radius: 3px; + } + .slope-plot svg { + position: absolute; + top: 0; + bottom: 0; + left: calc(var(--lw) + 8px); + right: calc(var(--lw) + 8px); + width: calc(100% - 2 * var(--lw) - 16px); + height: 100%; + overflow: visible; + pointer-events: none; + } + .slope-plot svg line { + vector-effect: non-scaling-stroke; + stroke-width: 3; + stroke-linecap: round; + pointer-events: stroke; + } + .slope-plot .dot { + position: absolute; + width: 10px; + height: 10px; + margin: -5px 0 0 -5px; + border-radius: 50%; + } + .slope-plot .dot.from { + left: calc(var(--lw) + 8px); + } + .slope-plot .dot.to { + left: calc(100% - var(--lw) - 8px); + } + .slope-plot .lbl { + position: absolute; + transform: translateY(-50%); + width: var(--lw); + display: flex; + gap: 4px; + align-items: baseline; + font-size: 0.875em; + line-height: 1.2; + white-space: nowrap; + min-width: 0; + } + .slope-plot .lbl.from { + left: 0; + justify-content: flex-end; + } + .slope-plot .lbl.to { + right: 0; + } + .slope-plot .lbl .name { + color: var(--secondary-text-color); + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; + } + .slope-plot .lbl .value { + font-weight: 500; + flex: none; + } + .slope-plot .lbl .pct { + flex: none; + padding-left: 2px; + font-size: 0.9em; + } + /* Narrow cards: the change column would squeeze the names to nothing. */ + @container (max-width: 380px) { + .slope-plot .lbl .pct { + display: none; + } + } + .slope-legend { + display: flex; + flex-wrap: wrap; + gap: 4px 16px; + padding: 4px 0; + font-size: 0.875em; + color: var(--secondary-text-color); + } + .slope-legend-item { + display: flex; + align-items: center; + gap: 6px; + } + `,thumbnail:()=>Z` + + + + ${[[14,28],[50,10],[14,12],[50,22],[14,32],[50,30]].map(([e,t])=>Z``)}`,validate(e){pn(e);const t=(t,o)=>{if(void 0!==e[t]&&!o.includes(e[t]))throw new Error(`"${t}" must be one of: ${o.join(", ")}`)};t("measure",bp),t("color_by",_p),t("increase",xp);for(const o of e.entities??[]){const e="object"==typeof o&&o?o.group:void 0;if(void 0!==e&&"string"!=typeof e)throw new Error('"group" must be text')}},editor(e){const t=Pi("slope",e.hass,()=>[...zi(e.hass),{name:"measure",selector:xi(e.hass,"measure",bp)},{type:"grid",name:"",schema:[{name:"color_by",selector:xi(e.hass,"color_by",_p)},{name:"increase",selector:xi(e.hass,"increase",xp)}]}]);return Di(e,"chart",t,Ap)},entityFields:e=>({schema:Pi("slope-entity",e,()=>[{name:"group",selector:{text:{}}}]),fields:Sp}),disconnect:bn,render(e){const t=Date.now(),o=$a(e.raw.period),a="change"===e.raw.measure?"change":"value",n=mn("change"===a?{...e,raw:{...e.raw,period:{last:2*o.last,unit:o.unit}}}:e,t),r=ka(o,t).start,i={start:n.range.start,middle:r},s=e.hass,l=kp(s,"change"===a?"previous":"ago",o),c="change"===a?kp(s,"last",o):_t(s,"slope_now");if("ready"!==n.status)return R`${xn(n,e)}`;const d=zp(n,e,a,i),u=d.flatMap(e=>[e.from,e.to]),h=Ih(u);if(!h)return R`
${_t(s,"chart_no_data")}
`;const{show:p}=e.config,m=new Set(d.filter(e=>e.entity.stateObj).map(e=>e.entity.unit)),f=1===m.size?[...m][0]:void 0,g=(e,t)=>{const o=kn(t,e.entity.decimals,s);return void 0===f&&e.entity.unit?`${o} ${e.entity.unit}`:o},v=d.filter(e=>null!==e.from||null!==e.to),y=Math.max(160,22*v.length),[b,_]=wo(h,.06),x=e=>9+(_-e)/(_-b)*(y-18),w=e=>{const t=v.filter(t=>null!==e(t)),o=function(e,t,o,a){const n=e.map((e,t)=>({y:e,i:t})).sort((e,t)=>e.y-t.y||e.i-t.i),r=n.map(e=>Math.min(a,Math.max(o,e.y)));for(let s=1;sa){r[r.length-1]=a;for(let e=r.length-2;e>=0;e--)r[e]=Math.min(r[e],r[e+1]-t)}if(r.length&&r[0]i[e.i]=r[t]),i}(t.map(t=>x(e(t))),18,9,y-9);return new Map(t.map((e,t)=>[e,o[t]]))},$=w(e=>e.from),k=w(e=>e.to),M=_t(s,"slope_no_reading"),z=_t(s,"chart_aria",{style:_t(s,"editor_style_slope"),names:d.map(e=>e.entity.name).join(", ")});return R`
+ ${l} + ${f?R`${f}`:W} + ${c} +
+
+
+
+ ${v.map(e=>{const t=_t(s,"slope_entity_aria",{name:e.entity.name,from:null===e.from?M:g(e,e.from),to:null===e.to?M:g(e,e.to)}),o=p.value&&null!==e.from&&null!==e.to?Mp(e.from,e.to,s):"",a=e=>p.name?R`${e}`:W;return R``})} +
+ ${"group"===e.raw.color_by?function(e){const t=new Map;for(const o of e)o.group&&!t.has(o.group)&&t.set(o.group,o.color);return t.size?R`
+ ${[...t].map(([e,t])=>R`${e}`)} +
`:W}(d):W}`}},Cp=["range","then","target"],jp=["horizontal","vertical"];function Np(e,t,o,a,n){const r=e.available?e.value:null,i=[];if("range"===t){const e=function(e){return Ih(e.flatMap(e=>[e.v,e.min,e.max]))}(o),t=e?Ih([e[0],e[1],r]):void 0;t&&i.push({kind:"low",value:t[0]},{kind:"high",value:t[1]}),null===r||!n&&t||i.push({kind:"now",value:r})}else{const n="then"===t?Bh(o,a)??function(e){for(const t of e)if(null!==t.v)return t.v;return null}(o):e.target;null!==n&&i.push({kind:t,value:n}),null!==r&&i.push({kind:"now",value:r})}return i.sort((e,t)=>e.value-t.value)}function Lp(e){if(0===e.length)return[];const t=e.some(e=>"low"===e.kind)?e.filter(e=>"now"!==e.kind):e,o=t[0],a=t[t.length-1];return o===a?[a]:[o,a]}function Tp(e,t,o,a,n){return e.map((e,r)=>{const i=Np(e,t,o(r),a,n);return{index:r,entity:e,marks:i,labelled:Lp(i)}})}function Op(e,t){return 1===t.last?_t(e,`dumbbell_ago_${t.unit}_one`):_t(e,`dumbbell_ago_${t.unit}`,{n:t.last})}yt("en",{editor_style_dumbbell:"Dumbbell",editor_ends:"Compare",editor_ends_range:"Lowest and highest in the period",editor_ends_then:"Start of the period and now",editor_ends_target:"Target and now",editor_show_now:"Mark the current value",editor_orientation:"Direction",editor_orientation_horizontal:"Across",editor_orientation_vertical:"Upright",dumbbell_low:"Low",dumbbell_high:"High",dumbbell_now:"Now",dumbbell_target:"Target",dumbbell_ago_hour_one:"An hour ago",dumbbell_ago_hour:"{n} hours ago",dumbbell_ago_day_one:"A day ago",dumbbell_ago_day:"{n} days ago",dumbbell_ago_week_one:"A week ago",dumbbell_ago_week:"{n} weeks ago",dumbbell_ago_month_one:"A month ago",dumbbell_ago_month:"{n} months ago",dumbbell_no_value:"No value"});const Fp={low:Ce("blue"),high:Ce("red"),then:Ce("grey"),target:"var(--primary-text-color, #212121)"};function Pp(e,t){return"target"===e||"now"===e&&"range"===t}function Dp(e,t,o){return"now"!==e?Fp[e]:"range"===t?"var(--primary-text-color, #212121)":Ce(o.band.color)}const Up="range",Hp="horizontal",Ip=!0;function Bp(e){return{ends:e.ends??Up,orientation:e.orientation??Hp,showNow:void 0===e.show_now?Ip:!0===e.show_now}}function Rp(e,t,o){const a=e.entity;if(0===e.marks.length)return`${a.name}, ${a.available?_t(t.hass,"dumbbell_no_value"):a.display}`;const n=e.marks.map(e=>`${(e=>"then"===e?Op(t.hass,o):_t(t.hass,`dumbbell_${e}`))(e.kind)} ${Mn(kn(e.value,a.decimals,t.hass),a.unit)}`);return`${a.name}: ${n.join(", ")}`}const Zp=[...Mi,{name:"ends",path:["ends"],default:Up},{name:"show_now",path:["show_now"],default:Ip},{name:"orientation",path:["orientation"],default:Hp}];const qp={layout:"chart",group:"compare",height:220,styles:r` + ${$n} + .db { + --pad: 40px; + --span: calc(100% - 2 * var(--pad)); + --dot: 14px; + display: flex; + flex-direction: column; + } + .db.no-values { + --pad: 12px; + } + .db-legend { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + align-items: center; + gap: 4px 14px; + padding: 2px 0 6px; + font-size: 0.8125em; + color: var(--secondary-text-color); + } + .db-unit { + margin-right: auto; + } + /* Narrow cards: every other tick label, so they don't run together. */ + @container (max-width: 400px) { + .db.horizontal .db-tick:nth-child(even) { + visibility: hidden; + } + } + .db-key { + display: inline-flex; + align-items: center; + gap: 6px; + } + .db-swatch { + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--mark); + box-sizing: border-box; + } + .db-swatch.ring, + .db-dot.ring { + background: var(--card-background-color, #fff); + border: 2.5px solid var(--mark); + } + .db-row { + position: relative; + cursor: pointer; + border-radius: 6px; + outline: none; + } + .db-row:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: -2px; + } + .db-name { + color: var(--primary-text-color); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .db-plot { + position: relative; + } + .db-grid, + .db-bar, + .db-dot, + .db-label, + .db-tick { + position: absolute; + } + .db-dot { + width: var(--dot); + height: var(--dot); + border-radius: 50%; + background: var(--mark); + box-sizing: border-box; + z-index: 1; + } + .db-dot.now.ring { + z-index: 2; + } + .db-label, + .db-tick, + .db-status { + font-size: 0.8125em; + color: var(--secondary-text-color); + white-space: nowrap; + line-height: 1; + } + .db-status { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + } + .db-bar, + .db-dot, + .db-label { + transition: + left var(--cd-duration) var(--cd-easing), + width var(--cd-duration) var(--cd-easing), + bottom var(--cd-duration) var(--cd-easing), + height var(--cd-duration) var(--cd-easing), + opacity var(--cd-duration) var(--cd-easing); + } + :host(:not([ready])) .db-bar, + :host(:not([ready])) .db-dot, + :host(:not([ready])) .db-label { + opacity: 0; + } + + /* Rows: value across, one entity per row. */ + .db.horizontal .db-row { + display: grid; + grid-template-columns: var(--name-w) minmax(0, 1fr); + align-items: center; + column-gap: 8px; + height: 36px; + } + .db.horizontal.no-names .db-row, + .db.horizontal.no-names .db-axis { + grid-template-columns: minmax(0, 1fr); + } + .db.horizontal .db-plot { + height: 100%; + } + .db.horizontal .db-grid { + top: 0; + bottom: 0; + left: calc(var(--pad) + var(--span) * var(--f)); + border-left: 1px dashed var(--divider-color, rgba(127, 127, 127, 0.3)); + } + .db.horizontal .db-bar { + top: 50%; + height: 6px; + transform: translateY(-50%); + border-radius: 3px; + left: calc(var(--pad) + var(--span) * var(--a)); + width: calc(var(--span) * (var(--b) - var(--a))); + background: linear-gradient(to right, var(--from), var(--to)); + opacity: 0.45; + } + .db.horizontal .db-dot { + top: 50%; + left: calc(var(--pad) + var(--span) * var(--f)); + transform: translate(-50%, -50%); + } + .db.horizontal .db-label { + top: 50%; + left: calc(var(--pad) + var(--span) * var(--f)); + } + .db.horizontal .db-label.lo { + transform: translate(calc(-100% - 11px), -50%); + } + .db.horizontal .db-label.hi { + transform: translate(11px, -50%); + } + .db.horizontal .db-axis { + display: grid; + grid-template-columns: var(--name-w) minmax(0, 1fr); + column-gap: 8px; + height: 22px; + } + .db.horizontal .db-axis .db-plot { + grid-column: -2; + } + .db.horizontal .db-tick { + top: 6px; + left: calc(var(--pad) + var(--span) * var(--f)); + transform: translateX(-50%); + } + + /* Columns: value up, one entity per column. */ + .db.vertical .db-body { + display: flex; + } + .db.vertical .db-row { + flex: 1 1 0; + min-width: 0; + display: flex; + flex-direction: column-reverse; + align-items: stretch; + } + .db.vertical .db-name { + text-align: center; + font-size: 0.8125em; + padding-top: 4px; + height: 1.4em; + } + .db.vertical .db-plot { + height: 180px; + } + /* Columns are narrow and tall: status text runs up the column. */ + .db.vertical .db-status { + writing-mode: vertical-rl; + transform: rotate(180deg); + overflow: hidden; + } + .db.vertical { + --pad: 22px; + } + .db.vertical .db-grid { + left: 0; + right: 0; + bottom: calc(var(--pad) + var(--span) * var(--f)); + border-top: 1px dashed var(--divider-color, rgba(127, 127, 127, 0.3)); + } + .db.vertical .db-bar { + left: 50%; + width: 6px; + transform: translateX(-50%); + border-radius: 3px; + bottom: calc(var(--pad) + var(--span) * var(--a)); + height: calc(var(--span) * (var(--b) - var(--a))); + background: linear-gradient(to top, var(--from), var(--to)); + opacity: 0.45; + } + .db.vertical .db-dot { + left: 50%; + bottom: calc(var(--pad) + var(--span) * var(--f)); + transform: translate(-50%, 50%); + } + .db.vertical .db-label { + left: 50%; + bottom: calc(var(--pad) + var(--span) * var(--f)); + } + .db.vertical .db-label.lo { + transform: translate(-50%, calc(100% + 11px)); + } + .db.vertical .db-label.hi { + transform: translate(-50%, -11px); + } + .db.vertical .db-axis { + flex: none; + position: relative; + width: 3.5em; + height: 180px; + } + .db.vertical .db-tick { + right: 6px; + bottom: calc(var(--pad) + var(--span) * var(--f)); + transform: translateY(50%); + } + `,thumbnail:()=>Z` + + + + + + + + + `,validate(e){if(pn(e),void 0!==e.ends&&!Cp.includes(e.ends))throw new Error(`"ends" must be one of: ${Cp.join(", ")}`);if(void 0!==e.orientation&&!jp.includes(e.orientation))throw new Error(`"orientation" must be one of: ${jp.join(", ")}`);if(void 0!==e.show_now&&"boolean"!=typeof e.show_now)throw new Error('"show_now" must be true or false')},editor:function(e){const{ends:t}=Bp(e.config),o=Pi(`dumbbell-${t}`,e.hass,()=>[{type:"grid",name:"",schema:[{name:"ends",selector:xi(e.hass,"ends",Cp)},{name:"orientation",selector:xi(e.hass,"orientation",jp)}]},..."target"===t?[]:zi(e.hass),..."range"===t?[{name:"show_now",selector:{boolean:{}}}]:[]]);return Di(e,"chart",o,Zp)},disconnect:bn,render(e){const{ends:t,orientation:o,showNow:a}=Bp(e.raw),n=$a(e.raw.period);let r;if("target"===t)r=Tp(e.items,t,()=>[],0,a);else{const o=mn(e);if("ready"!==o.status)return xn(o,e)??R``;r=Tp(e.items,t,e=>o.series[e]?.points??[],o.range.start,a)}const i=Ih(r.flatMap(e=>e.marks.map(e=>e.value))),s=i?Hh(i[0],i[1],4):[],[l,c]=s.length?[s[0],s[s.length-1]]:[0,1],d=e=>c===l?.5:(e-l)/(c-l),u=function(e){const t=new Set(e.map(e=>e.unit).filter(e=>""!==e));return 1===t.size?[...t][0]:""}(e.items),h=function(e){if(e.length<2)return 0;const t=Math.abs(e[1]-e[0]);for(let o=0;o<6;o++)if(Math.abs(Math.round(t*10**o)-t*10**o)<1e-6)return o;return 6}(s),{show:p}=e.config,m=Math.max(...e.items.map(e=>e.name.length)),f=`min(${Math.min(m,16)+1}ch, 38%)`,g=_t(e.hass,"chart_aria",{style:_t(e.hass,"editor_style_dumbbell"),names:e.items.map(e=>e.name).join(", ")}),v=s.map(t=>R`${kn(t,h,e.hass)}`),y=r.map(o=>function(e,t,o,a,n,r){const i=e.entity,{show:s}=t.config,l=e.labelled[0],c=e.labelled[e.labelled.length-1],d=e=>Pp(e.kind,o)?"color-mix(in srgb, var(--primary-text-color, #212121) 30%, transparent)":Dp(e.kind,o,i);return R``}(o,e,t,d,s,n)),b=`db ${o} ${p.name?"":"no-names"} ${p.value?"":"no-values"}`;return R`
+ ${i?function(e,t,o,a,n,r){const i=t=>_t(e.hass,t),s=new Set(a.map(e=>Ce(e.entity.band.color))),l=1===s.size?[...s][0]:Ce("primary"),c="range"===t?[[i("dumbbell_low"),Fp.low,!1],...o?[[i("dumbbell_now"),Dp("now",t,a[0].entity),!0]]:[],[i("dumbbell_high"),Fp.high,!1]]:["then"===t?[Op(e.hass,n),Fp.then,!1]:[i("dumbbell_target"),Fp.target,!0],[i("dumbbell_now"),l,!1]];return R`
+ ${r?R`${r}`:W} + ${c.map(([e,t,o])=>R`${e}`)} +
`}(e,t,a,r,n,u):W} + ${"horizontal"===o?R`${y} + ${s.length?R``:W}`:R`
+ ${s.length?R``:W}${y} +
`} +
`}};function Wp(e){const t={...e,source:e.source??"statistics"};if("history"===t.source&&void 0===e.group_by){const o=e.period??{},a=(o.last??24)*({hour:1,day:24,week:168,month:720}[o.unit??"hour"]??1);t.group_by="day"===o.calendar||!o.calendar&&a<=48?"hour":"day"}return t}function Vp(e){return e.flatMap((t,o)=>o>0?[[t[0],e[o-1][1]],t]:[t])}function Yp(e,t,o){if(e.length<2)return"";const[a,n,r]="step"===o?[Vp(e),Vp(t),"straight"]:[e,t,o];return`${sn(a,r)}${sn([...n].reverse(),r).replace(/^M/,"L")}Z`}yt("en",{editor_style_range_area:"Range area",editor_mean:"Draw the average as a line",editor_range_color:"Colour"});const Gp=[...wi.map(e=>"source"===e.name?{...e,default:"statistics"}:e),{name:"curve",path:["curve"],default:"smooth"},{name:"mean",path:["mean"],default:!0},{name:"line_width",path:["line_width"],default:At},...Ei,...Ai];const Xp=[{name:"range_color",path:["color"]},{name:"curve",path:["curve"]},{name:"dash",path:["dash"],default:!1}],Jp={layout:"chart",group:"history",height:240,styles:r` + ${$n} + ${Rn} + ${fr} + ${Gn} + .plot .band { + fill-opacity: 0.22; + } + .plot .band-edge { + stroke-width: 1px; + stroke-opacity: 0.45; + } + .plot .series .line { + stroke-linecap: round; + stroke-linejoin: round; + } + .plot .series .line.dashed { + stroke-dasharray: 6 5; + } + `,thumbnail:()=>Z` + + `,validate(e){if(pn(e),Lo(e),cr(e),Ft(e),qn(e),void 0!==e.mean&&"boolean"!=typeof e.mean)throw new Error('"mean" must be true or false')},editor:function(e){const t=Pi(`range_area|${$i(e.config)}`,e.hass,()=>[...ki(e.hass,e.config),{name:"curve",selector:xi(e.hass,"curve",an)},{type:"grid",name:"",schema:[{name:"mean",selector:{boolean:{}}},{name:"line_width",selector:Vt}]},...Ci(e.hass),...Si(e.hass)]);return Di(e,"chart",t,Gp)},entityFields:e=>({schema:Pi("range-area-entity",e,()=>[{type:"grid",name:"",schema:[{name:"range_color",selector:{text:{}}},{name:"curve",selector:xi(e,"curve",an)},{name:"dash",selector:{boolean:{}}}]}]),fields:Xp}),disconnect(e){bn(e),Pn(e),sr(e)},render:function(e){const t={...e,raw:Wp(e.raw)},o=mn(t,Date.now(),{range:!0}),a=vn(o,t),n=_n(o,t),r=a===W&&n===W?W:R`${a}${n}`,i=xn(o,t),s=Wn(t.raw);if(i)return Vn("over"===s.header?{...s,header:"above"}:s,r,i);const l=o.series,c=Un(t,o.range,xo(l.map(e=>e.points))),d=!1!==t.raw.mean,u=t.raw.line_width??At,h={text:(e,o)=>{if(!o||null===o.v)return"—";const a=o=>kn(o,e.entity.decimals,t.hass),n=o.min??o.v,r=o.max??o.v;return Mn(`${a(n)} – ${a(r)}`,e.entity.unit)+(d?` · ${a(o.v)}`:"")}};d||(h.dotY=()=>null);const p=lr(t.raw),m=_t(t.hass,"chart_aria",{style:_t(t.hass,"editor_style_range_area"),names:o.series.map(e=>e.entity.name).join(", ")});return Vn(s,r,Bn({axes:c,ctx:t,content:Z`${l.map(e=>function(e,t,o,a){const n=[],r=[],i=[];for(const s of function(e){const t=[];let o=[];for(const a of e)null!==a.v?o.push({t:a.t,v:a.v,lo:a.min??a.v,hi:a.max??a.v}):(o.length&&t.push(o),o=[]);return o.length&&t.push(o),t}(e.points)){const o=s.map(e=>[t.x(e.t),t.y(e.hi)]),a=s.map(e=>[t.x(e.t),t.y(e.lo)]);n.push(Yp(o,a,e.curve)),r.push(sn(o,e.curve),sn(a,e.curve)),i.push(sn(s.map(e=>[t.x(e.t),t.y(e.v)]),e.curve))}return Z` + + + ${o?Z``:W} + `}(e,c.scales,d,u))}`,label:m,overlay:R`${"over"===s.header?Yn(r):W} + ${p?mr(t,l,c.scales,ir(t.host),Dn(t),h):W}`,hover:p?dr(t,o.range,l.map(e=>e.points)):void 0}))}},Kp=["line","area","column"];function Qp(e,t){return e.config.spark??t.spark??"line"}function em(e){const t=e.period??{},o=(t.last??24)*({hour:1,day:24,week:168,month:720}[t.unit??"hour"]??1);return(t.calendar?"day"===t.calendar:o<=48)?"hour":"day"}function tm(e,t){return void 0===e.group_by&&0!==t.length&&t.every(e=>"column"===e)?{...e,group_by:em(e),aggregate:e.aggregate??"mean"}:e}function om(e,t,o,a){const n=ta(e.points,120),r=n.filter(e=>null!==e.v).map(e=>e.v),i=function(e,t){if(0===e.length)return;let o=Math.min(...e),a=Math.max(...e);return t?(o=Math.min(o,0),a=Math.max(a,0),o===a?[o,o+1]:[o,a]):([o,a]=wo([o,a],.1),[o,a])}(r,"column"===t);if(!i)return Z``;const s=$o([o.start,o.end],[0,100]),l=$o(i,[23,1]),c=l(Math.min(Math.max(0,i[0]),i[1]));if("column"===t){const t=[];return n.forEach((e,a)=>{if(null===e.v)return;const r=e.end??n[a+1]?.t??o.end,i=s(e.t),d=Math.max(0,Math.min(100,s(r))-i);if(d<=0)return;const u=Math.min(l(e.v),c);t.push(Z``)}),Z`${t}`}const d=ln(n).map(e=>e.map(e=>[s(e.t),l(e.v)])),u=d.map(t=>sn(t,e.curve)).join(""),h="area"===t?d.filter(e=>e.length>1).map(t=>`${sn(t,"step"===e.curve?"straight":e.curve)}L${t[t.length-1][0]},${c}L${t[0][0]},${c}Z`).join(""):"";return Z`${h?Z``:W} + `}yt("en",{editor_style_sparkline:"Sparklines",editor_spark:"Sparkline",editor_spark_line:"Line",editor_spark_area:"Area",editor_spark_column:"Columns",editor_spark_bar:"Bar under the name and value",editor_spark_color:"Colour",spark_aria:"Trend of {name}"});const am=[...wi,{name:"spark",path:["spark"],default:"line"},{name:"curve",path:["curve"],default:"smooth"},{name:"line_width",path:["line_width"],default:1.5},{name:"spark_bar",path:["bar"],default:!1}];const nm=[{name:"spark",path:["spark"]},{name:"spark_color",path:["color"]}];const rm={layout:"chart",group:"history",height:120,styles:r` + ${$n} + /* One grid for every row, so the sparklines line up whatever each value's width. */ + .spark-rows { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(64px, 38%) auto; + column-gap: 12px; + } + .spark-rows.icons { + grid-template-columns: auto minmax(0, 1fr) minmax(64px, 38%) auto; + } + .spark-rows.bars { + grid-template-columns: minmax(0, 1fr) minmax(64px, 38%); + } + .spark-rows.icons.bars { + grid-template-columns: auto minmax(0, 1fr) minmax(64px, 38%); + } + .spark-row { + grid-column: 1 / -1; + display: grid; + grid-template-columns: subgrid; + align-items: center; + padding: 6px 0; + min-height: 28px; + border-radius: 6px; + cursor: pointer; + } + .spark-row:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: 2px; + } + .spark-row .icon { + color: var(--state-icon-color, var(--secondary-text-color)); + --mdc-icon-size: 22px; + } + .spark-row .name { + font-size: 14px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + } + .spark-row .value { + font-size: 14px; + font-weight: 500; + white-space: nowrap; + text-align: right; + min-width: calc(var(--cd-value-ch, 4) * 1ch); + font-variant-numeric: tabular-nums; + } + .spark-row.unavailable .value { + color: var(--secondary-text-color); + font-weight: 400; + font-size: 13px; + max-width: 7em; + overflow: hidden; + text-overflow: ellipsis; + } + /* Narrow cards: the names keep their room, the sparklines give some up. */ + @container (max-width: 360px) { + .spark-rows { + grid-template-columns: minmax(0, 1fr) minmax(48px, 30%) auto; + } + .spark-rows.icons { + grid-template-columns: auto minmax(0, 1fr) minmax(48px, 30%) auto; + } + .spark-rows.bars { + grid-template-columns: minmax(0, 1fr) minmax(48px, 30%); + } + .spark-rows.icons.bars { + grid-template-columns: auto minmax(0, 1fr) minmax(48px, 30%); + } + } + .spark { + width: 100%; + height: 28px; + overflow: visible; + } + .spark path { + vector-effect: non-scaling-stroke; + stroke-linecap: round; + stroke-linejoin: round; + } + .spark .spark-fill { + fill-opacity: 0.22; + } + .spark-body { + min-width: 0; + } + .spark-body .top { + display: flex; + justify-content: space-between; + gap: 8px; + margin-bottom: 6px; + } + .spark-body .value { + font-weight: 400; + font-size: 13px; + color: var(--secondary-text-color); + min-width: 0; + } + .spark-track { + position: relative; + height: 8px; + border-radius: 4px; + overflow: hidden; + background: var(--cd-track); + } + .spark-bar { + position: absolute; + top: 0; + bottom: 0; + left: calc(var(--start) * 100%); + width: calc((var(--end) - var(--start)) * 100%); + border-radius: 4px; + background: var(--c-start); + background-image: linear-gradient(90deg, var(--c-start), var(--c-end)); + } + .spark-target { + position: absolute; + top: 0; + bottom: 0; + width: 2px; + left: calc(var(--target) * (100% - 2px)); + background: var(--primary-text-color); + } + .spark-message { + color: var(--secondary-text-color); + font-size: 0.875em; + padding: 4px 0; + } + .spark-message.error { + color: var(--error-color, #db4437); + } + `,thumbnail:()=>Z` + + + + + `,validate:function(e){pn(e);const t=[e.spark,...(e.entities??[]).map(e=>"object"==typeof e?e.spark:void 0)];for(const a of t)if(void 0!==a&&!Kp.includes(a))throw new Error(`"spark" must be one of: ${Kp.join(", ")}`);if(void 0!==e.bar&&"boolean"!=typeof e.bar)throw new Error('"bar" must be true or false');const o=e.line_width;if(void 0!==o&&("number"!=typeof o||oEt))throw new Error('"line_width" must be a number from 0.5 to 12')},editor:function(e){const t=Pi(`sparkline|${$i(e.config)}`,e.hass,()=>[...ki(e.hass,e.config),{type:"grid",name:"",schema:[{name:"spark",selector:xi(e.hass,"spark",Kp)},{name:"curve",selector:xi(e.hass,"curve",an)}]},{type:"grid",name:"",schema:[{name:"line_width",selector:Vt},{name:"spark_bar",selector:{boolean:{}}}]}]);return Di(e,"chart",t,am)},entityFields:e=>({schema:Pi("sparkline-entity",e,()=>[{type:"grid",name:"",schema:[{name:"spark",selector:xi(e,"spark",Kp)},{name:"spark_color",selector:{text:{}}}]}]),fields:nm}),disconnect(e){bn(e)},render:function(e){const t=e.items.map(t=>Qp(t,e.raw)),o={...e,raw:tm(e.raw,t)},a=mn(o),{show:n}=o.config,r=!0===o.raw.bar,i=o.raw.line_width??1.5,s="error"===a.status?R`
${_t(o.hass,"chart_error",{error:a.error??""})}
`:W,l=a.series.map((s,l)=>{const c=s.entity,d=c.config.color,u=Ce(d||c.band.color),h="column"===Qp(c,e.raw)&&void 0===o.raw.group_by,p={...s,color:u,points:h?(m=s.points,f=o.raw,g=a.range,v=o.hass,Va(m,pa(em(f),g.start,g.end,fa(v)),f.aggregate??"mean",Date.now())):s.points};var m,f,g,v;const y=c.config.line_width??i,b=n.name?R`${c.name}`:W,_=n.value||!c.available?R`${c.display}`:W;return R`
+ ${n.icon?R``:W} + ${r?R`
+
${b}${_}
+ ${function(e,t){const o=je(e.band.color);return R`
+
+ ${t&&null!==e.targetPosition&&e.available?R`
`:W} +
`}(c,n.target)} +
`:b} + + ${"ready"===a.status?om(p,t[l],a.range,y):W} + + ${r?W:_} +
`});return R`${vn(a,o)}${s} +
${l}
`}},im=["wiggle","centre","share","zero"],sm=["inside_out","listed"];function lm(e){return e.map(e=>null===e||!Number.isFinite(e)||e<0?0:e)}function cm(e,t,o){const a="share"===o?function(e){const t=e[0]?.length??0,o=Array.from({length:t},(t,o)=>e.reduce((e,t)=>e+t[o],0));return e.map(e=>e.map((e,t)=>o[t]>0?e/o[t]*100:0))}(e):e,n=t.map(e=>a[e]);let r=function(e,t){const o=e[0]?.length??0,a=Array.from({length:o},(t,o)=>e.reduce((e,t)=>e+t[o],0));if("zero"===t||"share"===t)return new Array(o).fill(0);if("centre"===t)return a.map(e=>-e/2);const n=new Array(o).fill(0);for(let l=1;le+s)}(n,o);const i=new Array(e.length);for(const s of t){const e=r.map((e,t)=>e+a[s][t]);i[s]={lower:r,upper:e},r=e}return i}function dm(e){let t=0,o=-1/0;return e.upper.forEach((a,n)=>{const r=a-e.lower[n];r>o&&(o=r,t=n)}),{at:t,thickness:Math.max(0,o)}}function um(e,t,o){const a=Math.min(e.range.end,t);if(e.group){const t=e.series[0]?.points??[],o=t.map((e,t)=>t).filter(e=>t[e].t(t[e].t+Math.min(t[e].end??t[e].t,a))/2),values:e.series.map(e=>o.map(t=>e.points[t]?.v??null))}}let n=Math.min(240,Math.max(24,Math.round(o/4)));if("statistics"===e.source){const t=Math.max(1,...e.series.map(e=>e.points.filter(e=>!e.carried).length));n=Math.min(n,t)}const r=function(e,t,o){const a=Math.max(e.start+1,Math.min(e.end,t)),n=Math.max(1,Math.round(o)),r=(a-e.start)/n,i=[];for(let s=0;s(e+r[t+1])/2),values:e.series.map(t=>function(e,t,o,a){return("statistics"===o?Ya(e,t,"mean",a):Va(e,t,"mean",a)).map(e=>e.v)}(t.points,r,e.source,a))}}function hm(e){return e.baseline??"wiggle"}yt("en",{editor_style_streamgraph:"Streamgraph",editor_baseline:"Shape",editor_baseline_wiggle:"Streamgraph (flowing)",editor_baseline_centre:"Centred",editor_baseline_share:"Share of the total (100 %)",editor_baseline_zero:"Stacked from zero",editor_stream_order:"Order",editor_stream_order_inside_out:"Biggest in the middle",editor_stream_order_listed:"As listed, first at the bottom",editor_stream_labels:"Names on the streams",editor_stream_curve:"Edges",editor_fill_color:"Colour",stream_total:"Total"});const pm=e=>Math.round(1e4*e)/100+"%";function mm(e,t,o,a,n,r,i){const[s,l]=a.domain,c=Wn(n.raw).compact?110:180,d=Dn(n),u=e=>e/(l-s)*c,h=new Map,p=[];for(const f of e.series){const e=t[f.index],{at:s,thickness:l}=dm(e);if(u(l)<18)continue;const m=i[f.index][s],g=r?`${kn(e.upper[s]-e.lower[s],0,n.hass)} %`:null===m?"":Mn(kn(m,f.entity.decimals,n.hass),f.entity.unit),v=`${f.entity.name} ${g}`.trim();h.set(f.index,v),p.push({key:f.index,x:o[s]/cn*d,y:a.y((e.upper[s]+e.lower[s])/2)/dn*c,width:6.2*v.length,thickness:u(l)})}const m=function(e,t){const o=[],a=new Map;for(const n of[...e].sort((e,t)=>t.thickness-e.thickness||e.key-t.key)){if(n.width>t-4)continue;const e=Math.min(Math.max(2,n.x-n.width/2),t-n.width-2),r={left:e,right:e+n.width,top:n.y-6.5,bottom:n.y+6.5},i=o.some(e=>r.lefte.left-4&&r.tope.top);i||(o.push(r),a.set(n.key,e))}return a}(p,d);return R``}function fm(e,t,o,a,n,r,i,s){if(void 0===i)return W;const l=nr(o.times.map(e=>({t:e})),i);if(l<0)return W;const c=r[l]/cn,d=or(c,Dn(e)),u=[...n].reverse().map(e=>t.series[e]),h=a.map(e=>e.upper[l]-e.lower[l]),p=o.values.reduce((e,t)=>e+Math.max(0,t[l]??0),0),m=new Set(t.series.map(e=>e.entity.unit));return R``}const gm=[...wi,{name:"baseline",path:["baseline"],default:"wiggle"},{name:"stream_order",path:["stream_order"],default:"inside_out"},{name:"stream_curve",path:["curve"],default:"smooth"},{name:"stream_labels",path:["labels"],default:!0},...Ei,...Ai],vm=new Set(["y_axis","grid_horizontal","y_min","y_max"]);function ym(e){return e.map(e=>{const t=e;if(!vm.has(t.name)){if(t.schema){const e=t.schema.filter(e=>!vm.has(e.name));return e.length?{...t,schema:e}:void 0}return t}}).filter(e=>void 0!==e)}const bm={schema:[{name:"fill_color",selector:{text:{}}}],fields:[{name:"fill_color",path:["color"]}]},_m={layout:"chart",group:"totals",height:240,styles:r` + ${$n} + ${Rn} + ${fr} + ${Gn} + .style-streamgraph .stream { + cursor: pointer; + stroke: var(--card-background-color, #fff); + stroke-width: 1px; + stroke-linejoin: round; + } + .stream-labels { + position: absolute; + inset: 0; + pointer-events: none; + overflow: hidden; + } + .stream-label { + position: absolute; + transform: translateY(-50%); + font-size: 11px; + font-weight: 500; + line-height: 1; + white-space: nowrap; + color: #fff; + } + .stream-label.dark { + color: rgba(0, 0, 0, 0.8); + } + .stream-label.theme { + color: var(--primary-text-color); + } + .tooltip-row.total { + border-top: 1px solid var(--divider-color, rgba(127, 127, 127, 0.3)); + margin-top: 2px; + padding-top: 2px; + } + `,thumbnail:()=>Z` + + + `,validate(e){if(pn(e),Lo(e),cr(e),qn(e),void 0!==e.baseline&&!im.includes(e.baseline))throw new Error(`"baseline" must be one of: ${im.join(", ")}`);if(void 0!==e.stream_order&&!sm.includes(e.stream_order))throw new Error(`"stream_order" must be one of: ${sm.join(", ")}`);if(void 0!==e.labels&&"boolean"!=typeof e.labels)throw new Error('"labels" must be true or false')},editor:function(e){const t=function(e){const t=hm(e);return`${$i(e)}|${"wiggle"===t||"centre"===t?"floating":"axis"}`}(e.config),o=Pi(`streamgraph|${t}`,e.hass,()=>{const o=Ci(e.hass);return[...ki(e.hass,e.config),{type:"grid",name:"",schema:[{name:"baseline",selector:xi(e.hass,"baseline",im)},{name:"stream_order",selector:xi(e.hass,"stream_order",sm)}]},{type:"grid",name:"",schema:[{name:"stream_curve",selector:xi(e.hass,"curve",["smooth","straight","step"])},{name:"stream_labels",selector:{boolean:{}}}]},...t.endsWith("floating")?ym(o):o,...Si(e.hass)]});return Di(e,"chart",o,gm)},entityFields:()=>bm,disconnect(e){bn(e),Pn(e),sr(e)},render(e){const t=Date.now(),o=mn(e,t),a=vn(o,e),n=_n(o,e),r=a===W&&n===W?W:R`${a}${n}`,i=xn(o,e),s=Wn(e.raw);if(i)return Vn("over"===s.header?{...s,header:"above"}:s,r,i);const l=hm(e.raw),c="share"===l,d="wiggle"===l||"centre"===l,u=um(o,t,Dn(e)),h=u.values.map(lm),p="listed"===(e.raw.stream_order??"inside_out")?h.map((e,t)=>t):function(e){const t=e.map((e,t)=>{let o=0;for(let a=1;ae[o]&&(o=a);return{i:t,at:o,sum:e.reduce((e,t)=>e+t,0)}});t.sort((e,t)=>e.at-t.at||e.i-t.i);let o=0,a=0;const n=[],r=[];for(const i of t)ot?.04*(o-t):1;g={...e.raw,y_axis:!1,y_min:t-a,y_max:o+a,gridlines:{...e.raw.gridlines??{},horizontal:!1}}}else c?g={...e.raw,y_min:0,y_max:100}:void 0===e.raw.y_min&&(g={...e.raw,y_min:0});const v=Un({...e,raw:g},o.range,f),{scales:y}=v,b=u.times.map(e=>y.x(e)),_=e.raw.curve??"smooth",x=Math.min(o.range.end,t),w=[y.x(o.range.start),...b,y.x(x)],$=0===u.times.length?Z``:Z`${p.map(e=>{const t=o.series[e];return Z`[t,o.y(e.upper[a])]),r=t.map((t,a)=>[t,o.y(e.lower[a])]).reverse();return 0===n.length?"":`${sn(n,a)}L${sn(r,a).slice(1)}Z`}((e=>({lower:[e.lower[0],...e.lower,e.lower[e.lower.length-1]],upper:[e.upper[0],...e.upper,e.upper[e.upper.length-1]]}))(m[e]),w,y,_)} + fill=${t.color} + >`})}`,k=lr(e.raw),M=_t(e.hass,"chart_aria",{style:_t(e.hass,"editor_style_streamgraph"),names:o.series.map(e=>e.entity.name).join(", ")}),z=u.times.map(e=>({t:e,v:0}));return Vn(s,r,Bn({axes:v,ctx:{...e},content:$,label:M,overlay:R`${"over"===s.header?Yn(r):W} + ${!1!==e.raw.labels&&u.times.length?mm(o,m,b,y,e,c,u.values):W} + ${k?fm(e,o,u,m,p,b,ir(e.host),c):W}`,hover:k?dr(e,o.range,[z]):void 0}))}};const xm=["columns","line"],wm=.7;function $m(e){return{last:14,unit:"day",...e.period??{}}}function km(e){return e.group_by??"day"}function Mm(e,t=Date.now()){const o=$a($m(e.raw)),a=e.raw.source??"statistics";const n=km(e.raw),r=e.raw.aggregate??"mean",i=fa(e.hass),s=Ma(o,t,i,o.navigate?Aa(e.host,o):0),l=ua(n,s.start,i),c=pa(n,l,s.end,i),d={start:l,end:c[c.length-1]},u=Math.min(s.end,t),h=e.items.map(e=>({entity:e.entityId,attribute:e.config.attribute})),p=on(e.host,e.requestUpdate),m="statistics"===a?function(e,t,o){let a="hour";return"hour"===e?a="5minute":"day"!==e&&t.timeZone===o&&(a="day"),{period:a,types:["mean","min","max","state"],value:"mean"}}(n,i,e.hass.config?.time_zone):void 0,f=p.get(e.hass,h,{start:l,end:s.end},a,{live:s.live,statistics:m});let g=[];const v=e.items.map((e,t)=>{const o=e.config,n=f.data.get(Ra(h[t]))??[];let i;return 0===t?(g="statistics"===a?function(e,t,o){const a=[];let n=0;for(let r=0;r+1=o)continue;const n="number"==typeof a.max?Math.max(a.max,a.v):a.v,r="number"==typeof a.min?Math.min(a.min,a.v):a.v;l?(l.high=Math.max(l.high,n),l.low=Math.min(l.low,r),l.close=a.v):l={t:i,end:s,open:a.v,high:n,low:r,close:a.v}}a.push(l)}return a}(n,c,u):function(e,t,o){const[a,n,r,i]=["first","max","min","last"].map(a=>Va(e,t,a,o));return a.map((e,t)=>null===e.v||null===n[t].v||null===r[t].v||null===i[t].v?null:{t:e.t,end:e.end??e.t,open:e.v,high:n[t].v,low:r[t].v,close:i[t].v})}(n,c,u),i=g.map((e,t)=>({t:c[t],end:c[t+1],v:e?e.close:null}))):i=function(e,t,o,a,n){return"history"===a?Va(e,t,o,n):"sum"===o||"change"===o?Va(e,t,"change",n):Ya(e,t,o,n)}(n,c,r,a,u),{index:t,entity:e,points:i,color:Ce(o.color??(0===t?un[0]:"grey")),curve:"smooth",dash:!1}});return{status:f.status,error:f.error,range:d,period:o,source:a,series:v,live:s.live,back:s.back,zone:i,group:{unit:n,aggregate:r},candles:g,unit:n}}yt("en",{editor_style_candlestick:"Candlestick",editor_candle_by:"One candle per",editor_under_aggregate:"Entities underneath: combine readings with",editor_up_color:"Colour when it rose",editor_down_color:"Colour when it fell",editor_underneath:"Draw the other entities underneath as",editor_underneath_columns:"Columns",editor_underneath_line:"A line",candle_open:"Open",candle_high:"High",candle_low:"Low",candle_close:"Close",candle_week_of:"Week of {date}"});const zm=e=>Math.round(1e4*e)/100+"%";function Am(e,t,o,a){if(void 0===a)return W;const n=t.series[0].points.map(e=>({t:(e.t+(e.end??e.t))/2})),r=nr(n,a);if(r<0)return W;const i=t.candles[r],s=o(n[r].t)/cn,l=or(s,Dn(e)),c=t.series[0].entity,d=(t,o=c)=>Mn(kn(t,o.decimals,e.hass),o.unit),u=i?[[_t(e.hass,"candle_open"),d(i.open)],[_t(e.hass,"candle_high"),d(i.high)],[_t(e.hass,"candle_low"),d(i.low)],[_t(e.hass,"candle_close"),d(i.close)]]:[[c.name,"—"]];for(const h of t.series.slice(1)){const e=h.points[r]?.v;u.push([h.entity.name,null==e?"—":d(e,h.entity),h.color])}return R``}const Sm=[...wi.map(e=>"period_last"===e.name?{...e,default:14}:"period_unit"===e.name?{...e,default:"day"}:"source"===e.name?{...e,default:"statistics"}:"group_by"===e.name?{name:"candle_by",path:["group_by"],default:"day"}:"aggregate"===e.name?{name:"under_aggregate",path:["aggregate"],default:"mean"}:e),{name:"up_color",path:["up_color"],default:"green"},{name:"down_color",path:["down_color"],default:"red"},{name:"underneath",path:["underneath"],default:"columns"},...Ei,...Ai];const Em={schema:[{name:"fill_color",selector:{text:{}}}],fields:[{name:"fill_color",path:["color"]}]};yt("en",{editor_fill_color:"Colour"});const Cm={layout:"chart",group:"history",height:240,styles:r` + ${$n} + ${Rn} + ${fr} + ${Gn} + .candle { + cursor: pointer; + } + .candle .wick { + stroke-width: 1.5px; + } + .under-columns rect { + opacity: 0.45; + } + .under-line { + stroke-width: 2px; + stroke-linejoin: round; + opacity: 0.7; + } + `,thumbnail:()=>Z` + + + + + + + + + + `,validate(e){pn(e),Lo(e),cr(e),qn(e),$a($m(e));for(const t of["up_color","down_color"])if(void 0!==e[t]&&("string"!=typeof e[t]||""===e[t]))throw new Error(`"${t}" must be a colour name or CSS colour`);if(void 0!==e.underneath&&!xm.includes(e.underneath))throw new Error(`"underneath" must be one of: ${xm.join(", ")}`)},editor:function(e){const t=Pi(`candlestick|${e.config.period?.calendar?"calendar":"rolling"}`,e.hass,()=>{return[...(t=e.hass,o=e.config,ki(t,{...o,group_by:km(o)}).map(e=>{const o=e;return o.schema?.some(e=>"group_by"===e.name)?{...o,schema:[{name:"candle_by",selector:xi(t,"group_by",Za)},{name:"under_aggregate",selector:xi(t,"aggregate",qa)}]}:e})),{type:"grid",name:"",schema:[{name:"up_color",selector:{text:{}}},{name:"down_color",selector:{text:{}}}]},{name:"underneath",selector:xi(e.hass,"underneath",xm)},...Ci(e.hass),...Si(e.hass)];var t,o});return Di(e,"chart",t,Sm)},entityFields:()=>Em,disconnect(e){bn(e),Pn(e),sr(e)},render(e){const t=Mm(e),o=vn(t,e),a=_n(t,e),n=o===W&&a===W?W:R`${o}${a}`,r=xn(t,e),i=Wn(e.raw);if(r)return Vn("over"===i.header?{...i,header:"above"}:i,n,r);const s=Un(e,t.range,function(e){let t=1/0,o=-1/0;for(const a of e)a&&(t=Math.min(t,a.low),o=Math.max(o,a.high));return t===1/0?void 0:[t,o]}(t.candles)),l=t.series.length>1;l&&(s.scales.y=$o(s.value.domain,[146,0]));const{x:c,y:d}=s.scales,u=Ce(e.raw.up_color??"green"),h=Ce(e.raw.down_color??"red"),p=t.series[0],m=t.candles.map(e=>{if(!e)return W;const t=c(e.t),o=c(e.end)-t,a=t+o/2,n=e.close>=e.open?u:h,r=d(Math.max(e.open,e.close)),i=Math.max(1.5,d(Math.min(e.open,e.close))-r);return Z` + + + `}),f=e.raw.underneath??"columns",g=Z`${l?function(e,t,o){const a=e.series.slice(1);return Z`${a.map((e,n)=>{const r=e.points.map(e=>e.v).filter(e=>null!==e),i=Math.min(0,...r),s=Math.max(0,...r),l=$o([i,s===i?i+1:s],[dn,156]);if("line"===o){const o=ln(e.points).map(e=>sn(e.map(e=>[t((e.t+(e.end??e.t))/2),l(e.v)]),"straight")).join("");return Z``}return Z` + ${e.points.map(e=>{if(null===e.v)return W;const o=t(e.t),r=t(e.end??e.t)-o,i=r*wm/a.length,s=o+r*(1-wm)/2+n*i,c=l(Math.max(0,e.v));return Z``})} + `})}`}(t,c,f):W}${m}`,v=lr(e.raw),y=_t(e.hass,"chart_aria",{style:_t(e.hass,"editor_style_candlestick"),names:t.series.map(e=>e.entity.name).join(", ")}),b=p.points.map(e=>({t:(e.t+(e.end??e.t))/2,v:e.v}));return Vn(i,n,Bn({axes:s,ctx:e,content:g,label:y,overlay:R`${"over"===i.header?Yn(n):W} + ${v?Am(e,t,c,ir(e.host)):W}`,hover:v?dr(e,t.range,[b]):void 0}))}},jm=["horizontal","vertical"],Nm=1e3,Lm=100,Tm=e=>"number"==typeof e&&Number.isFinite(e);yt("en",{editor_orientation:"Direction",editor_orientation_horizontal:"Across",editor_orientation_vertical:"Upright",editor_value_axis:"Value labels",editor_value_grid:"Lines at each value label",editor_value_min:"Lowest value shown",editor_value_max:"Highest value shown",dist_median:"Median",dist_middle:"Middle half",dist_range:"Lowest – highest",dist_count:"Readings"});const Om=[{name:"orientation",path:["orientation"],default:"horizontal"},{name:"value_axis",path:["value_axis"],default:!0},{name:"value_grid",path:["value_grid"],default:!0},{name:"value_min",path:["value_min"]},{name:"value_max",path:["value_max"]},{name:"tooltip",path:["tooltip"],default:!0},{name:"compact",path:["compact"],default:!1}];function Fm(e){return[{name:"orientation",selector:xi(e,"orientation",jm)},{type:"grid",name:"",schema:[{name:"value_axis",selector:{boolean:{}}},{name:"value_grid",selector:{boolean:{}}},{name:"value_min",selector:{number:{mode:"box",step:"any"}}},{name:"value_max",selector:{number:{mode:"box",step:"any"}}}]},{type:"grid",name:"",schema:[{name:"tooltip",selector:{boolean:{}}},{name:"compact",selector:{boolean:{}}}]}]}function Pm(e){for(const t of["compact","tooltip"])if(void 0!==e[t]&&"boolean"!=typeof e[t])throw new Error(`"${t}" must be true or false`);if(void 0!==e.orientation&&!jm.includes(e.orientation))throw new Error(`"orientation" must be one of: ${jm.join(", ")}`);for(const t of["value_axis","value_grid"])if(void 0!==e[t]&&"boolean"!=typeof e[t])throw new Error(`"${t}" must be true or false`);for(const t of["value_min","value_max"])if(void 0!==e[t]&&!Tm(e[t]))throw new Error(`"${t}" must be a number`);if(Tm(e.value_min)&&Tm(e.value_max)&&e.value_min>=e.value_max)throw new Error('"value_min" must be below "value_max"')}function Dm(e){return{orientation:e.orientation??"horizontal",axis:!1!==e.value_axis,grid:!1!==e.value_grid,min:Tm(e.value_min)?e.value_min:void 0,max:Tm(e.value_max)?e.value_max:void 0}}function Um(e){return Math.min(140,Math.max(64,Math.round(.3*(e-32))))}function Hm(e,t,o,a=64){const n=Dm(e.raw),r=!0===e.raw.compact,i=Fn(e),s=Math.max(1,t);if("horizontal"===n.orientation){const e=$o(o.domain,[0,Nm]);return{orientation:n.orientation,count:s,width:Nm,height:s*Lm,at:(t,o,a=0)=>[e(t),(o+.5+a)*Lm],valuePx:Math.max(40,i-32-Um(i)-8),bandPx:Math.min(r?Math.round(.69*a):a,s>12?28:s>6?40:1/0)}}const l=$o(o.domain,[Nm,0]),c=Math.max(40,i-32-(n.axis?40:0));return{orientation:n.orientation,count:s,width:s*Lm,height:Nm,at:(e,t,o=0)=>[(t+.5+o)*Lm,l(e)],valuePx:r?110:180,bandPx:c/s}}function Im(e,t){return"horizontal"===t?Math.max(2,Math.min(8,Math.floor(e/64))):e>140?4:3}function Bm(e,t,o,a){const n=Dm(t),r=Do(e,{min:n.min,max:n.max},o);return{domain:r.domain,ticks:r.ticks,format:e=>kn(e,r.decimals,a)}}function Rm(e,t){const o=function(e){for(const t of[1,2,3,4,6,12])if(t>=e)return t;return Math.max(12,Oo(e,1))}(24/Math.max(1,e)),a=[];for(let i=0;i<=24;i+=o)a.push(i);let n;const r={hour:"numeric",minute:"2-digit",timeZone:"UTC",hour12:Go(t)};try{n=new Intl.DateTimeFormat(t?.locale?.language??t?.language??"en",r)}catch{n=new Intl.DateTimeFormat("en",r)}return{domain:[0,24],ticks:a,format:e=>n.format(Date.UTC(2e3,0,1,0,Math.round(60*e)))}}const Zm=e=>Math.round(1e4*e)/100+"%",qm=new WeakMap;function Wm(e){return qm.get(e)}function Vm(e){qm.delete(e)}function Ym(e,t,o,a,n){const r=(t+.5)/e.count,i="horizontal"===e.orientation?r>.5?`top:auto;bottom:${Zm(1-t/e.count)};right:0`:`top:${Zm((t+1)/e.count)};right:0`:r>.5?`top:0;right:${Zm(1-t/e.count)}`:`top:0;left:${Zm((t+1)/e.count)}`;return R``}function Gm({ctx:e,geometry:t,categories:o,scale:a,content:n,label:r,overlay:i=W,hover:s=!1}){const l=Dm(e.raw),c="horizontal"===t.orientation,d=a.ticks.filter(e=>e>=a.domain[0]&&e<=a.domain[1]),u=l.grid?Z`${d.map(e=>{const[o,a]=t.at(e,0);return c?Z``:Z``})}`:W,h=l.axis&&d.length?R`
+ ${c?W:R``} + ${d.map(e=>{const o=(e=>{const[o,a]=t.at(e,0);return c?o/t.width:a/t.height})(e);return R`.96?"end":"":o<.08?"top":""}" style="${c?"left":"top"}:${Zm(o)}" + >${a.format(e)}`})} +
`:W,p=s?function(e,t){const o=t=>{qm.get(e.host)!==t&&(void 0===t?qm.delete(e.host):qm.set(e.host,t),e.requestUpdate())};return{move(e){const a=e.currentTarget.getBoundingClientRect(),n="horizontal"===t.orientation,r=n?a.height:a.width,i=n?e.clientY-a.top:e.clientX-a.left;r>0&&o(Math.min(t.count-1,Math.max(0,Math.floor(i/r*t.count))))},leave(){o(void 0)}}}(e,t):void 0,m=R`
+ ${o.map(e=>R`
+ ${e.name} + ${e.detail?R`${e.detail}`:W} +
`)} +
`,f=c?`height:${t.count*t.bandPx}px`:`height:${t.valuePx}px`,g=c&&t.bandPx<40;return R`
+ ${c?m:h} +
+ + ${u} + ${n} + + ${i} +
+ ${c?h:m} +
`}const Xm=r` + .cat-plot { + display: grid; + gap: 0 8px; + } + .cat-plot.horizontal { + grid-template-columns: clamp(64px, 30%, 140px) minmax(0, 1fr); + } + .cat-plot.vertical { + grid-template-columns: auto minmax(0, 1fr); + } + .cat-plot .plot-area { + position: relative; + min-width: 0; + } + .cat-plot.horizontal .plot-area, + .cat-plot.vertical .plot-area { + grid-column: 2; + grid-row: 1; + } + .cat-plot .plot { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + overflow: visible; + } + .cat-plot .plot path, + .cat-plot .plot line, + .cat-plot .plot rect { + vector-effect: non-scaling-stroke; + } + .cat-plot .grid line { + stroke: var(--divider-color, rgba(127, 127, 127, 0.25)); + stroke-width: 1; + } + .cat-content { + opacity: 0; + transition: opacity var(--cd-duration) var(--cd-easing); + } + :host([ready]) .cat-content { + opacity: 1; + } + :host([static]) .cat-content { + transition: none; + } + .cat-labels { + display: flex; + min-width: 0; + } + .horizontal .cat-labels { + grid-column: 1; + grid-row: 1; + flex-direction: column; + } + .vertical .cat-labels { + grid-column: 2; + grid-row: 2; + padding-top: 4px; + } + .cat-label { + display: flex; + flex-direction: column; + justify-content: center; + min-width: 0; + flex: 1 1 0; + cursor: pointer; + border-radius: 4px; + } + .vertical .cat-label { + align-items: center; + text-align: center; + } + .cat-label:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: 1px; + } + .cat-name, + .cat-detail { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 100%; + } + .cat-name { + font-size: 0.875em; + } + .cat-name .swatch { + display: inline-block; + width: 8px; + height: 8px; + margin-right: 6px; + vertical-align: middle; + } + .cat-detail { + font-size: 0.75em; + color: var(--secondary-text-color); + font-variant-numeric: tabular-nums; + } + .frame.compact .cat-detail, + .cat-plot.dense .cat-detail { + display: none; + } + .cat-axis { + position: relative; + } + .horizontal .cat-axis { + grid-column: 2; + grid-row: 2; + height: 18px; + } + .horizontal .cat-axis .axis-label { + top: 5px; + transform: translateX(-50%); + } + .horizontal .cat-axis .axis-label.start { + transform: none; + } + .horizontal .cat-axis .axis-label.end { + transform: translateX(-100%); + } + .vertical .cat-axis { + grid-column: 1; + grid-row: 1; + min-width: 24px; + } + .vertical .cat-axis .axis-label { + right: 0; + transform: translateY(-50%); + } + .vertical .cat-axis .axis-label.top { + transform: none; + } + .cat-plot .axis-label { + position: absolute; + font-size: 11px; + line-height: 1; + color: var(--secondary-text-color); + white-space: nowrap; + pointer-events: none; + font-variant-numeric: tabular-nums; + } + .cat-sizer { + display: block; + height: 0; + overflow: hidden; + visibility: hidden; + font-size: 11px; + font-variant-numeric: tabular-nums; + white-space: nowrap; + } + .band-tooltip { + position: absolute; + min-width: 120px; + } + .band-tooltip .tooltip-time { + display: flex; + align-items: center; + gap: 6px; + } + .band-tooltip .swatch { + width: 8px; + height: 8px; + border-radius: 50%; + } +`;function Jm(e,t,o){const a=[];for(let n=0;n0&&n===e.length-1&&r.t>=o)continue;const s=t?Math.max(1,Math.min(i,o)-r.t):1;a.push({t:r.t,v:r.v,w:s,real:!0!==r.carried})}return a}function Km(e){return[...e].sort((e,t)=>e.v-t.v)}function Qm(e,t){const o=e.length;if(0===o)return Number.NaN;if(1===o)return e[0].v;const a=e.reduce((e,t)=>e+t.w,0),n=Math.min(1,Math.max(0,t))*a;let r=0,i=-1/0,s=e[0].v;for(const l of e){const e=r+l.w/2;if(n<=e)return i===-1/0?l.v:s+(l.v-s)*(n-i)/(e-i||1);r+=l.w,i=e,s=l.v}return e[o-1].v}function ef(e){if(0===e.length)return;const t=Km(e),o=Qm(t,.25),a=Qm(t,.5),n=Qm(t,.75),r=1.5*(n-o),i=t.filter(e=>e.v>=o-r&&e.v<=n+r),s=t.reduce((e,t)=>e+t.w,0);return{n:e.length,min:t[0].v,max:t[t.length-1].v,q1:o,median:a,q3:n,low:i.length?i[0].v:o,high:i.length?i[i.length-1].v:n,mean:s>0?t.reduce((e,t)=>e+t.v*t.w,0)/s:a}}function tf(e){const t=e.length;if(0===t)return 1;const o=Km(e),a=o.reduce((e,t)=>e+t.w,0),n=o.reduce((e,t)=>e+t.v*t.w,0)/a,r=Math.sqrt(o.reduce((e,t)=>e+t.w*(t.v-n)**2,0)/a),i=Qm(o,.75)-Qm(o,.25),s=i>0?Math.min(r,i/1.34):r,l=o.reduce((e,t)=>e+t.w*t.w,0),c=l>0?a*a/l:t;let d=1/0;for(let h=1;h0&&e0?Math.max(u,d===1/0?0:d):d!==1/0?d:.01*Math.abs(n)||1}function of(e,t,o,a=64){if(0===e.length)return[];let n=1/0,r=-1/0;for(const u of e)n=Math.min(n,u.v),r=Math.max(r,u.v);const i=Math.max(o[0],n-2*t),s=Math.min(o[1],r+2*t);if(!(s>i))return[];const l=Array.from({length:a+1},(e,t)=>i+(s-i)*t/a),c=function(e,t,o){const a=e.reduce((e,t)=>e+t.w,0);if(0===a||t<=0)return o.map(()=>0);const n=1/(a*t*Math.sqrt(2*Math.PI));return o.map(o=>{let a=0;for(const n of e){const e=(o-n.v)/t;e>-6&&e<6&&(a+=n.w*Math.exp(-.5*e*e))}return a*n})}(e,t,l),d=Math.max(...c);return l.map((e,t)=>({x:e,d:d>0?c[t]/d:0}))}function af(e){const t=43758.5453*Math.sin(12.9898*(e+1));return t-Math.floor(t)-.5}function nf(e,t){const o=e.filter(e=>e.real);if(o.length<=t)return o;const a=[];for(let n=0;ne?Mn(kn(e.median,t.entity.decimals,o),t.entity.unit):"";function hf(e,t,o,a,n,r,i){const s=(e,t)=>e?_t(i,"violin_median",{value:uf(e,t,i)}):_t(i,"violin_none");if("none"===o.split)return e.map((e,o)=>{const a=ef(t[o]);return{series:e,readings:t[o],summary:a,name:e.entity.name,detail:s(a,e)}});const l=e.map((e,o)=>function(e,t,o){const a=t.slice(0,-1).map(()=>[]);for(const n of e)for(let e=0;e+1=r&&n.t=r})}return a}(t[o],a,n)),c=[];for(let d=0;d+11?`${h} · ${e[t].entity.name}`:h,detail:s(u,e[t])})}return c}function pf(e,t,o,a,n=0,r=.42){if(0===e.length)return"";const i=e.map(e=>t.at(e.x,o,n-e.d*r)),s=e.map(e=>t.at(e.x,o,a?n:n+e.d*r));return function(e,t){const o=e=>Math.round(100*e)/100;return`${[...e,...[...t].reverse()].map(([e,t],a)=>`${a?"L":"M"}${o(e)},${o(t)}`).join("")}Z`}(i,s)}function mf(e,t,o,a,n){const{summary:r,series:i}=e;if(!r)return Z``;const s=a.bandwidth??tf(e.readings),l=of(e.readings,s,n),c="raincloud"===a.shape,d=Math.min(1,110/o.bandPx),u=Z``,h=c?.08*d:0,p=function([e,t],[o,a]){return{x:Math.min(e,o),y:Math.min(t,a),width:Math.abs(o-e),height:Math.abs(a-t)}}(o.at(r.q1,t,h-.045*d),o.at(r.q3,t,h+.045*d)),[m,f]=o.at(r.low,t,h),[g,v]=o.at(r.high,t,h),y=a.box?Z` + + + + `:W,b=a.readings?Z`o.at(e.v,t,d*function(e,t,o){return o?.3+.22*af(e):1.6*af(e)*lf*t}(a,function(e,t){if(0===e.length)return 0;let o=0;for(;o`:W;return Z`${u}${y}${b}`}const ff=(e,t,o)=>{const a=e=>kn(e,t.entity.decimals,o),n=e=>Mn(e,t.entity.unit);return[[_t(o,"dist_median"),n(a(e.median))],[_t(o,"dist_middle"),n(`${a(e.q1)} – ${a(e.q3)}`)],[_t(o,"dist_range"),n(`${a(e.min)} – ${a(e.max)}`)],[_t(o,"dist_count"),kn(e.n,0,o)]]},gf=[...wi,{name:"shape",path:["shape"],default:"violin"},{name:"box",path:["box"],default:!0},{name:"split_by",path:["split_by"],default:"none"},{name:"bandwidth",path:["bandwidth"]},...Om],vf=new Map;const yf={schema:[{name:"fill_color",selector:{text:{}}}],fields:[{name:"fill_color",path:["color"]}]};yt("en",{editor_fill_color:"Colour"});const bf={layout:"chart",group:"distribution",height:240,styles:r` + ${$n} + ${fr} + ${Gn} + ${Xm} + .style-violin .shape { + fill-opacity: 0.35; + stroke-width: 1.5px; + stroke-linejoin: round; + } + .style-violin .whisker { + stroke-width: 1.5px; + } + .style-violin .median { + stroke: var(--card-background-color, #fff); + stroke-width: 6px; + stroke-linecap: round; + } + .style-violin .dots { + fill: none; + stroke-width: 4px; + stroke-linecap: round; + stroke-opacity: 0.7; + } + `,thumbnail:()=>Z` + + + + + + `,validate(e){if(pn(e),cr(e),Pm(e),void 0!==e.shape&&!rf.includes(e.shape))throw new Error(`"shape" must be one of: ${rf.join(", ")}`);if(void 0!==e.split_by&&!sf.includes(e.split_by))throw new Error(`"split_by" must be one of: ${sf.join(", ")}`);for(const o of["box","readings"])if(void 0!==e[o]&&"boolean"!=typeof e[o])throw new Error(`"${o}" must be true or false`);const t=e.bandwidth;if(void 0!==t&&!("number"==typeof t&&Number.isFinite(t)&&t>0))throw new Error('"bandwidth" must be a number above 0')},editor:function(e){const t=cf(e.config),o=Pi(`violin|${$i(e.config)}`,e.hass,()=>[...ki(e.hass,e.config),{type:"grid",name:"",schema:[{name:"shape",selector:xi(e.hass,"shape",rf)},{name:"split_by",selector:xi(e.hass,"split_by",sf)}]},{type:"grid",name:"",schema:[{name:"box",selector:{boolean:{}}},{name:"readings",selector:{boolean:{}}}]},{name:"bandwidth",selector:{number:{min:0,mode:"box",step:"any"}}},...Fm(e.hass)]);return Di(e,"chart",o,function(e){let t=vf.get(e);return t||(t=[...gf,{name:"readings",path:["readings"],default:"raincloud"===e}],vf.set(e,t)),t}(t.shape))},entityFields:()=>yf,disconnect(e){bn(e),Pn(e),Vm(e)},render(e){const t=Date.now(),o=mn(e,t),a=vn(o,e),n={...Wn(e.raw),header:"above"},r=xn(o,e);if(r)return Vn(n,a,r);const i=cf(e.raw),s=Math.min(o.range.end,t),l="history"===o.source&&!o.group,c=o.series.map(e=>Jm(e.points,l,s)),d="none"===i.split?[]:pa(i.split,o.range.start,s,o.zone),u=hf(o.series,c,i,d,l,o.zone,e.hass);let h=1/0,p=-1/0;for(const k of u){if(!k.summary)continue;const e=i.bandwidth??tf(k.readings);h=Math.min(h,k.summary.min-e),p=Math.max(p,k.summary.max+e)}const m=Dm(e.raw),f=Hm(e,u.length,{domain:[0,1]}),g=Bm(h===1/0?void 0:[h,p],e.raw,Im(f.valuePx,m.orientation),e.hass),v=Hm(e,u.length,g),y=Z`${u.map((e,t)=>mf(e,t,v,i,g.domain))}`,b=u.map(e=>({index:e.series.index,name:e.name,detail:e.detail,color:e.series.color,label:`${e.name}, ${e.detail}`})),_=lr(e.raw),x=_?Wm(e.host):void 0,w=void 0===x?void 0:u[x],$=_t(e.hass,"chart_aria",{style:_t(e.hass,"raincloud"===i.shape?"violin_raincloud":"editor_style_violin"),names:o.series.map(e=>e.entity.name).join(", ")});return Vn(n,a,Gm({ctx:e,geometry:v,categories:b,scale:g,content:y,label:$,hover:_,overlay:w?.summary&&void 0!==x?Ym(v,x,w.name,ff(w.summary,w.series,e.hass),w.series.color):W}))}},_f=.75;function xf(e){const t=e.map((e,t)=>t).sort((t,o)=>e[t].at-e[o].at||t-o),o=new Array(e.length).fill(0),a=[];let n=0;const r=function(e){let t=0;for(const o of e)o.r>t&&(t=o.r);return t}(e);for(const i of t){const{at:t,r:s}=e[i];for(;ns+r+_f;)n++;const l=a.slice(n).filter(e=>Math.abs(e.at-t)Math.abs(e)-Math.abs(t)||t-e).find(e=>l.every(o=>(o.at-t)**2+(o.y-e)**2>=(o.r+s+_f)**2-1e-6)),u=d??0;o[i]=u,a.push({at:t,r:s,y:u})}return o}function wf(e,t,o=1){let a=1,n=xf(e);const r=t=>function(e,t){let o=0;return e.forEach((e,a)=>{o=Math.max(o,Math.abs(t[a])+e.r)}),o}(e.map(e=>({...e,r:e.r*t})),n),i=Math.min(...e.map(e=>e.r)),s=Math.min(1,o/Math.max(i,1e-9));for(let l=0;l<4;l++){const o=r(a);if(o<=t)return{offsets:n,scale:a};const i=Math.max(s,a*Math.sqrt(t/o)*.95);if(i>=a)break;a=i,n=xf(e.map(e=>({...e,r:e.r*a})))}if(r(a)>t){const o=Math.max(0,t-Math.max(...e.map(e=>e.r*a))),r=Math.max(...n.map(Math.abs));r>0&&(n=n.map(e=>e*o/r))}return{offsets:n,scale:a}}const $f=["value","time_of_day"];function kf(e){return{placeBy:e.place_by??"value",dot:"number"==typeof e.dot_size?e.dot_size:6,sized:!0===e.size_by_value}}yt("en",{editor_style_beeswarm:"Beeswarm",editor_place_by:"Place each dot by",editor_place_by_value:"Its value",editor_place_by_time_of_day:"The time of day it happened",editor_dot_size:"Dot size (px)",editor_size_by_value:"Bigger dots for bigger values",swarm_readings:"{n} readings",swarm_events:"{n} times on",swarm_shown:"Dots shown",swarm_none:"No readings",swarm_earliest_latest:"Earliest – latest",swarm_lowest_highest:"Lowest – highest"});const Mf=/^(binary_sensor|switch|input_boolean|light|fan|siren|automation)\./;function zf(e,t,o){const a=Math.min(e.range.end,o);return e.series.map(o=>{const n="time_of_day"===t.placeBy&&Mf.test(o.entity.entityId);let r;r=n?function(e){const t=[];let o=null;for(const a of e)null!==a.v&&(1!==a.v||1===o||a.carried||t.push(a),o=a.v);return t}(o.points).map(e=>({t:e.t,v:1,w:1,real:!0})):Jm(o.points,"history"===e.source&&!e.group,a).filter(e=>e.real);const i=nf(r,400);return{series:o,positions:i.map(o=>"time_of_day"===t.placeBy?function(e,t){const o=sa(e,t);return o.hour+o.minute/60+o.second/3600}(o.t,e.zone.timeZone):o.v),values:i.map(e=>e.v),total:r.length,events:n}})}function Af(e,t,o,a,n){if(0===e.positions.length)return Z``;const r=n.dot/2,i=a.domain[1]-a.domain[0]||1,s=Math.min(...e.values),l=Math.max(...e.values),c=e.values.map(t=>{if(!n.sized||e.events)return r;const o=function(e,t,o,a){if(!(o>t))return a;const n=Math.min(1,Math.max(0,(e-t)/(o-t)));return a*Math.sqrt(.12+.88*n)}(t,s,l,r);return Math.max(.5,Math.round(o/r*6)/6)*r}),d=e.positions.map((e,t)=>({at:(e-a.domain[0])/i*o.valuePx,r:c[t]})),{offsets:u,scale:h}=wf(d,o.bandPx/2-1,1),p=new Map;return d.forEach((a,n)=>{const r=Math.round(a.r*h*2*100)/100,i=p.get(r)??[];i.push(o.at(e.positions[n],t,u[n]/o.bandPx)),p.set(r,i)}),Z` + ${[...p].map(([e,t])=>Z``)} + `}function Sf(e,t,o,a){const n=ef(e.positions.map((e,t)=>({t:t,v:e,w:1,real:!0})));if(!n)return[];const r=n=>o?t.format(n):Mn(kn(n,e.series.entity.decimals,a),e.series.entity.unit);return[[_t(a,"dist_count"),kn(e.total,0,a)],...e.positions.length{const t=e;return t.schema?{...t,schema:t.schema.filter(e=>"value_min"!==e.name&&"value_max"!==e.name)}:e})}const jf={schema:[{name:"fill_color",selector:{text:{}}}],fields:[{name:"fill_color",path:["color"]}]};yt("en",{editor_fill_color:"Colour"});const Nf={layout:"chart",group:"distribution",height:260,styles:r` + ${$n} + ${fr} + ${Gn} + ${Xm} + .swarm .dots { + fill: none; + stroke-linecap: round; + stroke-opacity: 0.85; + } + `,thumbnail:()=>Z` + + + + + + + + + + `,validate(e){if(pn(e),Pm(e),void 0!==e.place_by&&!$f.includes(e.place_by))throw new Error(`"place_by" must be one of: ${$f.join(", ")}`);const t=e.dot_size;if(void 0!==t&&!("number"==typeof t&&t>=2&&t<=16))throw new Error('"dot_size" must be a number from 2 to 16');if(void 0!==e.size_by_value&&"boolean"!=typeof e.size_by_value)throw new Error('"size_by_value" must be true or false')},editor:function(e){const t=kf(e.config),o=Pi(`beeswarm|${$i(e.config)}|${t.placeBy}`,e.hass,()=>[...ki(e.hass,e.config),{name:"place_by",selector:xi(e.hass,"place_by",$f)},{type:"grid",name:"",schema:[{name:"dot_size",selector:{number:{min:2,max:16,step:1,mode:"box",unit_of_measurement:"px"}}},{name:"size_by_value",selector:{boolean:{}}}]},..."value"===t.placeBy?Fm(e.hass):Cf(Fm(e.hass))]);return Di(e,"chart",o,Ef)},entityFields:()=>jf,disconnect(e){bn(e),Pn(e),Vm(e)},render(e){const t=Date.now(),o=mn(e,t),a=vn(o,e),n={...Wn(e.raw),header:"above"},r=xn(o,e);if(r)return Vn(n,a,r);const i=kf(e.raw),s=zf(o,i,t),l=Dm(e.raw),c=Im(Hm(e,s.length,{domain:[0,1]},96).valuePx,l.orientation);let d;if("time_of_day"===i.placeBy)d=Rm(c,e.hass);else{const t=s.flatMap(e=>e.positions);d=Bm(t.length?[Math.min(...t),Math.max(...t)]:void 0,e.raw,c,e.hass)}const u=Hm(e,s.length,d,96),h=Z`${s.map((e,t)=>Af(e,t,u,d,i))}`,p=s.map(t=>{const o=function(e,t){if(0===e.total)return _t(t,"swarm_none");const o=kn(e.total,0,t);return _t(t,e.events?"swarm_events":"swarm_readings",{n:o})}(t,e.hass);return{index:t.series.index,name:t.series.entity.name,detail:o,color:t.series.color,label:`${t.series.entity.name}, ${o}`}}),m=lr(e.raw),f=m?Wm(e.host):void 0,g=void 0===f?void 0:s[f],v=_t(e.hass,"chart_aria",{style:_t(e.hass,"editor_style_beeswarm"),names:o.series.map(e=>e.entity.name).join(", ")});return Vn(n,a,Gm({ctx:e,geometry:u,categories:p,scale:d,content:h,label:v,hover:m,overlay:g&&g.total>0&&void 0!==f?Ym(u,f,g.series.entity.name,Sf(g,d,"time_of_day"===i.placeBy,e.hass),g.series.color):W}))}};function Lf(e,t,o){const a=new Set,n=[t];for(;n.length;){const t=n.pop();if(t===o)return!0;a.has(t)||(a.add(t),n.push(...e.get(t)??[]))}return!1}function Tf(e){const t=new Map,o=[];for(const a of e)a.from&&a.to&&a.from!==a.to&&(Lf(t,a.to,a.from)||(t.set(a.from,[...t.get(a.from)??[],a.to]),o.push(a)));return o}function Of(e){return null!==e&&Number.isFinite(e)&&e>0?e:0}function Ff(e,t){const o=Tf(e),a=function(e){const t=new Map;for(const o of e)t.set(o.from,t.get(o.from)??0),t.set(o.to,t.get(o.to)??0);for(let o=0;o<=t.size;o++){let o=!1;for(const a of e){const e=t.get(a.from)+1;e>t.get(a.to)&&(t.set(a.to,e),o=!0)}if(!o)break}return t}(o),n=new Map;for(const b of o)n.has(b.from)||n.set(b.from,n.size),n.has(b.to)||n.set(b.to,n.size);const r=o.filter(e=>Of(e.value)>0),i=new Map,s=e=>{let t=i.get(e);return t||(t={name:e,order:n.get(e),column:a.get(e),value:0,in:0,out:0,x:0,y:0,h:0},i.set(e,t)),t},l=r.map(e=>{const t=Of(e.value),o=s(e.from),a=s(e.to);return o.out+=t,a.in+=t,{flow:e,value:t,source:o,target:a,width:0,y0:0,y1:0}}),c=[...i.values()].sort((e,t)=>e.order-t.order);for(const b of c)b.value=Math.max(b.in,b.out);const d=[...new Set(c.map(e=>e.column))].sort((e,t)=>e-t);for(const b of c)b.column=d.indexOf(b.column);const u=d.length;if(0===u)return{nodes:c,links:l,columns:u};const h=d.map((e,t)=>c.filter(e=>e.column===t)),p=e=>t.above?.(e,u)??0,m=h.map(e=>e.reduce((e,t)=>e+p(t),0)+t.gap*(e.length-1)),f=h.map(e=>e.reduce((e,t)=>e+t.value,0)),g=Math.max(0,Math.min(...h.map((e,o)=>(t.height-m[o])/f[o]))),v=t.width-t.left-t.right-t.nodeWidth;h.forEach((e,o)=>{let a=(t.height-(f[o]*g+m[o]))/2;const n=t.left+(u>1?v*o/(u-1):v/2);for(const r of e)a+=p(r),r.x=n,r.y=a,r.h=r.value*g,a+=r.h+t.gap});const y=e=>e.y+e.h/2;for(const b of l)b.width=b.value*g;for(const b of c){const e=l.filter(e=>e.source===b).sort((e,t)=>y(e.target)-y(t.target));let t=b.y;for(const a of e)a.y0=t+a.width/2,t+=a.width;const o=l.filter(e=>e.target===b).sort((e,t)=>y(e.source)-y(t.source));t=b.y;for(const a of o)a.y1=t+a.width/2,t+=a.width}return{nodes:c,links:l,columns:u}}function Pf(e,t){return e.length*t*.58}function Df(e,t,o){if(Pf(e,o)<=t)return e;const a=Math.floor(t/(.58*o))-1;return a>=1?`${e.slice(0,a).trimEnd()}…`:""}function Uf(e,t,o){const a=e.map((e,t)=>t).sort((t,o)=>e[t]-e[o]),n=[...e];let r=-1/0;for(const s of a)n[s]=Math.max(n[s],r+t,t/2),r=n[s];let i=1/0;for(const s of[...a].reverse())n[s]=Math.min(n[s],i-t,o-t/2),i=n[s];return n}yt("en",{editor_style_sankey:"Sankey (flow)",editor_link_color:"Colour flows by",editor_link_color_auto:"Where they come from, or where they go after a middle node",editor_link_color_source:"Where they come from",editor_link_color_target:"Where they go",editor_from:"Flows from",editor_to:"Flows to",editor_flow_color:"Flow colour",sankey_aria:"Flow chart: {flows}",sankey_flow:"{from} to {to} {value}",sankey_no_flows:'Give each entity a "Flows from" and a "Flows to" in its options.',sankey_nothing:"Nothing is flowing right now."});const Hf=["auto","source","target"],If=10,Bf=e=>"string"==typeof e?e.trim():"";const Rf=new WeakMap,Zf=(e,t)=>e.column>0&&e.column0===e.column?"left":Zf(e,t)&&e.in>0&&e.out>0?"above":"right";function Wf(e,t){return R`
${_t(t,e)}
`}const Vf=[{name:"link_color",path:["link_color"],default:"auto"}];const Yf=[{name:"from",path:["from"],default:""},{name:"to",path:["to"],default:""},{name:"flow_color",path:["color"]}],Gf=[{type:"grid",name:"",schema:[{name:"from",selector:{text:{}}},{name:"to",selector:{text:{}}}]},{name:"flow_color",selector:{text:{}}}];const Xf={layout:"chart",group:"totals",height:220,styles:r` + ${$n} + ${fr} + .sankey-wrap { + position: relative; + } + .sankey { + display: block; + width: 100%; + height: auto; + overflow: visible; + } + .sankey .link { + fill-opacity: 0.35; + transition: + fill-opacity 0.15s ease, + opacity var(--cd-duration) var(--cd-easing); + } + .sankey.dark .link { + fill-opacity: 0.45; + } + .sankey.hovering .link { + fill-opacity: 0.2; + } + .sankey .flow:hover .link, + .sankey .flow:focus-visible .link, + .sankey .flow.active .link { + fill-opacity: 0.75; + } + .sankey .flow { + cursor: pointer; + outline: none; + } + .sankey .flow:focus-visible { + box-shadow: none; + } + .sankey .flow:focus-visible .link { + fill-opacity: 0.85; + } + :host(:not([ready])) .sankey .link { + opacity: 0; + } + .sankey .node { + transition: opacity var(--cd-duration) var(--cd-easing); + } + :host(:not([ready])) .sankey .node { + opacity: 0; + } + :host([static]) .sankey .node { + transition: none; + } + .sankey .label { + pointer-events: none; + } + .sankey .name { + fill: var(--primary-text-color); + } + .sankey .value { + fill: var(--secondary-text-color); + } + .sankey-tip { + pointer-events: none; + white-space: nowrap; + max-width: none; + transform: translate(-50%, calc(-100% - 6px)); + } + `,thumbnail:()=>Z` + + + + + + + + + `,validate:function(e){if(void 0!==e.link_color&&!Hf.includes(e.link_color))throw new Error(`"link_color" must be one of: ${Hf.join(", ")}`);const t=[];(e.entities??[]).forEach((e,o)=>{if("object"!=typeof e||null===e)return;const a=e;for(const t of["from","to"])if(void 0!==a[t]&&"string"!=typeof a[t])throw new Error(`Entity ${o+1}: "${t}" must be a node name`);const n=Bf(a.from),r=Bf(a.to);if(n&&r&&n===r)throw new Error(`Entity ${o+1}: "from" and "to" must be different nodes`);n&&r&&t.push([n,r])});const o=function(e){const t=new Map;for(const[r,i]of e){if(r===i)return[r,i];t.set(r,[...t.get(r)??[],i])}const o=new Map,a=[],n=e=>{if("open"===o.get(e))return[...a.slice(a.indexOf(e)),e];if("done"!==o.get(e)){o.set(e,"open"),a.push(e);for(const o of t.get(e)??[]){const e=n(o);if(e)return e}a.pop(),o.set(e,"done")}};for(const r of t.keys()){const e=n(r);if(e)return e}}(t);if(o)throw new Error(`Flows can't go round in a loop: ${o.join(" → ")}`)},editor:function(e){const t=Pi("sankey",e.hass,()=>[{name:"link_color",selector:xi(e.hass,"link_color",Hf)}]);return Di(e,"chart",t,Vf)},entityFields:()=>({schema:Gf,fields:Yf}),disconnect(e){Pn(e),Rf.delete(e)},render:function(e){const t=function(e){return e.items.flatMap((e,t)=>{const o=e.config,a=Bf(o.from),n=Bf(o.to);return a&&n?[{index:t,from:a,to:n,value:e.available?e.value:null}]:[]})}(e);if(!t.length)return Wf("sankey_no_flows",e.hass);const o=e.items[t[0].index],a=t=>Mn(kn(t,o.decimals,e.hass),o.unit),n=Math.max(200,Fn(e)-32),{show:r}=e.config,i=function(e,t,o){const a=t<400?11:12,n=a+3,r=Ff(e,{width:t,height:200,left:0,right:0,nodeWidth:If,gap:12}),i=r.columns,s=new Map;for(const b of r.nodes)s.set(b.column,(s.get(b.column)??0)+1);const l=Math.max(200,44*Math.max(0,...s.values())),c=.3*t,d=e=>({name:o.name?e.name:"",value:o.value?o.format(e.value):""}),u=e=>{const t=r.nodes.filter(t=>qf(t,i)===e&&!Zf(t,i)).map(e=>Math.max(Pf(d(e).name,a),Pf(d(e).value,a)));return t.length&&Math.max(...t)>0?Math.min(c,Math.max(...t))+6+3:2},h=u("left"),p=u("right"),m=r.nodes.some(e=>"above"===qf(e,i)),f=m&&(o.name||o.value)?20:0,g=Ff(e,{width:t,height:l-f,left:h,right:p,nodeWidth:If,gap:12,above:(e,t)=>"above"===qf(e,t)&&f?20:0});for(const b of g.nodes)b.y+=f;for(const b of g.links)b.y0+=f,b.y1+=f;const v=i>1?(t-h-p-If)/(i-1):t,y=[];if(o.name||o.value){for(const o of g.nodes){const e=qf(o,i),n=d(o);if("above"===e){const r=o.x+5,i=Math.min(1.6*v,2*r,2*(t-r)),s=Pf(`${n.name} ${n.value}`,a)<=i||!n.name?n.value:"",l=Df(n.name,s?i-Pf(` ${s}`,a):i,a);y.push({node:o,side:e,name:l,value:s,y:o.y-6});continue}const r=Zf(o,i)?v-If-12:("left"===e?h:p)-6-2,s=Pf(n.value,a)<=r?n.value:"";y.push({node:o,side:e,name:Df(n.name,r,a),value:s,y:o.y+o.h/2})}const e=o.name&&o.value?2*n:n;for(let t=0;t"above"!==e.side&&e.node.column===t);Uf(o.map(e=>e.y),e,l).forEach((e,t)=>o[t].y=e)}}return{width:t,height:l,font:a,layout:g,labels:y}}(t,n,{name:r.name,value:r.value,format:a}),{layout:s}=i;if(!s.links.length)return Wf("sankey_nothing",e.hass);const l=function(e,t,o){const a=Tf(e),n=new Set(a.map(e=>e.to)),r=new Map;for(const c of a)for(const e of[c.from,c.to])r.has(e)||r.set(e,"");const i=e=>"target"===t||"auto"===t&&n.has(e.from)?e.to:e.from,s=new Set;for(const c of a){const e=o(c.index);e&&!s.has(i(c))&&(r.set(i(c),Ce(e)),s.add(i(c)))}[...r.keys()].forEach((e,t)=>{s.has(e)||r.set(e,Ce(un[t%un.length]))});const l=new Map;for(const c of a){const e=o(c.index);l.set(c.index,e?Ce(e):r.get(i(c)))}return{node:r,link:l}}(t,e.raw.link_color??"auto",t=>{const o=e.items[t].config.color;return"string"==typeof o&&o?o:void 0}),c=Rf.get(e.host),d=t=>()=>{void 0===t?Rf.delete(e.host):Rf.set(e.host,t),e.requestUpdate()},u=!0===e.hass.themes?.darkMode,h=t=>_t(e.hass,"sankey_flow",{from:t.flow.from,to:t.flow.to,value:a(t.value)}),p=_t(e.hass,"sankey_aria",{flows:s.links.map(h).join(", ")}),m=s.links.find(e=>e.flow.index===c),f=s.links.map(t=>{const o=e.items[t.flow.index],a=Ae(o.config);return Z` + ${h(t)} + Math.round(10*e)/10,[s,l,c,d]=[e.y0-r,e.y1-r,e.y1+r,e.y0+r].map(i);return`M${i(o)},${s}C${i(n)},${s} ${i(n)},${l} ${i(a)},${l}L${i(a)},${c}C${i(n)},${c} ${i(n)},${d} ${i(o)},${d}Z`}(t,If)} fill=${l.link.get(t.flow.index)}> + `}),g=s.nodes.map(e=>Z``);return R`
+ + ${f} + ${g} + + + ${function(e,t,o,a){if(!e)return W;const n=(e.source.x+If+e.target.x)/2,r=(e.y0+e.y1)/2-e.width/2,i=Math.min(Math.max(n,70),t.width-70);return R``}(m,i,m?l.link.get(m.flow.index):"",m?a(m.value):"")} +
`}},Jf=new Set(["unavailable","unknown"]);function Kf(e){return void 0===e||""===e||Jf.has(e)}function Qf(e,t){const o=[];for(let a=0;a=t)break;const r=Math.min(t,a+18}yt("en",{editor_bar_labels:"Text on the bars",editor_bar_labels_state:"State",editor_bar_labels_duration:"How long",editor_bar_labels_both:"State and how long",editor_bar_labels_none:"None",editor_timeline_legend:"Legend of states",editor_timeline_legend_above:"Above the chart",editor_timeline_legend_hidden:"Hidden",editor_state_colors:"State colours",editor_state_colors_help:"A palette name (amber, green, blue, red, purple, grey, primary) or any CSS colour.",editor_state_colors_state:"State",editor_state_colors_color:"Colour",editor_state_colors_add:"Add a state colour",editor_state_colors_remove:"Remove",editor_timeline_group:"Group (rows with the same group share a heading)"});const rg=new Set(["source","group_by","aggregate"]),ig=[...wi.filter(e=>!rg.has(e.name)),{name:"bar_labels",path:["bar_labels"],default:"state"},{name:"x_axis",path:["x_axis"],default:!0},{name:"grid_vertical",path:["gridlines","vertical"],default:!0},{name:"tooltip",path:["tooltip"],default:!0},{name:"timeline_legend",path:["header"],default:"above"},{name:"compact",path:["compact"],default:!1}];const sg={text:{}};function lg(e){const t=e.state_colors;return t&&"object"==typeof t&&!Array.isArray(t)?Object.entries(t):[]}function cg(e,t){const o=new Set(lg(e).map(([e])=>e)),a=(e.entities??[]).map(e=>"string"==typeof e?e:e.entity),n=a.map(e=>t?.states[e]?.state).filter(e=>!!e);return[...n,"on","off","home","not_home","heat","idle"].find(e=>!o.has(e)&&"unavailable"!==e&&"unknown"!==e)??`state_${o.size+1}`}function dg(e){const t=lg(e.config),o=t=>_t(e.hass,t),a=t=>e.commit(function(e,t){const o={...e},a=t.filter(([e])=>""!==e.trim());return a.length?o.state_colors=Object.fromEntries(a.map(([e,t])=>[e.trim(),t])):delete o.state_colors,o}(e.config,t)),n=(e,o)=>n=>{n.stopPropagation();const r=String(n.detail?.value??""),i=t.map(e=>[...e]);1===o&&""===r.trim()||(i[e][o]=r,a(i))};return R`
+ +
${o("editor_state_colors")}
+
${o("editor_state_colors_help")}
+ ${t.map(([r,i],s)=>R`
+ + + + a(t.filter((e,t)=>t!==s))} + > +
`)} + +
`}const ug=[{name:"timeline_group",path:["group"]}];yt("en",{editor_style_timeline:"State timeline",timeline_no_states:"No states recorded in this period.",timeline_for:"for {duration}",timeline_now:"Now",timeline_measurement:"Readings, not states: a line chart suits this one"});const hg=["state","duration","both","none"],pg={normal:30,compact:22},mg={normal:24,compact:18};function fg(e,t,o){const a=t.entity.stateObj??{entity_id:t.entity.entityId,state:o,attributes:{}},n=t.entity.config.attribute;try{return n?e.formatEntityAttributeValue?.(a,n,o)??o:e.formatEntityState?.({...a,state:o},o)??o}catch{return o}}function gg(e,t,o){return function(e,t,o){const a=o?.[e];if(a)return{color:Ce(a),quiet:!1,text:Le(a)};const n=tg.has(e)&&!Object.prototype.hasOwnProperty.call(eg,e),r=Object.prototype.hasOwnProperty.call(eg,e)?eg[e]:n?"grey":og[function(e){let t=0;for(let o=0;o>>0;return t}(e)%og.length],i=Ce(r);return{color:/^[a-z0-9_]+$/.test(e)&&/^[a-z0-9_]+$/.test(t)?`var(--state-${t}-${e}-color, ${i})`:i,quiet:n,text:n?"theme":Le(r)}}(t,e.domain,function(e){const t=e.state_colors;return t&&"object"==typeof t&&!Array.isArray(t)?t:void 0}(o))}const vg=e=>Math.round(1e4*e)/100+"%";function yg(e,t,o,a){const n=o.raw.bar_labels??"state";return e.spans.map(r=>{if(Kf(r.state))return W;const{left:i,width:s}=function(e,t){const o=t.end-t.start;if(!(o>0))return{left:0,width:0};const a=Math.max(t.start,e.start),n=Math.min(t.end,e.end);return{left:(a-t.start)/o,width:Math.max(0,n-a)/o}}(r,t.range);if(s<=0)return W;const l=r.state,c=gg(e,l,o.raw),d=function(e,t,o){const a=ag(o.end-o.start);return"state"===e?t:"duration"===e?a:"both"===e?`${t} · ${a}`:""}(n,fg(o.hass,e.series,l),r),u=""!==d&&function(e){return 6.2*e.length+10}(d)<=s*a;return R`
+ ${u?R`${d}`:W} +
`})}const bg={layout:"chart",group:"history",height:200,styles:r` + ${$n} + ${Rn} + ${fr} + ${Gn} + .tl-legend { + display: flex; + flex-wrap: wrap; + gap: 4px 14px; + padding: 0 0 8px; + font-size: 0.875em; + color: var(--secondary-text-color); + } + .tl-key { + display: inline-flex; + align-items: center; + gap: 6px; + white-space: nowrap; + } + .swatch.quiet { + opacity: 0.45; + } + .tl-wrap { + display: grid; + grid-template-columns: fit-content(35%) minmax(0, 1fr); + grid-template-rows: auto auto; + } + .tl-wrap.no-names { + grid-template-columns: 0 minmax(0, 1fr); + } + .tl-names { + grid-column: 1; + grid-row: 1; + display: flex; + flex-direction: column; + min-width: 0; + padding-right: 10px; + } + .tl-name, + .tl-heading { + display: flex; + align-items: center; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + height: var(--tl-row); + } + .tl-name { + font-size: 0.875em; + cursor: pointer; + border-radius: 4px; + } + .tl-name span { + overflow: hidden; + text-overflow: ellipsis; + } + .tl-name:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: -2px; + } + .tl-heading { + height: var(--tl-heading); + font-size: 0.75em; + font-weight: 500; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--secondary-text-color); + } + .tl-plot { + position: relative; + grid-column: 2; + grid-row: 1; + display: flex; + flex-direction: column; + min-width: 0; + } + .tl-gap { + height: var(--tl-heading); + position: relative; + } + .tl-gap::after { + content: ''; + position: absolute; + left: 0; + right: 0; + top: 50%; + border-top: 1px solid var(--divider-color, rgba(127, 127, 127, 0.25)); + } + .tl-row { + position: relative; + height: var(--tl-row); + cursor: pointer; + } + .tl-track, + .tl-bar { + position: absolute; + top: 4px; + bottom: 4px; + border-radius: 6px; + } + .tl-track { + left: 0; + right: 0; + background: color-mix(in srgb, var(--primary-text-color) 5%, transparent); + } + .tl-bars { + position: absolute; + inset: 0; + clip-path: inset(0 100% 0 0); + transition: clip-path var(--cd-duration) var(--cd-easing); + } + :host([ready]) .tl-bars { + clip-path: inset(0 0 0 0); + } + .tl-bar { + min-width: 2px; + background: var(--tl-color); + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + box-sizing: border-box; + } + .tl-bar.quiet { + background: color-mix(in srgb, var(--tl-color) 30%, transparent); + } + .tl-label { + font-size: 11px; + font-weight: 500; + line-height: 1; + padding: 0 5px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .tl-bar.text-dark .tl-label { + color: #1c1c1c; + } + .tl-bar.text-light .tl-label { + color: #fff; + } + .tl-bar.text-theme .tl-label { + color: var(--primary-text-color); + } + .tl-grid { + position: absolute; + top: 0; + bottom: 0; + border-left: 1px dashed var(--divider-color, rgba(127, 127, 127, 0.25)); + pointer-events: none; + } + .tl-now { + position: absolute; + top: 0; + bottom: 0; + border-left: 1.5px dashed var(--secondary-text-color); + opacity: 0.7; + pointer-events: none; + } + .tl-hint { + position: absolute; + inset: 0; + display: flex; + align-items: center; + padding: 0 8px; + font-size: 0.75em; + color: var(--secondary-text-color); + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + .tl-for { + font-weight: 400; + color: var(--secondary-text-color); + } + .tl-wrap .x-axis { + position: relative; + grid-column: 2; + grid-row: 2; + height: 18px; + } + .tl-wrap .x-axis .axis-label { + top: 5px; + } + .frame.compact .tl-legend { + padding-bottom: 4px; + font-size: 0.8em; + } + .frame.compact .tl-wrap .x-axis { + height: 14px; + } + .frame.compact .tl-wrap .x-axis .axis-label { + top: 3px; + } + .frame.compact .tl-track, + .frame.compact .tl-bar { + top: 3px; + bottom: 3px; + border-radius: 4px; + } + `,thumbnail:()=>Z` + + + + + + + + + `,validate:function(e){if($a(e.period),void 0!==e.source&&"history"!==e.source)throw new Error('"source" must be history: a state timeline reads states, which statistics don\'t keep');if(void 0!==e.group_by)throw new Error('"group_by" does not apply to a state timeline');if(void 0!==e.bar_labels&&!hg.includes(e.bar_labels))throw new Error(`"bar_labels" must be one of: ${hg.join(", ")}`);if(void 0!==e.x_axis&&"boolean"!=typeof e.x_axis)throw new Error('"x_axis" must be true or false');const t=e.gridlines;if(void 0!==t&&("object"!=typeof t||null===t||Array.isArray(t)))throw new Error('"gridlines" must have "vertical" (true or false)');if(void 0!==t?.vertical&&"boolean"!=typeof t.vertical)throw new Error('"gridlines.vertical" must be true or false');!function(e,t){if(void 0===e)return;if("object"!=typeof e||null===e||Array.isArray(e))throw new Error(`${t} must map states to colours, like "on: amber"`);for(const[o,a]of Object.entries(e))if("string"!=typeof a||""===a.trim())throw new Error(`${t}: the colour for "${o}" must be text`)}(e.state_colors,'"state_colors"');for(const o of e.entities??[]){if("object"!=typeof o)continue;const e=o;if(void 0!==e.group&&"string"!=typeof e.group)throw new Error(`"group" of ${o.entity} must be text`)}cr(e),qn(e)},editor:function(e){const t=Pi(`timeline|${$i(e.config)}`,e.hass,()=>{return[...(t=e.hass,o=e.config,ki(t,o).filter(e=>!rg.has(e.name??"")&&!(e.schema??[]).some(e=>rg.has(e.name??"")))),{name:"bar_labels",selector:xi(e.hass,"bar_labels",["state","duration","both","none"])},{type:"grid",name:"",schema:[{name:"x_axis",selector:{boolean:{}}},{name:"grid_vertical",selector:{boolean:{}}},{name:"tooltip",selector:{boolean:{}}},{name:"compact",selector:{boolean:{}}}]},{name:"timeline_legend",selector:xi(e.hass,"timeline_legend",["above","hidden"])}];var t,o});return R`${Di(e,"chart",t,ig)}${dg(e)}`},entityFields:function(e){return{schema:Pi("timeline_entity",e,()=>[{name:"timeline_group",selector:{text:{}}}]),fields:ug}},disconnect(e){bn(e),Pn(e),sr(e)},render(e){const t=Date.now(),o=mn(e,t,{text:!0}),a=Wn(e.raw),n=a.compact?"compact":"normal",r=vn(o,e),i=Math.min(o.range.end,t),s=o.series.map(e=>({series:e,spans:Qf(e.points,i),domain:e.entity.entityId.split(".")[0],measurement:!1}));for(const A of s)A.measurement=ng(A.spans);const l="hidden"!==a.header,c=(e,t)=>R`
${e}${t}
`,d="ready"!==o.status?xn(o,e):s.every(e=>e.spans.every(e=>Kf(e.state)))?R`
${_t(e.hass,"timeline_no_states")}
`:void 0;if(d)return c(r,d);const u=e.config.show.name,h=Fn(e),p=Math.max(0,...s.map(e=>e.series.entity.name.length)),m=u?Math.min(.35*h,7*p+10):0,f=Math.max(40,h-32-m),g=function(e,t,o){const a=Math.max(2,Math.min(8,Math.round(o/70))),n=Zo(Yo(t.hass)),{unit:r,ticks:i}=Vo(e.range,a,n);return{unit:r,ticks:i,x:$o([e.range.start,e.range.end],[0,1]),offset:n}}(o,e,f),v=!1!==e.raw.x_axis,y=e.raw.gridlines?.vertical??!0,b=lr(e.raw),_=new Map(s.map(e=>[e.series.index,e])),x=function(e,t){const o=[],a=new Set;for(const n of e){const r=t(n);if(void 0!==r){if(!a.has(r)){a.add(r),o.push({heading:r});for(const a of e)t(a)===r&&o.push({item:a})}}else o.push({item:n})}return o}(o.series.map(e=>_.get(e.index)),e=>(e=>{const t=e.entity.config.group;return"string"==typeof t&&""!==t.trim()?t.trim():void 0})(e.series)),w=o.range.end-o.range.start,$=o.live&&o.range.end>t&&t>o.range.start,k=_t(e.hass,"chart_aria",{style:_t(e.hass,"editor_style_timeline"),names:s.map(e=>e.series.entity.name).join(", ")}),M=b?dr(e,o.range,[Array.from({length:241},(e,t)=>({t:o.range.start+w*t/240,v:null})),...s.map(e=>e.spans.map(e=>({t:e.start,v:null})))]):void 0,z=`--tl-row:${pg[n]}px;--tl-heading:${mg[n]}px`;return c(R`${r}${l?function(e,t){const o=new Map;for(const n of e)if(!n.measurement)for(const e of n.spans){if(Kf(e.state))continue;const a=fg(t.hass,n.series,e.state);o.has(a)||o.set(a,{label:a,color:gg(n,e.state,t.raw)})}if(0===o.size)return W;const a=[...o.values()];return R`
+ ${[...a.filter(e=>!e.color.quiet),...a.filter(e=>e.color.quiet)].map(({label:e,color:t})=>R`${e}`)} +
`}(s,e):W}`,R`
+ ${u?R`
+ ${x.map(e=>"heading"in e?R`
${e.heading}
`:R`
+ ${e.item.series.entity.name} +
`)} +
`:W} + `}(s,o,e,f):W} +
+ ${v?R`
+ ${g.ticks.map(t=>{const o=g.x(t);return R`.95?"end":""}" style="left:${vg(o)}" + >${ea(t,g.unit,w,e.hass,g.offset)}`})} +
`:W} +
`)}},_g={current:"current_temperature",target:"temperature",low:"target_temp_low",high:"target_temp_high",action:"hvac_action"},xg=Object.keys(_g);const wg=["heat","cool","dry","fan"],$g={heating:"heat",preheating:"heat",cooling:"cool",defrosting:"cool",drying:"dry",fan:"fan"};function kg(e){return void 0===e?void 0:$g[e]}function Mg(e,t){let o=0,a=e.length;for(;o>1;e[n].t<=t?o=n+1:a=n}return o>0?e[o-1]:void 0}const zg=e=>(Math.round(100*e)/100).toString();yt("en",{editor_set_point:"Show the set point",editor_shading:"Shade while heating or cooling",editor_shade_fan:"Shade while only the fan runs",editor_current_color:"Temperature colour",editor_set_point_color:"Set point colour",editor_heating_color:"Heating colour",editor_cooling_color:"Cooling colour"});const Ag=new Set(["source","group_by","aggregate"]),Sg=[...wi.filter(e=>!Ag.has(e.name)),{name:"set_point",path:["set_point"],default:!0},{name:"shading",path:["shading"],default:!0},{name:"shade_fan",path:["shade_fan"],default:!1},{name:"curve",path:["curve"],default:"smooth"},{name:"line_width",path:["line_width"],default:At},{name:"current_color",path:["current_color"]},{name:"set_point_color",path:["set_point_color"]},{name:"heating_color",path:["heating_color"]},{name:"cooling_color",path:["cooling_color"]},...Ei,...Ai];function Eg(e){return ki(e.hass,e.config).filter(e=>!Ag.has(e.name??"")&&!(e.schema??[]).some(e=>Ag.has(e.name??"")))}yt("en",{editor_style_climate:"Climate",climate_set_point:"Set point",climate_range:"{low} – {high}",climate_not_thermostat:"{name} is not a thermostat. Pick a climate entity.",climate_action_heating:"Heating",climate_action_preheating:"Preheating",climate_action_cooling:"Cooling",climate_action_defrosting:"Defrosting",climate_action_drying:"Drying",climate_action_fan:"Fan",climate_action_idle:"Idle",climate_action_off:"Off"});const Cg="var(--state-climate-heat-color, #ff8100)",jg="var(--state-climate-cool-color, #2b9af9)",Ng="var(--state-climate-dry-color, #efbd07)",Lg="var(--state-climate-fan_only-color, #8a8a8a)",Tg=["set_point","shading","shade_fan"],Og=["current_color","set_point_color","heating_color","cooling_color"];function Fg(e,t,o){if(t.stateObj&&e?.formatEntityAttributeValue){const a=e.formatEntityAttributeValue(t.stateObj,_g.action,o);if(a)return a}const a=_t(e,`climate_action_${o}`);return a===`climate_action_${o}`?o:a}function Pg(e,t,o,a,n){return"number"==typeof t?e.text(t):"number"==typeof o&&"number"==typeof a?_t(n,"climate_range",{low:kn(o,e.decimals,n),high:e.text(a)}):"—"}const Dg=e=>Math.round(1e4*e)/100+"%";function Ug(e,t,o,a,n,r,i){if(void 0===i)return W;const s=n.x(i)/cn;if(!(s>=0&&s<=1))return W;const l=or(s,Dn(e)),c=Mg(o.current,i),d=Mg(o.action,i)?.s,u=kg(d),h=e=>Mg(e,i)?.v??void 0,p=c&&null!==c.v?n.y(c.v)/dn:-1,m=(e,t,o)=>R`
+ ${e}${t}${o} +
`;return R``}const Hg=(e,t=!1)=>R`
${e}
`;const Ig={layout:"chart",group:"history",height:240,styles:r` + ${$n} + ${Rn} + ${fr} + ${Gn} + .style-climate .current { + stroke-linecap: round; + stroke-linejoin: round; + } + .style-climate .set-point { + stroke-dasharray: 6 5; + stroke-width: 2px; + } + .style-climate .range-band { + fill-opacity: 0.08; + } + .style-climate .action-span { + fill-opacity: 0.2; + } + .style-climate .set-point-swatch { + box-sizing: border-box; + height: 0; + border-radius: 0; + border-top: 3px dashed; + } + .style-climate .action-swatch { + border-radius: 2px; + opacity: 0.45; + } + `,thumbnail:()=>Z` + + + + `,validate(e){pn(e),Lo(e),cr(e),Ft(e),qn(e),function(e){if(void 0!==e.source&&"history"!==e.source)throw new Error("\"source\" must be history: long-term statistics don't keep a thermostat's set point or action");if(void 0!==e.group_by)throw new Error('"group_by" is not available on a climate chart');for(const t of Tg)if(void 0!==e[t]&&"boolean"!=typeof e[t])throw new Error(`"${t}" must be true or false`);for(const t of Og)if(void 0!==e[t]&&("string"!=typeof e[t]||""===e[t]))throw new Error(`"${t}" must be a colour name or CSS colour`)}(e)},editor:function(e){const t=Pi(`climate|${$i(e.config)}`,e.hass,()=>[...Eg(e),{type:"grid",name:"",schema:[{name:"set_point",selector:{boolean:{}}},{name:"shading",selector:{boolean:{}}},{name:"shade_fan",selector:{boolean:{}}}]},{type:"grid",name:"",schema:[{name:"curve",selector:xi(e.hass,"curve",an)},{name:"line_width",selector:Vt}]},{type:"grid",name:"",schema:[{name:"current_color",selector:{text:{}}},{name:"set_point_color",selector:{text:{}}},{name:"heating_color",selector:{text:{}}},{name:"cooling_color",selector:{text:{}}}]},...Ci(e.hass),...Si(e.hass)]);return Di(e,"chart",t,Sg)},disconnect(e){bn(e),Pn(e),sr(e)},render(e){const t=Wn(e.raw),o=function(e){const t=(t,o)=>Ce(e[t]??o);return{setPoint:!1!==e.set_point,shading:!1!==e.shading,shadeFan:!0===e.shade_fan,colors:{current:t("current_color","primary"),setPoint:t("set_point_color","red"),heat:t("heating_color",Cg),cool:t("cooling_color",jg),dry:Ng,fan:Lg}}}(e.raw),a=e.items[0],n=$a(e.raw.period),r=fa(e.hass),i=Date.now(),s=Ma(n,i,r,n.navigate?Aa(e.host,n):0),l={start:s.start,end:s.end},c=function(e,t){const o=t.config?.unit_system,a=e.config.unit??o?.temperature??"°C",n=e.config.decimals??1;return{unit:a,decimals:n,text:e=>"number"==typeof e&&Number.isFinite(e)?Mn(kn(e,n,t),a):"—"}}(a,e.hass),d=a.entityId.startsWith("climate."),u=function(e,t,o,a){const{show:n}=e.config;if(!n.name&&!n.value)return W;const r=t.stateObj?.attributes??{},i=t.entityId.startsWith("climate.")&&void 0!==t.stateObj&&!["unavailable","unknown"].includes(t.stateObj.state),s="string"==typeof r[_g.action]?r[_g.action]:void 0,l=kg(s),c=(e,t,o)=>R`
+ ${e} ${n.name?R`${t}`:W} + ${n.value?R`${o}`:W} +
`;return R`
+ ${c(R``,t.name,i?a.text(r[_g.current]):t.display)} + ${o.setPoint&&i?c(R``,_t(e.hass,"climate_set_point"),Pg(a,r[_g.target],r[_g.low],r[_g.high],e.hass)):W} + ${i&&s?R`
+ + ${Fg(e.hass,t,s)} +
`:W} +
`}(e,a,d?o:{...o,setPoint:!1},c);if(!d)return Vn({...t,header:"above"},u,Hg(_t(e.hass,"climate_not_thermostat",{name:a.name})));const h=function(e){const t={};for(const o of xg)t[o]="action"===o?{entity:e,attribute:_g[o],text:!0}:{entity:e,attribute:_g[o]};return t}(a.entityId),p=on(e.host,e.requestUpdate).get(e.hass,Object.values(h),l,"history",{live:s.live}),m=function(e,t,o,a){const n={};for(const r of xg){const i=e.get(a(t[r]))??[],s=i[i.length-1],l="action"===r?void 0!==s?.s:null!=s?.v;n[r]=s&&l&&s.tn.start&&a.push(n),n=void 0);const e=kg(r.s);e&&o.includes(e)&&(n={kind:e,action:r.s,start:r.t,end:t})}return n&&t>n.start&&a.push({...n,end:Math.max(n.end,t)}),a}(m.action,Math.min(l.end,i),v):[],b=o.setPoint?[m.current,m.target,m.low,m.high]:[m.current];let _;if("loading"===p.status?_=Hg(_t(e.hass,"chart_loading")):"error"===p.status?_=Hg(_t(e.hass,"chart_error",{error:p.error??""}),!0):b.every(e=>e.every(e=>null===e.v))&&0===y.length&&(_=Hg(_t(e.hass,"chart_no_data"))),_)return Vn("over"===t.header?{...t,header:"above"}:t,g,_);const x=Un(e,l,xo(b)),{scales:w}=x,$=e.raw.line_width??At,k=e.raw.curve??"smooth",M=Z`${y.map(e=>Z``)}`,z=function(e,t){const o=new Map(t.map(e=>[e.t,e.v]));return e.map(e=>{const t=o.get(e.t);return{t:e.t,v:null!==e.v&&"number"==typeof t?[e.v,t]:null}})}(m.low,m.high),A=o.setPoint?Z` + {if(!n.length)return;const r=n[n.length-1];if(e>r.t&&n.push({t:e,v:r.v}),n.length>1){const e=n.map(e=>[t(e.t),o(e.v[1])]),r=n.map(e=>[t(e.t),o(e.v[0])]);a+=`M${zg(e[0][0])},${zg(e[0][1])}`;for(let t=1;t=0;t--)a+=`V${zg(r[t][1])}H${zg(r[t][0])}`;a+="Z"}n=[]};for(const i of e)null===i.v?r(i.t):n.push({t:i.t,v:i.v});return n.length&&r(e[e.length-1].t),a}(z,w.x,w.y)} fill=${o.colors.setPoint}> + ${[m.target,m.low,m.high].map(e=>Z`{if(!o.length)return;const a=o[o.length-1];e>a.t&&o.push({t:e,v:a.v}),t.push(o),o=[]};for(const r of e){if(null===r.v){a(r.t);continue}const e=o[o.length-1];e&&e.v===r.v||o.push({t:r.t,v:r.v})}const n=e[e.length-1];return o.length&&a(n.t),t}(e).map(e=>sn(e.map(e=>[t.x(e.t),t.y(e.v)]),"step")).join("")}(e,w)} fill="none" stroke=${o.colors.setPoint}>`)}`:W,S=ln(ta(m.current,500)).map(e=>sn(e.map(e=>[w.x(e.t),w.y(e.v)]),k)).join(""),E=Z`${M}${A}`,C=_t(e.hass,"chart_aria",{style:_t(e.hass,"editor_style_climate"),names:a.name}),j=lr(e.raw);return Vn(t,g,Bn({axes:x,ctx:e,content:E,label:C,overlay:R`${"over"===t.header?Yn(g):W} + ${j?Ug(e,a,m,o,w,c,ir(e.host)):W}`,hover:j?dr(e,l,[m.current,m.target,m.low,m.high,m.action]):void 0}))}},Bg=[4,8,16];function Rg(e,t){const o=360/t;return Math.floor(Na(e)/o+.5)%t}function Zg(e,t){return Ea[e*(16/t)%16]}function qg(e,t,o,a){if(t<0)return null;const n=e[t];return o-n.tMath.round(100*e)/100||0;function Yg(e,t){return[Vg(e*Math.sin(t)),Vg(-e*Math.cos(t))]}yt("en",{editor_sectors:"Directions",editor_sectors_4:"4 (N, E, S, W)",editor_sectors_8:"8 (N, NE, E…)",editor_sectors_16:"16 (N, NNE, NE…)",editor_calm:"Calm below (blank: 0.5 m/s, or the same in km/h, mph or knots)",editor_wind_roles:"The first entity is the wind direction, in degrees or compass points such as SW. The second is the wind speed: without one, the petals only show how often the wind came from each way.",editor_wind_now:"Direction: {direction}. Speed: {speed}.",editor_wind_none:"none",editor_wind_statistics:"Long-term statistics give each hour's average direction. That is only right across north when the sensor records direction as an angle (state class measurement_angle); otherwise an hour swinging between NNW and NNE averages to south. History is exact."});const Gg=wi.filter(e=>"group_by"!==e.name&&"aggregate"!==e.name).map(e=>"period_last"===e.name?{...e,default:30}:"period_unit"===e.name?{...e,default:"day"}:e),Xg=[...Gg,{name:"sectors",path:["sectors"],default:"16"},{name:"calm",path:["calm"]},{name:"tooltip",path:["tooltip"],default:!0},{name:"compact",path:["compact"],default:!1}];function Jg(e,t){if(void 0===t)return _t(e.hass,"editor_wind_none");const o="string"==typeof t?t:t.entity;return("string"==typeof t?void 0:t.name)??e.hass?.states[o]?.attributes?.friendly_name??o}yt("en",{editor_style_wind_rose:"Wind rose",wind_prevailing:"Prevailing",wind_mean_speed:"Mean speed",wind_strongest:"Strongest",wind_calm:"Calm",wind_speed:"Speed",wind_speed_unit:"Speed ({unit})",wind_share:"Of the time",wind_aria:"Wind rose, prevailing {direction} {share}",wind_aria_calm:"Wind rose, prevailing {direction} {share}, calm {calm}",wind_aria_still:"Wind rose, calm {calm}"});const Kg={last:30,unit:"day"};function Qg(e){const t=e.period;return null==t?$a(Kg):"object"!=typeof t||Array.isArray(t)?$a(t):$a({...Kg,...t})}function ev(e){const t=e.sectors;if(null==t||""===t)return 16;const o="string"==typeof t?Number(t):t;if(!Bg.includes(o))throw new Error(`"sectors" must be one of: ${Bg.join(", ")}`);return o}function tv(e){const t=e.calm;if(null==t||""===t)return;const o="string"==typeof t?Number(t):t;if("number"!=typeof o||!Number.isFinite(o)||o<0)throw new Error('"calm" must be a number of 0 or more');return o}const ov=["color-mix(in srgb, var(--primary-color, #2f6fe4) 35%, var(--card-background-color, #fff))","color-mix(in srgb, var(--primary-color, #2f6fe4) 62%, var(--card-background-color, #fff))","var(--primary-color, #2f6fe4)","color-mix(in srgb, var(--primary-color, #2f6fe4) 68%, var(--primary-text-color, #212121))","color-mix(in srgb, var(--purple-color, #8b6cff) 80%, var(--primary-text-color, #212121))"],av=(e,t)=>kn(e,Number.isInteger(e)?0:1,t.hass);function nv(e,t,o){return Number.isFinite(e)?void 0===t?`${av(e,o)}+`:`${av(e,o)}–${av(t,o)}`:void 0===t?"":`< ${av(t,o)}`}function rv(e,t,o=!1){const a=100*e;return 0===a?`${kn(0,0,t.hass)}%`:`${kn(a,o||a<1?1:0,t.hass)}%`}const iv=new WeakMap;function sv(e,t){iv.get(e.host)!==t&&(void 0===t?iv.delete(e.host):iv.set(e.host,t),e.requestUpdate())}const lv=124;function cv(e,t,o,a){const{rose:n,n:r,bands:i,rings:s}=e,l=2*Math.PI/r,c=.09*l,d=lr(a.raw),u=n.sectors.map((o,s)=>{if(n.totals[s]<=0)return W;const u=s*l-l/2+c,h=s*l+l/2-c;let p=0;const m=o.map((t,o)=>{if(t<=0)return W;const a=function(e,t,o,a){if(t<=e||a<=o)return"";const n=a-o>Math.PI?1:0,[r,i]=Yg(t,o),[s,l]=Yg(t,a),c=`M${r} ${i}A${t} ${t} 0 ${n} 1 ${s} ${l}`;if(e<=0)return`${c}L0 0Z`;const[d,u]=Yg(e,a),[h,p]=Yg(e,o);return`${c}L${d} ${u}A${e} ${e} 0 ${n} 0 ${h} ${p}Z`}(e.radius(p),e.radius(p+t),u,h);return p+=t,Z``}),f=Zg(s,r),g=t.index>=0;return Z`sv(a,s):W} + @pointerleave=${d?()=>sv(a,void 0):W} + @focus=${d?()=>sv(a,s):W} + @blur=${d?()=>sv(a,void 0):W} + >${m}`}),h=function(e){const t=e.length;let o=0,a=1/0;for(let n=0;n{const[o,a]=Yg(113,t*Math.PI/2);return Z`${e}`});return R` + ${s.map(t=>Z``)} + + + + ${u} + + ${p} + ${s.map(t=>{const[o,n]=Yg(e.radius(t/100),h),r=Math.sin(h)>=0?"start":"end";return Z`${kn(t,Number.isInteger(t)?0:1,a.hass)}%`})} + `}function dv(e,t,o,a){const n=void 0!==o&&o.index>=0;return R`
+ ${e} + ${t} +
`}function uv(e,t,o,a){const n=(e,t)=>_t(a.hass,e,t),{rose:r}=e,i=r.prevailing<0?"—":`${Zg(r.prevailing,e.n)} · ${rv(r.totals[r.prevailing],a)}`,s=e=>null===e?"—":Mn(kn(e,o?.decimals??1,a.hass),o?.unit??"");return R`
+
+ ${dv(n("wind_prevailing"),i,t.direction,"prevailing")} + ${o?R`${dv(n("wind_mean_speed"),s(r.meanSpeed),t.speed,"mean")} + ${dv(n("wind_strongest"),s(function(e){let t=null;for(const o of e){const e="number"==typeof o.max&&Number.isFinite(o.max)?o.max:o.v;null!==e&&(null===t||e>t)&&(t=e)}return t}(o.points)),t.speed,"strongest")} + ${dv(n("wind_calm"),rv(r.calm,a),t.speed,"calm")}`:W} +
+ ${o?R`
+
${o.unit?n("wind_speed_unit",{unit:o.unit}):n("wind_speed")}
+
+ ${e.bands.map(e=>R`
+ + ${e.label} +
`)} +
+
`:W} +
`}const hv={layout:"chart",group:"distribution",height:280,styles:r` + ${$n} + ${fr} + ${Gn} + .wind { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: 8px 20px; + } + .rose-wrap { + position: relative; + flex: 1 1 200px; + max-width: 270px; + aspect-ratio: 1; + } + .rose { + display: block; + width: 100%; + height: 100%; + overflow: visible; + } + .rose .ring { + fill: none; + stroke: var(--divider-color, rgba(127, 127, 127, 0.3)); + stroke-dasharray: 3 3; + } + .rose .rim, + .rose .spoke { + fill: none; + stroke: var(--divider-color, rgba(127, 127, 127, 0.3)); + } + .rose .calm-circle { + fill: var(--card-background-color, #fff); + stroke: var(--divider-color, rgba(127, 127, 127, 0.3)); + } + .rose text { + font-family: inherit; + fill: var(--secondary-text-color); + text-anchor: middle; + font-size: 12px; + } + .rose .compass.north { + fill: var(--primary-text-color); + font-weight: 500; + } + .rose .ring-label { + font-size: 10px; + paint-order: stroke; + stroke: var(--card-background-color, #fff); + stroke-width: 3px; + stroke-linejoin: round; + } + .rose .petal { + outline: none; + transform-box: view-box; + transform-origin: 0 0; + transform: scale(0); + transition: transform var(--cd-duration) var(--cd-easing); + } + :host([ready]) .rose .petal { + transform: scale(1); + } + .rose .petal.entity { + cursor: pointer; + } + .rose .petal:focus-visible path { + stroke: var(--primary-text-color); + stroke-width: 1.5px; + } + .rose-tip { + top: 0; + white-space: nowrap; + } + .tooltip.rose-tip.right { + transform: translate(10px, -50%); + } + .tooltip.rose-tip.left { + transform: translate(calc(-100% - 10px), -50%); + } + .readout { + flex: 1 1 150px; + min-width: 0; + display: flex; + flex-direction: column; + gap: 14px; + } + .stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); + gap: 8px 16px; + } + .stat { + display: flex; + flex-direction: column; + min-width: 0; + border-radius: 4px; + } + .stat.entity { + cursor: pointer; + } + .stat:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: 2px; + } + .stat-label { + color: var(--secondary-text-color); + font-size: 0.875em; + } + .stat-value { + font-size: 1.25em; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-variant-numeric: tabular-nums; + } + .keys { + display: flex; + gap: 4px; + margin-top: 6px; + } + .key { + flex: 1 1 0; + min-width: 0; + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + } + .key-swatch { + width: 100%; + height: 10px; + border-radius: 2px; + } + .key-label { + font-size: 11px; + color: var(--secondary-text-color); + white-space: nowrap; + font-variant-numeric: tabular-nums; + } + .frame.compact .rose-wrap { + max-width: 170px; + flex-basis: 150px; + } + .frame.compact .wind { + gap: 4px 12px; + } + .frame.compact .readout { + gap: 8px; + } + .frame.compact .stats { + gap: 4px 12px; + } + .frame.compact .stat-value { + font-size: 1.05em; + } + `,thumbnail:()=>Z` + + + + + + `,validate(e){pn(e),Qg(e),cr(e),qn(e),ev(e),tv(e)},editor:function(e){const t=Pi(`wind_rose|${$i(e.config)}`,e.hass,()=>[...ki(e.hass,e.config).filter(e=>!e.schema?.some(e=>"group_by"===e.name)),{type:"grid",name:"",schema:[{name:"sectors",selector:xi(e.hass,"sectors",Bg.map(String))},{name:"calm",selector:{number:{min:0,mode:"box",step:"any"}}}]},{type:"grid",name:"",schema:[{name:"tooltip",selector:{boolean:{}}},{name:"compact",selector:{boolean:{}}}]}]),o=e.config.entities??[],a={...e,config:(n=e.config,"number"==typeof n.sectors?{...n,sectors:String(n.sectors)}:n),commit:t=>e.commit(function(e){if("string"!=typeof e.sectors)return e;const t=Number(e.sectors);return{...e,sectors:Number.isFinite(t)?t:e.sectors}}(t))};var n;const r="margin: 0 0 12px; color: var(--secondary-text-color); font-size: 0.875em; line-height: 1.4;";return R`

+ ${_t(e.hass,"editor_wind_roles")} +
${_t(e.hass,"editor_wind_now",{direction:Jg(e,o[0]),speed:Jg(e,o[1])})} +

+ ${"statistics"===e.config.source?R`

${_t(e.hass,"editor_wind_statistics")}

`:W} + ${Di(a,"chart",t,Xg)}`},disconnect(e){bn(e),iv.delete(e)},render(e){const t={...Wn(e.raw),header:"above"},o=function(e){const t=e.config.entities.length?e.config.entities:e.items.map(e=>e.config),o=t=>t&&{id:t.entity,entity:t,index:e.items.findIndex(e=>e.entityId===t.entity)};return{direction:o(t[0]),speed:o(t[1])}}(e),a=o.direction;if(!a)return R``;const n=function(e,t,o,a=Date.now()){const n=Qg(e.raw),r=e.raw.source??"history",i=fa(e.hass),s=Ma(n,a,i,n.navigate?Aa(e.host,n):0),l={start:s.start,end:s.end},c=[{entity:t.id,attribute:t.entity.attribute,bearing:!0},...o?[{entity:o.id,attribute:o.entity.attribute}]:[]],d="statistics"===r?function(e){return{period:e.end-e.start<=1728e5?"5minute":"hour",types:["mean","max"],value:"mean"}}(l):void 0,u=d?Ga[d.period]:1/0,h=d?{start:l.start-u,end:l.end}:l,p=on(e.host,e.requestUpdate).get(e.hass,c,h,r,{live:s.live,statistics:d}),m=e=>p.data.get(Ra(e))??[],f=m(c[0]),g=o?m(c[1]):void 0;return{frame:{status:p.status,error:p.error,range:l,period:n,source:r,series:[],live:s.live,back:s.back,zone:i},direction:f,speed:g?.some(e=>null!==e.v)?g:void 0,until:Math.min(l.end,a),maxHold:u}}(e,a,o.speed),{frame:r}=n,i=vn(r,e),s=R`
${_t(e.hass,"chart_no_data")}
`,l="ready"!==r.status?xn(r,e):n.direction.every(e=>null===e.v)?s:void 0;if(l)return Vn(t,i,l);const c=ev(e.raw),d=n.speed?o.speed:void 0,u=d?d.entity.unit??(d.entity.attribute?"":String(e.hass.states[d.id]?.attributes?.unit_of_measurement??"")):void 0,h=function(e){switch((e??"").trim().toLowerCase()){case"km/h":case"kmh":case"kph":return{from:[0,12,20,29,39],calm:1.8};case"mph":return{from:[0,8,13,19,25],calm:1.1};case"kn":case"kt":case"kts":case"knot":case"knots":return{from:[0,7,11,17,22],calm:1};case"ft/s":return{from:[0,10,20,30,40],calm:1.6};default:return{from:[0,3,6,9,12],calm:.5}}}(u),p=d?function(e,t,o){return e!==st&&e.length>0?e.map((t,a)=>({from:t.from,color:Ce(t.color),label:t.label??nv(t.from,e[a+1]?.from,o)})):t.map((e,a)=>({from:e,color:ov[a%ov.length],label:nv(e,t[a+1],o)}))}(as(d.entity.severity,e.hass),h.from,e):[{from:0,color:"var(--primary-color, #2f6fe4)",label:""}],m=d?tv(e.raw)??h.calm:0,f=function(e,t,o,a=1/0){const n=new Set([o]);for(const c of t?[e,t]:[e])for(const e of c)n.add(e.t),Number.isFinite(a)&&n.add(e.t+a);const r=[...n].filter(e=>e<=o).sort((e,t)=>e-t),i=[];let s=-1,l=-1;for(let c=0;c+1new Array(n).fill(0));let i=0,s=0,l=0,c=0;for(const m of e){if(i+=m.ms,null!==m.speed&&(l+=m.ms,c+=m.speed*m.ms,m.speedi>0?e/i:0,u=r.map(e=>e.map(d)),h=u.map(e=>e.reduce((e,t)=>e+t,0));let p=-1;return h.forEach((e,t)=>{e>0&&(p<0||e>h[p])&&(p=t)}),{total:i,calm:d(s),sectors:u,totals:h,prevailing:p,meanSpeed:l>0?c/l:null}}(f,c,p.map(e=>e.from),m);if(g.total<=0)return Vn(t,i,s);const v=function(e){if(!(e>0))return[];const t=e/3,o=10**Math.floor(Math.log10(t)),a=[1,2,2.5,5,10].map(e=>e*o).find(e=>e>=t-1e-9)??10*o,n=Math.max(1,Math.ceil(e/a-1e-9));return Array.from({length:n},(e,t)=>Math.round((t+1)*a*1e3)/1e3)}(100*Math.max(0,...g.totals)),y=(v[v.length-1]??100)/100,b={rose:g,n:c,bands:p,rings:v,radius:e=>10+e/y*90},_=t=>rv(t,e),x={direction:g.prevailing<0?"":Zg(g.prevailing,c),share:g.prevailing<0?"":_(g.totals[g.prevailing]),calm:_(g.calm)},w=_t(e.hass,g.prevailing<0?"wind_aria_still":d?"wind_aria_calm":"wind_aria",x),$=d?{unit:u??"",decimals:d.index>=0?e.items[d.index].decimals:1,points:n.speed??[]}:void 0;return Vn(t,i,R`
+
+ ${cv(b,a,w,e)} ${function(e,t,o){const a=iv.get(o.host);if(!(void 0!==a&&lr(o.raw)&&e.rose.totals[a]>0))return W;const n=2*a*Math.PI/e.n,[r,i]=Yg(e.radius(e.rose.totals[a]),n),s=r>0?"left":"right",l=void 0===t?[{color:e.bands[0].color,name:_t(o.hass,"wind_share"),share:e.rose.totals[a]}]:e.rose.sectors[a].map((o,a)=>({color:e.bands[a].color,name:Mn(e.bands[a].label,t),share:o})).filter(e=>e.share>0);return R``}(b,$?$.unit:void 0,e)} +
+ ${uv(b,{direction:a,speed:d},$,e)} +
`)}},pv=2*Math.PI,mv=1440,fv=6e4,gv=["on","open","opening","closing","home","playing","heating","cooling","drying","active","running","cleaning","detected"],vv=new Set(["unavailable","unknown"]);function yv(e){return void 0===e||""===e||vv.has(e)}function bv(e,t,o,a){const n=e[e.length-1];n&&n.end===t?n.end=o:e.push(a?{start:t,end:o,carried:a}:{start:t,end:o})}function _v(e,t){const o=[];for(const a of e){const e=Math.max(t.start,a.start),n=Math.min(t.end,a.end);if(n<=e)continue;const r=!0===a.carried||a.start!e.carried).length}function wv(e,t,o){return(e+la(e,o)-(t+la(t,o)))/fv}function $v(e,t,o){const a=e.start,n=e.end,r=la(a,o),i=la(Math.max(a,n-1e3),o),s=t+la(t,o),l=(e,t)=>(e=>Math.min(mv,Math.max(0,e)))((e+t-s)/fv);if(r===i)return[[l(a,r),l(n,r)]];const c=function(e,t,o){const a=la(e,o);for(;t-e>1e3;){const n=Math.floor((e+t)/2);la(n,o)===a?e=n:t=n}return 1e3*Math.floor(t/1e3)}(a,n-1e3,o);return[[l(a,r),l(c,r)],[l(c,i),l(n,i)]].filter(([e,t])=>t>e)}function kv(e){return e/mv*pv}function Mv(e,[t,o]){const a=Math.max(0,Math.ceil(t-.5)),n=Math.min(mv,Math.ceil(o-.5));for(let r=a;re+t,0);return Sv.has(t)?o:o/e.length}function Cv(e){return Sv.has(e)}function jv(e){const t=Math.max(0,Math.round(e/fv)),o=Math.floor(t/60),a=t-60*o;return 0===o?`${a} m`:a?`${o} h ${a} m`:`${o} h`}function Nv(e,t){return[Lv(e*Math.sin(t)),Lv(-e*Math.cos(t))]}function Lv(e){return Math.round(100*e)/100||0}function Tv(e,t,o,a){if(t<=0||a<=o)return"";const n=Math.max(0,e);if(a-o>=pv-1e-6){const e=(e,t)=>{const[o,a]=Nv(e,0),[n,r]=Nv(e,Math.PI);return`M${o} ${a}A${e} ${e} 0 1 ${t} ${n} ${r}A${e} ${e} 0 1 ${t} ${o} ${a}Z`};return n>0?`${e(t,1)}${e(n,0)}`:e(t,1)}const r=a-o>Math.PI?1:0,[i,s]=Nv(t,o),[l,c]=Nv(t,a),d=`M${i} ${s}A${t} ${t} 0 ${r} 1 ${l} ${c}`;if(0===n)return`${d}L0 0Z`;const[u,h]=Nv(n,a),[p,m]=Nv(n,o);return`${d}L${u} ${h}A${n} ${n} 0 ${r} 0 ${p} ${m}Z`}yt("en",{editor_days:"Days (more than 1 shows the average day)",editor_center:"In the middle",editor_day_clock_center_total:"Time on",editor_day_clock_center_now:"The time now",editor_day_clock_center_none:"Nothing",editor_bucket:"One bar for every",editor_bucket_15:"15 minutes",editor_bucket_30:"30 minutes",editor_bucket_60:"Hour",editor_aggregate_auto:"Automatic (change for meters, else average)",editor_ring_color:"Colour",editor_active:"Counts as on (empty: on, open, heating, home…)"});const Ov=[{name:"days",path:["days"],default:1},{name:"period_offset",path:["period","offset"],default:0},{name:"period_navigate",path:["period","navigate"],default:!1},{name:"center",path:["center"],default:"total"},{name:"bucket",path:["bucket"],default:"60"},{name:"aggregate",path:["aggregate"],default:"auto"},{name:"tooltip",path:["tooltip"],default:!0}];const Fv=[{type:"grid",name:"",schema:[{name:"ring_color",selector:{text:{}}},{name:"active",selector:{text:{multiple:!0}}}]}],Pv=[{name:"ring_color",path:["color"]},{name:"active",path:["active"]}];yt("en",{editor_style_day_clock:"24-hour clock",day_clock_times_one:"once",day_clock_times:"{n} times",day_clock_a_day:"{text} a day",day_clock_busiest:"{total} · busiest {time}",day_clock_peak:"peak {value} at {time}",day_clock_state:"{name} {state}",day_clock_per_day:"{label} per day",day_clock_no_data:"Nothing recorded",day_clock_now:"Now",day_clock_on:"on",day_clock_average:"Average day · last {n} days",day_clock_on_days:"on {on} of {of} days",day_clock_so_far:"{duration} so far",day_clock_range:"{from} – {to}",day_clock_average_value:"{value} on average"});const Dv=["total","now","none"],Uv=new Set(["alarm_control_panel","automation","binary_sensor","calendar","climate","cover","device_tracker","fan","humidifier","input_boolean","lawn_mower","light","lock","media_player","person","remote","schedule","script","siren","switch","timer","vacuum","valve","water_heater"]),Hv=e=>e.split(".")[0];function Iv(e){const t=e.config.active;if("string"==typeof t&&""!==t.trim()||Array.isArray(t)&&t.length>0)return"state";if(Uv.has(Hv(e.entityId)))return"state";const o=e.stateObj?.attributes??{};if(!e.config.attribute&&(void 0!==o.unit_of_measurement||void 0!==o.state_class))return"numeric";const a=e.stateObj?.state,n="string"==typeof a&&""!==a.trim()&&Number.isFinite(Number(a));return e.available&&n?"numeric":"state"}function Bv(e,t){return e.config.attribute?e.config.attribute:"state"===t&&"climate"===Hv(e.entityId)?"hvac_action":void 0}function Rv(e,t){if(void 0!==t.aggregate)return t.aggregate;const o=e.stateObj?.attributes?.state_class;return"total_increasing"===o||"total"===o?"change":"mean"}function Zv(e,t,o,a){const n=t.stateObj;let r=o.replace(/_/g," ");if(n){const t=a?e.formatEntityAttributeValue?.(n,a,o):e.formatEntityState?.(n,o);t&&(r=t)}return r.toLocaleLowerCase()}function qv(e,t){const o=Math.round(e)%mv,a=Date.UTC(2e3,0,1,Math.floor(o/60),o%60);return new Intl.DateTimeFormat(t?.locale?.language??"en",{hour:"numeric",minute:"2-digit",hour12:Go(t),timeZone:"UTC"}).format(a)}function Wv(e,t,o){return Mn(kn(e,t.decimals,o),t.unit)}function Vv(e,t=Date.now()){const{raw:o,hass:a}=e,n=o.days??1,r=Number(o.bucket??60),i=function(e,t){const o=$a(e.period);return 1===t?{last:1,unit:"day",calendar:"day",offset:o.offset,navigate:o.navigate,until:"end"}:{last:t,unit:"day",offset:o.offset,navigate:o.navigate,until:"end"}}(o,n),s=Math.max(0,i.offset+(i.navigate?Aa(e.host,i):0)),l=fa(a),c=function(e,t,o,a){const n=ha("day",ua("day",e,t),-a*o,t),r=[];for(let i=o-1;i>=-1;i--)r.push(ha("day",n,-i,t));return r}(t,l,n,s),d=0===s,u=c[c.length-1],h=Math.min(u,t),p=e.items.map(Iv),m=e.items.map((e,t)=>({entity:e.entityId,attribute:Bv(e,p[t]),text:!0})),f=on(e.host,e.requestUpdate).get(a,m,{start:c[0],end:d?t:u},"history",{live:d}),g={start:c[0],end:h},v=Math.max(1,c.slice(0,-1).filter(e=>e{const i=p[t],s=e.config,d=e.stateObj?f.data.get(Ra(m[t]))??[]:[],u={index:t,entity:e,kind:i,color:Ce(s.color??un[t%un.length]),missing:!e.stateObj,hasData:!1,intervals:[],runs:[],slots:[],heights:[],aggregate:Rv(e,o),totalMs:0,count:0,stat:""};if("state"===i){const e=function(e){if("string"==typeof e&&""!==e.trim())return new Set([e.trim()]);if(Array.isArray(e)){const t=e.filter(e=>"string"==typeof e&&""!==e.trim()).map(e=>e.trim());if(t.length)return new Set(t)}return new Set(gv)}(s.active),t=function(e,t,o){const a={active:[],known:[]};for(let n=0;n0,u.word=t.word;const o=t.active;1===n?u.intervals=o:u.runs=function(e,t,o,a){const n=new Uint16Array(mv),r=new Uint16Array(mv);for(let s=0;s+1e+t.end-t.start,0)/v;let r=0;for(let a=0;a+11===n?e:_t(a,"day_clock_a_day",{text:e}),h=[i(jv(u.totalMs))];u.count>0&&h.push(i(function(e,t){const o=Math.round(10*t)/10;return 1===o?_t(e,"day_clock_times_one"):_t(e,"day_clock_times",{n:o})}(a,u.count))),u.stat=h.join(" · ")}else{const t=d.map(e=>({...e,v:Ta(e.s),s:void 0}));u.slots=function(e,t,o,a,n,r){const i=new Map;for(let s=0;s+1=n)break;const c=Va(e,Av(l,o),a,n),d=new Map;for(const e of c){if(null===e.v)continue;const t=Math.floor(wv(e.t,l.start,r)/o);t<0||t*o>=mv||d.set(t,[...d.get(t)??[],e.v])}for(const[e,t]of d)i.set(e,[...i.get(e)??[],Ev(t,a)])}return[...i.entries()].sort((e,t)=>e[0]-t[0]).map(([e,t])=>({index:e,v:t.reduce((e,t)=>e+t,0)/t.length,days:t.length}))}(t,c,r,u.aggregate,h,l.timeZone);const o=Cv(u.aggregate);if(u.heights=function(e,t){if(0===e.length)return[];const o=e.map(e=>e.v);if(t){const e=Math.max(...o.map(Math.abs));return o.map(t=>e>0?Math.abs(t)/e:0)}const a=Math.min(...o),n=Math.max(...o);return o.map(e=>n>a?.15+.85*(e-a)/(n-a):1)}(u.slots,o),u.hasData=u.slots.length>0,u.hasData){const t=u.slots.reduce((e,t)=>t.v>e.v?t:e),i=qv(t.index*r,a);if(o){const t=Wv(u.slots.reduce((e,t)=>e+t.v,0),e,a);u.stat=_t(a,"day_clock_busiest",{total:1===n?t:_t(a,"day_clock_a_day",{text:t}),time:i})}else u.stat=_t(a,"day_clock_peak",{value:Wv(t.v,e,a),time:i})}}return u.missing?u.stat=_t(a,"entity_not_found"):u.hasData||"ready"!==f.status||(u.stat=_t(a,"day_clock_no_data")),u});return{status:f.status,error:f.error,rings:y,bounds:c,days:n,bucket:r,live:d,now:t,period:i,back:s,zone:l}}const Yv=new WeakMap;function Gv(e){let t=Yv.get(e);return t||(t={},Yv.set(e,t)),t}function Xv(e,t,o){const a=Gv(e.host);void 0!==a.timer&&clearTimeout(a.timer),a.timer=void 0,a.ring===t&&a.item===o||(a.ring=t,a.item=o,e.requestUpdate())}const Jv=new WeakMap;function Kv(e,t){if(Jv.has(e.host))return;const o=6e4-t%6e4+50;Jv.set(e.host,setTimeout(()=>{Jv.delete(e.host),"undefined"!=typeof document&&"hidden"===document.visibilityState?Kv(e,Date.now()):e.requestUpdate()},o))}const Qv=-132,ey=-120,ty=264,oy=240,ay=92;function ny(e,t,o){const a=Yv.get(e.host);if(void 0===a?.ring||void 0===a.item)return W;const n=t.rings[a.ring],r=o[a.ring]?.[a.item];if(!n||!r)return W;const{time:i,value:s}=function(e,t,o,a){const n=(e,t)=>_t(a,"day_clock_range",{from:e,to:t});if("numeric"===e.kind){const r=e.slots[t],i=n(qv(r.index*o.bucket,a),qv((r.index+1)*o.bucket,a)),s=Wv(r.v,e.entity,a);return{time:i,value:1===o.days?s:_t(a,"day_clock_average_value",{value:s})}}if(o.days>1){const o=e.runs[t];return{time:n(qv(o.from,a),qv(o.to,a)),value:_t(a,"day_clock_on_days",{on:o.on,of:o.of})}}const r=e.intervals[t],i=o.live&&r.end>=o.now-1e3,s=i?_t(a,"day_clock_now").toLocaleLowerCase():Ko(r.end,a),l=jv(r.end-r.start);return{time:n(Ko(r.start,a),s),value:i?_t(a,"day_clock_so_far",{duration:l}):l}}(n,r.item,t,e.hass),[l,c]=Nv(r.r+4,r.angle);return R``}function ry(e,t,o){const a=t.toLocaleLowerCase().includes(o.toLocaleLowerCase())?_t(e,"day_clock_on"):o;return _t(e,"day_clock_state",{name:t,state:a})}function iy(e,t){const o=e.entity;if(!o.stateObj)return o.display;if("numeric"===e.kind)return _t(t,"day_clock_state",{name:o.name,state:o.display});const a=Bv(o,"state"),n=a?o.stateObj.attributes?.[a]:o.stateObj.state;return"string"!=typeof n?_t(t,"day_clock_state",{name:o.name,state:o.display}):ry(t,o.name,Zv(t,o,n,a))}function sy(e){return e.rings.find(e=>!e.missing)}function ly(e,t,o){const a=Math.min(o,t/Math.max(1,.56*e.length)),n=.62*o;if(a>=n)return{size:a,text:e};const r=Math.max(3,Math.floor(t/(.56*n))-1);return{size:n,text:`${e.slice(0,r).trimEnd()}…`}}function cy(e,t,o){const a=function(e,t){const o=e.raw.center??"total";if("none"===o||0===t.rings.length)return;const{hass:a}=e,n=sy(t);if("now"===o&&t.live&&1===t.days&&n)return[Ko(t.now,a),iy(n,a)];const r=t.rings.find(e=>"state"===e.kind&&e.hasData);if(r){const e=r.word?Zv(a,r.entity,r.word,Bv(r.entity,"state")):_t(a,"day_clock_on"),o=ry(a,r.entity.name,e);return[jv(r.totalMs),1===t.days?o:_t(a,"day_clock_per_day",{label:o})]}const i=t.rings.find(e=>"numeric"===e.kind&&e.hasData);if(!i)return;const s=Cv(i.aggregate)?Wv(i.slots.reduce((e,t)=>e+t.v,0),i.entity,a):i.entity.display;return[s,i.entity.name]}(e,t);if(!a)return W;const n=Math.min(18,.4*o),r=Math.min(11,.25*o),i=(1.05*n+1.3*r)/2,s=2*Math.sqrt(Math.max(0,o*o-i*i))-8,l=ly(a[0],s,n),c=ly(a[1],s,r),d=.3*l.size+c.size,u=(-.75*l.size+d+.3*c.size)/2;return Z``}function dy(e,t){const o=function(e,t=92,o=44){const a=e.map(e=>"state"===e?15:22),n=a.reduce((e,t)=>e+t,0)+4*Math.max(0,e.length-1),r=t-o,i=n>r?r/n:1,s=[];let l=t;for(const c of a){const e=c*i;s.push({r0:l-e,r1:l}),l-=e+4*i}return{rings:s,hole:s.length?s[s.length-1].r0:t}}(t.rings.map(e=>e.kind),ay),a=lr(e.raw),n=t.rings.map((e,a)=>{const{r0:n,r1:r}=o.rings[a];return"state"===e.kind?function(e,t,o,a){const n=t.zone.timeZone;return 1===t.days?e.intervals.map((e,r)=>{const i=$v(e,t.bounds[0],n),s=i.map(([e,t])=>Tv(o,a,kv(e),kv(t))).join("");return{d:s,angle:kv(i.length?(i[0][0]+i[i.length-1][1])/2:0),r:a,item:r}}):e.runs.map((e,t)=>({d:Tv(o,a,kv(e.from),kv(e.to)),angle:kv((e.from+e.to)/2),r:a,opacity:Math.max(.12,e.on/Math.max(1,e.of)),item:t}))}(e,t,n,r):function(e,t,o,a){const n=mv/t.bucket,r=Math.min(.03,pv/n/8),i=Cv(e.aggregate),s=[];return e.slots.forEach((t,l)=>{if(i&&0===t.v)return;const c=t.index/n*pv+r,d=(t.index+1)/n*pv-r,u=o+Math.max(.04,e.heights[l])*(a-o);s.push({d:Tv(o,u,c,d),angle:(c+d)/2,r:u,item:l})}),s}(e,t,n,r)}),r=Yv.get(e.host),i=t.rings.map((i,s)=>{const{r0:l,r1:c}=o.rings[s],d=1===t.days&&n[s].length<=48,u="state"===i.kind?Z``:Z``;return Z` + ${u} + ${n[s].map((t,o)=>Z`Xv(e,s,o):W} + @pointerdown=${a?()=>Xv(e,s,o):W} + @pointerleave=${a?t=>function(e,t){if("touch"!==t.pointerType&&"pen"!==t.pointerType)return void Xv(e,void 0,void 0);const o=Gv(e.host);void 0!==o.timer&&clearTimeout(o.timer),o.timer=setTimeout(()=>{o.timer=void 0,Xv(e,void 0,void 0)},ar)}(e,t):W} + @focus=${a?()=>Xv(e,s,o):W} + @blur=${a?()=>Xv(e,void 0,void 0):W} + >`)} + `}),s=function(e){const t=e?.locale?.language??"en";let o=Go(e);if(void 0===o)try{o=!0===new Intl.DateTimeFormat(t,{hour:"numeric"}).resolvedOptions().hour12}catch{o=!1}return o?[0,6,12,18].map(e=>new Intl.DateTimeFormat(t,{hour:"numeric",hour12:!0,timeZone:"UTC"}).format(Date.UTC(2e3,0,1,e))):["00","06","12","18"]}(e.hass),l=Array.from({length:24},(e,t)=>{const o=t/24*pv,a=t%6==0,[n,r]=Nv(95,o),[i,s]=Nv(ay+(a?9:6),o);return Z``}),c=Z` + ${s[0]} + ${s[1]} + ${s[2]} + ${s[3]}`;let d=W;if(t.live&&1===t.days){const e=kv(wv(t.now,t.bounds[0],t.zone.timeZone)),[a,n]=Nv(o.hole,e),[r,i]=Nv(101,e);d=Z` + + + `}const u=_t(e.hass,"chart_aria",{style:_t(e.hass,"editor_style_day_clock"),names:t.rings.map(e=>e.entity.name).join(", ")});return R`
+ + ${l}${c} + ${i} + ${d} ${cy(e,t,o.hole)} + + ${a?ny(e,t,n):W} +
`}function uy(e,t,o=!1){return R`
+ ${e} +
${t}
+
`}const hy={layout:"chart",group:"history",height:280,styles:r` + ${$n} + ${fr} + .dc-body { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: 8px 20px; + padding-bottom: 4px; + } + .dc-dial { + position: relative; + flex: 0 1 280px; + min-width: 200px; + aspect-ratio: 264 / 240; + } + .dc-svg { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + overflow: visible; + } + .dc-track { + fill: var(--cd-track); + } + .dc-base { + fill: none; + stroke: var(--divider-color, rgba(127, 127, 127, 0.3)); + stroke-width: 1; + } + .dc-mark { + outline: none; + transition: fill-opacity 0.15s ease; + } + .dc-ring:hover .dc-mark:not(.hover), + .dc-ring:focus-within .dc-mark:not(.hover) { + fill-opacity: 0.75; + } + .dc-mark.hover { + stroke: var(--primary-text-color); + stroke-width: 1.2; + paint-order: stroke; + } + .dc-ring { + cursor: pointer; + outline: none; + } + .dc-ring:focus-visible .dc-track, + .dc-ring:focus-visible .dc-base { + stroke: var(--primary-color); + stroke-width: 1.5; + } + .dc-tick { + stroke: var(--secondary-text-color); + stroke-opacity: 0.5; + stroke-width: 1; + } + .dc-tick.major { + stroke-opacity: 1; + stroke-width: 1.5; + } + .dc-label { + fill: var(--secondary-text-color); + font-size: 10px; + } + .dc-future { + fill: var(--card-background-color, #fff); + fill-opacity: 0.55; + pointer-events: none; + } + .dc-hand { + stroke: var(--primary-text-color); + stroke-width: 2; + stroke-linecap: round; + pointer-events: none; + } + .dc-hand-dot { + fill: var(--primary-text-color); + } + .dc-centre text { + text-anchor: middle; + pointer-events: none; + } + .dc-centre-value { + fill: var(--primary-text-color); + font-weight: 500; + } + .dc-centre-label { + fill: var(--secondary-text-color); + } + .dc-marks { + opacity: 0; + transition: opacity var(--cd-duration) var(--cd-easing); + } + :host([ready]) .dc-marks { + opacity: 1; + } + :host([static]) .dc-marks, + :host([static]) .dc-mark { + transition: none; + } + .dc-legend { + flex: 1 1 160px; + min-width: 0; + display: flex; + flex-direction: column; + gap: 10px; + } + .dc-item { + display: flex; + gap: 8px; + align-items: baseline; + min-width: 0; + } + .dc-swatch { + flex: none; + width: 10px; + height: 10px; + border-radius: 2px; + } + .dc-text { + min-width: 0; + } + .dc-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .dc-stat { + color: var(--secondary-text-color); + font-size: 0.875em; + } + .dc-item.missing .dc-name { + color: var(--secondary-text-color); + } + .dc-foot { + margin-top: 4px; + } + .dc-now { + font-weight: 500; + font-size: 1.1em; + } + .dc-tip { + position: absolute; + pointer-events: none; + z-index: 1; + white-space: nowrap; + } + .dc-tip.tip-right { + transform: translate(6px, 0); + } + .dc-tip.tip-left { + transform: translate(calc(-100% - 6px), 0); + } + .dc-tip.tip-right.tip-up { + transform: translate(6px, -100%); + } + .dc-tip.tip-left.tip-up { + transform: translate(calc(-100% - 6px), -100%); + } + `,thumbnail:()=>Z` + + + + + + `,validate:function(e){cr(e),$a(e.period);const t=e.days;if(void 0!==t&&("number"!=typeof t||!Number.isInteger(t)||t<1||t>31))throw new Error('"days" must be a whole number from 1 to 31');if(void 0!==e.center&&!Dv.includes(e.center))throw new Error(`"center" must be one of: ${Dv.join(", ")}`);if(void 0!==e.bucket&&!zv.includes(Number(e.bucket)))throw new Error(`"bucket" must be one of: ${zv.join(", ")}`);if(void 0!==e.aggregate&&!qa.includes(e.aggregate))throw new Error(`"aggregate" must be one of: ${qa.join(", ")}`);for(const o of e.entities??[]){if("object"!=typeof o||null===o)continue;const e=o.active;if(void 0===e)continue;const t="string"==typeof e&&""!==e.trim()||Array.isArray(e)&&e.every(e=>"string"==typeof e);if(!t)throw new Error('"active" must be a state or a list of states')}},editor:function(e){var t;return Di({...e,config:(t=e.config,"number"==typeof t.bucket?{...t,bucket:String(t.bucket)}:t),commit:t=>e.commit(function(e){return"string"==typeof e.bucket?{...e,bucket:Number(e.bucket)}:e}(t))},"chart",Pi("day_clock",e.hass,()=>{return[{type:"grid",name:"",schema:[{name:"days",selector:{number:{min:1,max:31,mode:"box"}}},{name:"period_offset",selector:{number:{min:0,mode:"box"}}}]},{name:"period_navigate",selector:{boolean:{}}},{name:"center",selector:xi(t=e.hass,"day_clock_center",["total","now","none"])},{type:"grid",name:"",schema:[{name:"bucket",selector:xi(t,"bucket",zv.map(String))},{name:"aggregate",selector:xi(t,"aggregate",["auto",...qa])}]},{name:"tooltip",selector:{boolean:{}}}];var t}),Ov)},entityFields:function(){return{schema:Fv,fields:Pv}},disconnect(e){bn(e),function(e){const t=Jv.get(e);void 0!==t&&clearTimeout(t),Jv.delete(e)}(e);const t=Yv.get(e);void 0!==t?.timer&&clearTimeout(t.timer),Yv.delete(e)},render(e){const t=Vv(e);t.live&&Kv(e,t.now);const o=vn({period:t.period,range:{start:t.bounds[0],end:t.bounds[t.bounds.length-1]},back:t.back,zone:t.zone},e,t.now);return"loading"===t.status?uy(o,_t(e.hass,"chart_loading")):"error"===t.status?uy(o,_t(e.hass,"chart_error",{error:t.error??""}),!0):t.rings.every(e=>!e.hasData)?uy(o,_t(e.hass,"chart_no_data")):R`
+ ${o} +
${dy(e,t)} ${function(e,t){const{hass:o}=e;let a=W;return t.days>1?a=R`
+
${_t(o,"day_clock_average",{n:t.days})}
+
`:t.live&&sy(t)&&(a=R`
+
${_t(o,"day_clock_now")}
+
${Ko(t.now,o)} · ${iy(sy(t),o)}
+
`),R`
+ ${t.rings.map(e=>R`
+ +
+
${e.entity.name}
+
${e.stat}
+
+
`)} + ${a} +
`}(e,t)}
+
`}};function py(e,t){if(null!=e&&""!==e){if("number"==typeof e&&Number.isFinite(e))return{value:e,soft:!1};if("string"==typeof e){const t=e.trim(),o=t.startsWith("~"),a=Number(o?t.slice(1):t);if(""!==t&&"~"!==t&&Number.isFinite(a))return{value:a,soft:o}}throw new Error(`"${t}" must be a number, or ~ and a number for a soft limit (e.g. "~0")`)}}function my(e,t={}){let[o,a]=e??[t.min?.value??0,t.max?.value??1];if(t.fromZero&&(o>0&&(o=0),a<0&&(a=0)),void 0!==t.minRange&&a-os.value?r:s.value),r<=n&&(r=n+1),[n,r]}function fy(e,t,o){const a=[];for(const c of e){const e=Math.min(c.end??c.t,o);if(c.t>=o)break;a.push({...c,start:c.t,end:e,t:Math.round((c.t+e)/2)})}const n=a.filter(e=>null!==e.v);if(0===n.length)return a;const r=a[0],i=a[a.length-1],s=[...a];null!==r.v&&r.t>t.start&&s.unshift({t:t.start,v:r.v,carried:!0});const l=Math.min(t.end,o);return null!==i.v&&i.t({type:"grid",name:"",schema:e}),by=e=>({name:e,selector:{boolean:{}}}),_y=(e,t,o,a="any")=>({name:e,selector:{number:{mode:"box",step:a,...void 0!==t?{min:t}:{},...void 0!==o?{max:o}:{}}}}),xy=e=>({name:e,selector:{text:{}}});function wy(e){const t={};for(const o of["y_min","y_max","y_min_secondary","y_max_secondary"])"number"==typeof e[o]&&(t[o]=String(e[o]));return t}const $y=[{name:"series_color",path:["color"]},{name:"curve",path:["curve"]},{name:"line_width",path:["line_width"]},{name:"entity_aggregate",path:["aggregate"]},{name:"y_axis_side",path:["y_axis"],default:"primary"},{name:"fixed_value",path:["fixed_value"],default:!1},{name:"show_graph",path:["show_graph"],default:!0},{name:"show_line",path:["show_line"],default:!0},{name:"show_fill",path:["show_fill"],default:!0},{name:"show_points",path:["show_points"]},{name:"show_legend_entity",path:["show_legend"],default:!0},{name:"show_legend_state",path:["show_legend_state"],default:!1},{name:"show_indicator",path:["show_indicator"],default:!1},{name:"show_state",path:["show_state"],default:!1}];yt("en",{editor_style_trend:"Trend",trend_min:"Low",trend_avg:"Average",trend_max:"High"});const ky=["line","bar","none"],My=["solid","fade","none"],zy=["hover","always","never"],Ay=["default","left","right","center"],Sy=["right","left","state"],Ey=["left","right","center"],Cy=["smooth","hard"];function jy(e,t){return void 0===e?t:!0===e?"always":!1===e?"never":e}function Ny(e){return void 0===e||!0===e?"fade":!1===e?"none":e}const Ly=e=>"number"==typeof e&&Number.isFinite(e)?e:void 0;function Ty(e){return{graph:e.graph??"line",fill:Ny(e.fill),points:jy(e.points,"hover"),labels:jy(e.labels,"hover"),pointsPerHour:Ly(e.points_per_hour)??.5,height:Ly(e.height)??150,lineWidth:Ly(e.line_width)??2.5,barSpacing:Ly(e.bar_spacing)??4,transition:e.color_transition??"smooth",extrema:!0===e.extrema,average:!0===e.average,legend:!1!==e.legend,factor:Math.pow(10,Ly(e.value_factor)??0),alignHeader:e.align_header??"default",alignIcon:e.align_icon??"right",alignState:e.align_state??"left",fontSize:Ly(e.font_size)??100,fontSizeHeader:Ly(e.font_size_header),colorName:!0===e.color_name,colorValue:!0===e.color_value,colorIcon:!0===e.color_icon,group:!0===e.group,iconImage:"string"==typeof e.icon_image&&""!==e.icon_image?e.icon_image:void 0,primary:{min:py(e.y_min,"y_min"),max:py(e.y_max,"y_max"),minRange:Ly(e.min_range)},secondary:{min:py(e.y_min_secondary,"y_min_secondary"),max:py(e.y_max_secondary,"y_max_secondary"),minRange:Ly(e.min_range_secondary)}}}function Oy(e,t,o){if(void 0!==e&&!t.includes(e))throw new Error(`"${o}" must be one of: ${t.join(", ")}`)}function Fy(e,t,o,a=1/0,n=!1){if(void 0!==e&&("number"!=typeof e||!Number.isFinite(e)||ea||n&&!Number.isInteger(e))){throw new Error(`"${t}" must be ${n?"a whole number":"a number"} ${a===1/0?`${o} or more`:`from ${o} to ${a}`}`)}}function Py(e,t){if(void 0!==e&&"boolean"!=typeof e)throw new Error(`"${t}" must be true or false`)}function Dy(e,t,o){const a=Math.min(e.range.end,Date.now()),n="bar"===o.graph,r=n&&0===o.pointsPerHour?.5:o.pointsPerHour,i=e.group?[]:function(e,t,o=500){const a=e.end-e.start;if(a<=0||t<=0)return[];const n=Math.max(1,Math.min(o,Math.round(a/36e5*t))),r=a/n,i=[];for(let s=0;s<=n;s++)i.push(s===n?e.end:Math.round(e.start+s*r));return i}(e.range,r,n?96:500),s=t.raw.aggregate??"mean";return e.series.map(t=>{const n=t.entity.config;let r=t.points;n.fixed_value&&null!==t.entity.value?r=[{t:e.range.start,v:t.entity.value},{t:a,v:t.entity.value}]:e.group?r=fy(t.points,e.range,a):i.length&&(r=fy(function(e,t,o,a,n){if(t.length<2)return e;let r=e;const i=e[e.length-1],s=e[e.length-2];return"history"===a&&i&&s&&i.t===n&&i.v===s.v&&(r=e.slice(0,-1)),"statistics"===a?Ya(r,t,o,n):Va(r,t,o,n)}(t.points,i,n.aggregate??s,e.source,a),e.range,a)),1!==o.factor&&(r=r.map(e=>null===e.v?e:{...e,v:e.v*o.factor}));const l=void 0!==n.color?Ce(n.color):void 0,c=!l&&(e=>e.config.severity!==st)(t.entity),d=null===t.entity.value?null:t.entity.value*o.factor,u=l??(c&&null!==d?Ce(Te(d,t.entity.bands).color):t.color);return{index:t.index,entity:t.entity,own:n,points:r,color:c?void 0:l??t.color,current:u,curve:t.curve,secondary:"secondary"===n.y_axis,graphed:!1!==n.show_graph}})}function Uy(e){let t=1/0,o=-1/0;for(const a of e)for(const e of a.points)null!==e.v&&(e.vo&&(o=e.v));return t===1/0?void 0:[t,o]}const Hy=e=>Math.round(1e4*e)/100+"%";function Iy(e,t,o){return e.filter(({series:e})=>void 0===e.color).map(({series:e})=>{const a=function(e,t,o){const[a,n]=t,r=e=>Math.min(1,Math.max(0,(e-a)/(n-a))),i=[];return e.forEach((t,a)=>{const n=Ce(t.color),s=Number.isFinite(t.from)?r(t.from):0,l=a+1 + ${a.map(e=>Z``)} + `})}const By=e=>e.color??`url(#cd-trend-${e.index})`;function Ry(e,t){return void 0!==e.start&&void 0!==e.end&&e.end>e.start?`${Ko(e.start,t.hass)} – ${Ko(e.end,t.hass)}`:Ko(e.t,t.hass)}function Zy(e,t,o,a){const n=o.colorIcon?`color:${a}`:"";return o.iconImage?R``:R` + + `}function qy(e,t,o,a){if(!t.extrema&&!t.average)return W;const n=function(e,t){let o,a,n=0,r=0;for(let i=0;ia.v)&&(a={t:s.t,v:s.v})),t){const t=e[i+1],o=t?t.t-s.t:0;n+=s.v*o,r+=o}else s.carried||(n+=s.v,r+=1)}if(o&&a)return{min:o,max:a,mean:r>0?n/r:(o.v+a.v)/2}}(e.points,a);if(!n)return W;const r=(t,a,n)=>R`${_t(o.hass,t)} ${(t=>Mn(kn(t,e.entity.decimals,o.hass),e.entity.unit))(a)}${void 0!==n?R` · ${Ko(n,o.hass)}`:W}`;return R`
+ ${t.extrema?r("trend_min",n.min.v,n.min.t):W} + ${t.average?r("trend_avg",n.mean):W} + ${t.extrema?r("trend_max",n.max.v,n.max.t):W} +
`}const Wy={layout:"chart",group:"history",height:170,gridOptions:{columns:6,min_columns:3,min_rows:2},styles:r` + ${$n} + ${fr} + .content.style-trend { + padding: 0; + flex: 1; + display: flex; + flex-direction: column; + } + .content.style-trend > .chart { + flex: 1; + display: flex; + flex-direction: column; + } + /* The graph sits on the card's bottom edge, its corners cut by the card's rounding. */ + ha-card:has(> .content.style-trend) { + overflow: hidden; + display: flex; + flex-direction: column; + height: 100%; + } + ha-card:has(> .content.style-trend .trend.group) { + box-shadow: none; + border: none; + background: none; + } + .trend { + flex: 1; + display: flex; + flex-direction: column; + } + .trend-top { + padding: 16px 16px 0; + } + .trend.group .trend-top { + padding: 0 0 0; + } + .trend.no-graph .trend-top { + padding-bottom: 16px; + } + .trend-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + min-width: 0; + cursor: pointer; + border-radius: 4px; + font-size: 14px; + } + .trend-header.icon-left { + flex-direction: row-reverse; + justify-content: flex-end; + } + .trend-header.align-left { + justify-content: flex-start; + } + .trend-header.align-right { + justify-content: flex-end; + } + .trend-header.align-center { + justify-content: center; + } + .trend-name { + font-size: 1.15em; + font-weight: 500; + line-height: 1.4em; + color: var(--primary-text-color); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + } + .trend-icon { + flex: none; + display: inline-flex; + color: var(--state-icon-color, #44739e); + } + .trend-icon ha-state-icon { + --mdc-icon-size: 1.7em; + width: 1.7em; + height: 1.7em; + } + .trend-icon img { + width: 1.7em; + height: 1.7em; + object-fit: contain; + } + .trend-states { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0 16px; + min-width: 0; + padding-bottom: 4px; + } + .trend-states.align-right { + flex-direction: row-reverse; + } + .trend-states.align-center { + justify-content: space-evenly; + } + .trend-states .trend-icon { + align-self: center; + font-size: 1.15em; + } + .trend-state { + position: relative; + display: flex; + align-items: baseline; + gap: 4px; + min-width: 0; + cursor: pointer; + border-radius: 4px; + } + .trend-state:not(.small) { + margin-top: 4px; + } + .trend-value { + font-size: 2.25em; + font-weight: 400; + line-height: 1.15; + letter-spacing: -0.01em; + white-space: nowrap; + font-variant-numeric: tabular-nums; + } + .trend-unit { + font-size: 1.15em; + font-weight: 400; + color: var(--secondary-text-color); + white-space: nowrap; + } + /* While hovering, the reading's time sits after the value. */ + .trend-when { + align-self: center; + margin-left: 6px; + font-size: 0.8125em; + color: var(--secondary-text-color); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; + } + .trend-secondary { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 2px; + min-width: 0; + padding-top: 8px; + } + .trend-states.align-right .trend-secondary { + align-items: flex-start; + } + .trend-state.small { + align-items: center; + gap: 6px; + } + .trend-state.small .trend-value { + font-size: 1em; + font-weight: 500; + letter-spacing: 0; + } + .trend-state.small .trend-unit { + font-size: 0.875em; + } + .trend-state .swatch { + align-self: center; + } + /* Low, average and high, as the line chart's highs and lows. */ + .trend-info { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 2px 0 8px; + } + .trend-chip { + height: 20px; + box-sizing: border-box; + padding: 0 8px; + border-radius: 10px; + font-size: 11px; + line-height: 20px; + white-space: nowrap; + font-variant-numeric: tabular-nums; + color: var(--primary-text-color); + background: color-mix(in srgb, var(--series) 16%, var(--card-background-color, #fff)); + } + .trend-chip.trend_avg { + color: var(--secondary-text-color); + background: color-mix(in srgb, var(--primary-text-color) 6%, var(--card-background-color, #fff)); + } + .trend-chip-type { + font-weight: 500; + } + .trend-chip-time { + color: var(--secondary-text-color); + } + .trend-legend { + padding: 0 0 6px; + } + .trend .entity:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: 2px; + } + .trend-graph { + position: relative; + margin-top: auto; + width: 100%; + max-height: 400px; + } + .trend-graph.hoverable { + touch-action: pan-y; + cursor: crosshair; + } + .trend-graph.message { + display: flex; + align-items: center; + justify-content: center; + color: var(--secondary-text-color); + font-size: 0.875em; + min-height: 48px; + } + .trend-graph.message.error { + color: var(--error-color, #db4437); + } + .trend-graph .plot { + overflow: hidden; + } + .style-trend .line { + stroke-linecap: round; + stroke-linejoin: round; + } + .style-trend .dots { + stroke-linecap: round; + } + .style-trend .fill.solid { + fill-opacity: 0.2; + } + /* Columns as on the column chart: rounded at the outer end, growing in from the base. */ + .trend-cols { + position: absolute; + inset: 0; + pointer-events: none; + } + .trend-col { + position: absolute; + box-sizing: border-box; + border-radius: 4px 4px 0 0; + transform-origin: 50% 100%; + animation: cd-trend-grow var(--cd-duration) var(--cd-easing); + } + .trend-col.down { + border-radius: 0 0 4px 4px; + transform-origin: 50% 0; + } + :host([static]) .trend-col { + animation: none; + } + @keyframes cd-trend-grow { + from { + transform: scaleY(0); + } + } + .trend-labels-wrap { + position: absolute; + inset: 0; + pointer-events: none; + transition: opacity 0.2s ease; + z-index: 1; + } + .trend-labels-wrap.labels-hover { + opacity: 0; + } + .trend-graph:hover .trend-labels-wrap.labels-hover, + .trend-graph:active .trend-labels-wrap.labels-hover { + opacity: 1; + } + .trend-labels { + position: absolute; + top: 0; + bottom: 0; + display: flex; + flex-direction: column; + justify-content: space-between; + align-items: flex-start; + padding: 6px 8px; + box-sizing: border-box; + } + .trend-labels.primary { + left: 0; + } + .trend-labels.secondary { + right: 0; + align-items: flex-end; + } + /* Tinted with the series colour, as the line chart's high and low labels. */ + .trend-labels span { + height: 18px; + box-sizing: border-box; + padding: 0 6px; + border-radius: 9px; + font-size: 11px; + line-height: 18px; + white-space: nowrap; + font-variant-numeric: tabular-nums; + color: var(--primary-text-color); + background: color-mix(in srgb, var(--series) 16%, var(--card-background-color, #fff)); + } + .trend-graph .crosshair { + z-index: 1; + pointer-events: none; + } + .trend-graph .hover-dot { + z-index: 2; + pointer-events: none; + } + .bar-hover { + position: absolute; + top: 0; + bottom: 0; + background: color-mix(in srgb, var(--primary-text-color) 7%, transparent); + pointer-events: none; + } + `,thumbnail:()=>Z` + + + + + `,validate:function(e){pn(e),cr(e),Oy(e.graph,ky,"graph"),Oy(e.fill,[...My,!0,!1],"fill"),Oy(e.points,[...zy,!0,!1],"points"),Oy(e.labels,[...zy,!0,!1],"labels"),Oy(e.color_transition,Cy,"color_transition"),Oy(e.align_header,Ay,"align_header"),Oy(e.align_icon,Sy,"align_icon"),Oy(e.align_state,Ey,"align_state"),Fy(e.points_per_hour,"points_per_hour",0,60),Fy(e.height,"height",20,1e3),Fy(e.line_width,"line_width",St,Et),Fy(e.bar_spacing,"bar_spacing",0,50),Fy(e.value_factor,"value_factor",-9,9,!0),Fy(e.font_size,"font_size",25,400),Fy(e.font_size_header,"font_size_header",6,60),Fy(e.min_range,"min_range",0),Fy(e.min_range_secondary,"min_range_secondary",0);for(const t of["extrema","average","legend","color_name","color_value","group"])Py(e[t],t);if(void 0!==e.icon_image&&"string"!=typeof e.icon_image)throw new Error('"icon_image" must be a URL');Ty(e);for(const t of e.entities??[]){if("object"!=typeof t)continue;const e=t,o=t=>`"${t}" of ${e.entity}`;gy(e.aggregate,o("aggregate")),Oy(e.y_axis,["primary","secondary"],`y_axis of ${e.entity}`),Fy(e.line_width,`line_width of ${e.entity}`,St,Et);for(const t of["fixed_value","show_state","show_indicator","show_graph","show_line","show_fill","show_points","show_legend","show_legend_state"])if(void 0!==e[t]&&"boolean"!=typeof e[t])throw new Error(`${o(t)} must be true or false`)}},editor:function(e){const t=Pi(`trend|${$i(e.config)}`,e.hass,()=>{return t=e.hass,o=e.config,[...ki(t,o),_y("points_per_hour",0,60),yy({name:"graph",selector:xi(t,"graph",["line","bar","none"])},{name:"fill",selector:xi(t,"fill",["fade","solid","none"])},{name:"curve",selector:xi(t,"curve",an)},{name:"line_width",selector:Vt},{name:"points",selector:xi(t,"points",["hover","always","never"])},{name:"labels",selector:xi(t,"labels",["hover","always","never"])},_y("height",20,1e3,1),_y("bar_spacing",0,50,1)),{name:"color_transition",selector:xi(t,"color_transition",["smooth","hard"])},yy(by("extrema"),by("average"),by("legend"),by("trend_tooltip")),yy(xy("trend_y_min"),xy("trend_y_max"),_y("min_range",0)),yy(xy("y_min_secondary"),xy("y_max_secondary"),_y("min_range_secondary",0)),yy({name:"align_header",selector:xi(t,"align_header",["default","left","right","center"])},{name:"align_icon",selector:xi(t,"align_icon",["right","left","state"])},{name:"align_state",selector:xi(t,"align_state",["left","right","center"])},_y("font_size",25,400,1),_y("font_size_header",6,60,1),_y("value_factor",-9,9,1)),yy(by("color_name"),by("trend_color_icon"),by("color_value"),by("group")),{name:"icon_image",selector:{text:{}}}];var t,o}),o={...e,config:{...e.config,...wy(e.config)},commit:t=>e.commit(function(e){let t=e;for(const o of["y_min","y_max","y_min_secondary","y_max_secondary"]){const e=t[o];"string"==typeof e&&""!==e.trim()&&!e.trim().startsWith("~")&&Number.isFinite(Number(e))?t={...t,[o]:Number(e)}:""===e&&(t={...t},delete t[o])}return t}(t))};return Di(o,"chart",t,vy,{legend:"editor_trend_legend",labels:"editor_trend_labels",group:"editor_trend_group"})},entityFields:function(e){return{schema:Pi("trend-entity",e,()=>function(e){return[yy({name:"series_color",selector:{text:{}}},{name:"curve",selector:xi(e,"curve",an)},{name:"line_width",selector:Vt},{name:"entity_aggregate",selector:xi(e,"aggregate",qa)},{name:"y_axis_side",selector:xi(e,"y_axis_side",["primary","secondary"])}),yy(by("show_state"),by("show_indicator"),by("show_graph"),by("show_line"),by("show_fill"),by("show_points"),by("show_legend_entity"),by("show_legend_state"),by("fixed_value"))]}(e)),fields:$y}},disconnect(e){bn(e),Pn(e),sr(e)},render:function(e){const t=Ty(e.raw),{show:o}=e.config,a=mn(e),n=Dy(a,e,t),r=n[0],i=r.entity,s=n.filter(e=>e.graphed),l=function(e,t){if(void 0!==t)return e.map(e=>{const o=e.points.filter(e=>!e.carried),a=nr(o,t);return a<0?void 0:o[a]})}(n,lr(e.raw)&&"ready"===a.status?ir(e.host):void 0),c=t.colorName?`color:${r.current}`:"",d=o.icon&&"state"!==t.alignIcon?Zy(i,e,t,r.current):W,u=o.name||d!==W?R`
+ ${o.name?R`${i.name}`:W} ${d} +
`:W,h=(o,a)=>{const n=l?.[a],r=n?n.v:o.entity.value,i=n?{number:null===n.v?"—":kn(n.v,o.entity.decimals,e.hass),unit:o.entity.unit}:function(e,t,o,a){return null===e?{number:t.display,unit:""}:{number:kn(e*o,t.decimals,a),unit:t.unit}}(r,o.entity,t.factor,e.hass);return{parts:i,color:n&&null!==n.v&&void 0===o.color?Ce(Te(n.v,o.entity.bands).color):o.current,when:n?Ry(n,e):void 0}},p=h(r,0),m=o.value,f=n.slice(1).filter(e=>!0===e.own.show_state),g=m||f.length||"state"===t.alignIcon?R`
+ ${o.icon&&"state"===t.alignIcon?Zy(i,e,t,r.current):W} + ${m?R`
+ ${r.own.show_indicator?R``:W} + ${p.parts.number}${p.parts.unit?R`${p.parts.unit}`:W} + ${p.when?R`${p.when}`:W} +
`:W} + ${f.length?R`
+ ${f.map(e=>{const o=h(e,e.index);return R`
+ ${!1!==e.own.show_indicator?R``:W} + ${o.parts.number}${o.parts.unit?R`${o.parts.unit}`:W} +
`})} +
`:W} +
`:W,v=s.filter(e=>!1!==e.own.show_legend),y=t.legend&&s.length>1&&v.length?R`
+ ${v.map(t=>R`
+ + ${function(e,t){const o=t.raw.name;return void 0===o||e.entity.name!==o?e.entity.name:e.entity.stateObj?.attributes.friendly_name??e.entity.entityId}(t,e)} + ${t.own.show_legend_state?R`${t.entity.display}`:W} +
`)} +
`:W,b=vn(a,e),_="none"===t.graph?W:function(e,t,o,a,n){const r=`aspect-ratio:500 / ${a.height}`;if("ready"!==t.status||o.every(e=>e.points.every(e=>null===e.v))){const o="loading"===t.status?"chart_loading":"error"===t.status?"chart_error":"chart_no_data";return R`
+ ${_t(e.hass,o,{error:t.error??""})} +
`}const i="bar"===a.graph,s=new Map;for(const b of[!1,!0]){const e=o.filter(e=>e.secondary===b);e.length&&s.set(b,my(Uy(e),{...b?a.secondary:a.primary,fromZero:i}))}const l=$o([t.range.start,t.range.end],[0,cn]),c=o.map(e=>({series:e,y:$o(s.get(e.secondary),[dn,0])})),d=i?Z``:Z`${c.map(e=>function({series:e,y:t},o,a){const n=ln(e.points).map(a=>{const n=a.map(e=>[o(e.t),t(e.v)]);return{pts:n,d:sn(n,e.curve)}}),r=!1!==e.own.show_line,i="none"!==a.fill&&!1!==e.own.show_fill,s=e.own.line_width??a.lineWidth,l=e.own.show_points??"always"===a.points,c=e.points.filter(e=>null!==e.v&&!e.carried);return Z` + ${i?Z``${t}L${e[e.length-1][0]},200L${e[0][0]},200Z`).join("")} + fill=${By(e)} + mask=${"fade"===a.fill?"url(#cd-trend-fade)":W} + >`:W} + ${r?Z`e.d).join("")} fill="none" stroke=${By(e)} + style="stroke-width:${s}px">`:W} + ${l&&c.length<=200?Z`[o(e.t),t(e.v)]))} fill="none" + stroke=${By(e)} style="stroke-width:${Lt(s)}px">`:W} + `}(e,l,a))}`,u=Z`${Iy(c,s,a)} + + + + + + + `,h=_t(e.hass,"chart_aria",{style:_t(e.hass,"editor_style_trend"),names:o.map(e=>e.entity.name).join(", ")}),p=t=>{const n=o.filter(e=>e.secondary===t),r=Uy(n);if(!r)return W;const i=t?a.secondary:a.primary,s=i.min&&!i.min.soft?i.min.value:r[0],l=i.max&&!i.max.soft?i.max.value:r[1],c=n[0].entity,d=t=>Mn(kn(t,c.decimals,e.hass),c.unit);return R`
+ ${d(l)}${d(s)} +
`},m="never"===a.labels?W:R`
${p(!1)}${p(!0)}
`,f=n&&!i&&n[o[0].index]?R`
`:W,g=n&&!i?c.map(({series:e,y:t})=>{const o=n[e.index];if(!o||null===o.v||!1===e.own.show_points)return W;const a=t(o.v)/dn;if(!(a>=0&&a<=1))return W;const r=e.color??Ce(Te(o.v,e.entity.bands).color);return R``}):W,v=n&&i&&n[o[0].index]?(()=>{const e=n[o[0].index];return void 0===e.start||void 0===e.end?W:R`
`})():W,y=lr(e.raw)?dr(e,t.range,o.map(e=>e.points.filter(e=>!e.carried))):void 0;return R`
+ ${wn(d,h,u)} ${i?function(e,t,o){const a=e.length,n=e.flatMap(({series:e,y:n},r)=>{const i=Math.min(dn,Math.max(0,n(0)));return e.points.filter(e=>null!==e.v&&!e.carried&&void 0!==e.start&&void 0!==e.end).map(s=>{const l=t(s.start)/cn,c=(t(s.end)-t(s.start))/cn,d=Math.min(dn,Math.max(0,n(s.v))),u=d>i,h=e.color??Ce(Te(s.v,e.entity.bands).color),p=`(${Hy(c)} - ${o}px) / ${a}`;return R`
`})});return R`
${n}
`}(c,l,a.barSpacing):W} ${m} ${v} + ${f} ${"never"===a.points?W:g} +
`}(e,a,s,t,l);return R`
+
+ ${u} ${b===W?W:R`
${b}
`} ${g} + ${qy(r,t,e,"history"===a.source&&!a.group&&0===t.pointsPerHour)} + ${y} +
+ ${_} +
`}},Vy=["bars","gauges","history","totals","compare","distribution"];function Yy(e){return"chart"===e.layout}const Gy={bar:is,thin:au,segmented:tu,tank:ou,ring:Wd,needle:dd,arc:Vd,line:sc,column:El,area:Gi,combined:gc,pie:Dd,rings:Kd,pictograph:jd,waffle:du,histogram:Jc,scatter:Qd,bubble:il,heatmap:Dc,box:Ts,funnel:bu,radar:Yu,polar_area:Qu,treemap:_h,sunburst:Dh,icicle:Uh,grouped_bars:Qh,bullet:np,lollipop:cp,waterfall:yp,slope:Ep,dumbbell:qp,range_area:Jp,sparkline:rm,streamgraph:_m,candlestick:Cm,violin:bf,beeswarm:Nf,sankey:Xf,timeline:bg,climate:Ig,wind_rose:hv,day_clock:hy,trend:Wy},Xy="important",Jy=" !"+Xy,Ky=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,o)=>{const a=e[o];return null==a?t:t+`${o=o.includes("-")?o:o.replace(/(?:^(webkit|moz|ms|o)|)(?=[A-Z])/g,"-$&").toLowerCase()}:${a};`},"")}update(e,[t]){const{style:o}=e.element;if(void 0===this.ft)return this.ft=new Set(Object.keys(t)),this.render(t);for(const a of this.ft)null==t[a]&&(this.ft.delete(a),a.includes("-")?o.removeProperty(a):o[a]=null);for(const a in t){const e=t[a];if(null!=e){this.ft.add(a);const t="string"==typeof e&&e.endsWith(Jy);a.includes("-")||t?o.setProperty(a,t?e.slice(0,-11):e,t?Xy:""):o[a]=e}}return q}}),Qy=Object.keys(Fe),eb=Object.keys(Ee),tb="custom",ob={text:{}},ab={entity:{domain:["sensor","input_number","number"]}}; /** * @license * Copyright 2018 Google LLC * SPDX-License-Identifier: BSD-3-Clause - */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`
- + */function nb(e){return"string"==typeof e&&""!==e.trim()&&!Number.isFinite(Number(e))}const rb={icon:{}};function ib(e){return void 0===e?"none":Qy.find(t=>Fe[t]&&uo(Fe[t],e))??"custom"}function sb(e){return eb.includes(e)}class lb 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,o=void 0===t||""===t.trim()?NaN:Number(t);return Number.isFinite(o)?o:void 0}_t(e){return _t(this.hass,e)}_getSelectors(){const e=`${bt(this.hass)}|${this.unit??""}`;if(this._selectors?.key===e)return this._selectors;const t=Qy.map(e=>({value:e,label:this._t(`editor_preset_${e}`)})),o=e=>({select:{mode:"dropdown",options:e}});return this._selectors={key:e,preset:o(t),presetCustom:o([...t,{value:tb,label:this._t("editor_preset_custom")}]),color:o([...eb,tb].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===tb||!Qy.includes(t))return;if(t===ib(this.bands))return;const o=Fe[t];this._emit(o?structuredClone(o):void 0)}_updateBand(e,t,o){const a=[...this.bands??[]],n={...a[e]};null==o||""===o?delete n[t]:n[t]=o,a[e]=n,this._emit(a)}_fromChanged(e,t){t.stopPropagation();const o=Number(t.detail?.value);""!==t.detail?.value&&null!=t.detail?.value&&Number.isFinite(o)&&this._updateBand(e,"from",o)}_fromEntityChanged(e,t){t.stopPropagation();const o=t.detail?.value;"string"==typeof o&&""!==o&&this._updateBand(e,"from",o)}_toggleFromMode(e){const t=this.bands?.[e]?.from,o=new Set(this._entityOpen);nb(t)?this._updateBand(e,"from",this._entityValue(t)??0):o.delete(e)||o.add(e),this._entityOpen=o}_colorChanged(e,t){t.stopPropagation();const o=t.detail?.value,a=this.bands?.[e]?.color??"primary";if(!o)return;const n=new Set(this._customOpen);o!==tb?(n.delete(e),this._customOpen=n,o!==a&&this._updateBand(e,"color",o)):sb(a)&&(this._customOpen=n.add(e))}_customColorChanged(e,t){t.stopPropagation();const o="string"==typeof t.detail?.value?t.detail.value.trim():"";if(!o)return;const a=new Set(this._customOpen);a.delete(e),this._customOpen=a,this._updateBand(e,"color",o)}_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,o="number"==typeof t?t:nb(t)?this._entityValue(t)??NaN:"string"==typeof t&&""!==t.trim()?Number(t):NaN;e.push({from:Number.isFinite(o)?o+10:0,color:"primary"}),this._emit(e)}_renderBand(e,t,o){const a=!sb(e.color)||this._customOpen.has(t),n=nb(e.from)?e.from:void 0,r=void 0!==n||this._entityOpen.has(t);return R`
+ this._fromChanged(t,e)} > @@ -2194,8 +6750,8 @@ const et=pe(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de data-role="color" .hass=${this.hass} .label=${this._t("editor_band_color")} - .selector=${n.color} - .value=${o?Bl:e.color} + .selector=${o.color} + .value=${a?tb:e.color} @value-changed=${e=>this._colorChanged(t,e)} > this._labelChanged(t,e)} > @@ -2212,7 +6768,7 @@ const et=pe(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=${ql} + .selector=${rb} .value=${e.icon??""} @value-changed=${e=>this._iconChanged(t,e)} > @@ -2235,31 +6791,31 @@ const et=pe(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de data-role="from-entity" .hass=${this.hass} .label=${this._t("editor_band_from_entity_label")} - .selector=${Rl} - .value=${a??""} + .selector=${ab} + .value=${n??""} @value-changed=${e=>this._fromEntityChanged(t,e)} >`:W} - ${o?R`this._customColorChanged(t,e)} >`:W} -
`}render(){const e=this._getSelectors(),t=Wl(this.bands);return R``}render(){const e=this._getSelectors(),t=ib(this.bands);return R` - ${(this.bands??[]).map((t,n)=>this._renderBand(t,n,e))} - `}}Yl.properties={hass:{attribute:!1},bands:{attribute:!1},unit:{attribute:!1},_customOpen:{state:!0},_entityOpen:{state:!0}},Yl.styles=r` + ${(this.bands??[]).map((t,o)=>this._renderBand(t,o,e))} + `}}lb.properties={hass:{attribute:!1},bands:{attribute:!1},unit:{attribute:!1},_customOpen:{state:!0},_entityOpen:{state:!0}},lb.styles=r` :host { display: block; } @@ -2326,7 +6882,7 @@ const et=pe(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",Yl);const Gl={number:{mode:"box",step:"any"}},Xl={entity:{domain:["sensor","input_number","number"]}};function Jl(e){return null==e||""===e?"default":"number"==typeof e||""!==e.trim()&&Number.isFinite(Number(e))?"number":"entity"}class Kl extends le{willUpdate(e){e.has("value")&&"default"!==Jl(this.value)&&(this._mode=void 0)}_modeSelectorFor(){const e=(e,t)=>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`_t(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")}],o=t.map(e=>e.label).join("|");return this._modeSelector?.key!==o&&(this._modeSelector={key:o,selector:{select:{mode:"dropdown",options:t}}}),this._modeSelector.selector}get _currentMode(){return this._mode??ub(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,o="number"==typeof t?t:""===t||null==t?NaN:Number(t);this._mode="number",this._emit(Number.isFinite(o)?o: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=>_t(this.hass,e),t=this._currentMode;return R``:"entity"===t?R``:W}`}}Kl.properties={hass:{attribute:!1},label:{attribute:!1},value:{attribute:!1},defaultLabel:{attribute:!1},_mode:{state:!0}},Kl.styles=r` + >`:W}`}}hb.properties={hass:{attribute:!1},label:{attribute:!1},value:{attribute:!1},defaultLabel:{attribute:!1},_mode:{state:!0}},hb.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",Kl);const Ql=["min","max","target"];function ec(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 tc extends le{constructor(){super(),this._computeLabel=e=>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._t(`editor_${e.name}`),this._expanded=new Set}_t(e,t){return _t(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,o){this._emit(zt(this._asConfig,e,t,o))}_move(e,t){const o=new Set;for(const a of this._expanded)o.add(a===e?t:a===t?e:a);this._expanded=o,this._emit(function(e,t,o){const a=[...e.entities??[]];if(t<0||t>=a.length||t===o)return e;const[n]=a.splice(t,1);return a.splice(Math.max(0,Math.min(o,a.length)),0,n),{...e,entities:a}}(this._asConfig,e,t))}_remove(e){const t=new Set;for(const o of this._expanded)o!==e&&t.add(o>e?o-1:o);this._expanded=t,this._emit(function(e,t){const o=[...e.entities??[]];return t<0||t>=o.length?e:(o.splice(t,1),{...e,entities:o})}(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,o){o.stopPropagation();const a=this.entities?.[e];if(void 0===a)return;const n="string"==typeof a?{entity:a}:a,r=po(n,t,o.detail?.value??{});if(r===n)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,o,a,n){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=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)} + @value-changed=${e=>this._formChanged(t,n,e)} + >`}_renderOverrides(e,t){const o=e=>t=>{t.stopPropagation(),e(t.detail?.value)};return R`
+ ${this._form("entity-attribute",t,e,function(e){let t=eo.get(e);return t||(t=[{name:"attribute",selector:{attribute:{entity_id:e}}}],eo.set(e,t)),t}(e.entity),qt)} + ${this._form("entity-text",t,e,Qt,Zt)} ${this.chartFields?this._form("entity-chart",t,e,this.chartFields.schema,this.chartFields.fields):W} this._set(t,"icon",e))} + @value-changed=${o(e=>this._set(t,"icon",e))} > - ${Ql.map(o=>R`R`this._set(t,o,e))} + @value-changed=${o(e=>this._set(t,a,e))} >`)} this._set(t,"severity",e))} + .unit=${e.unit??this.unit??mb(this.hass,e.entity,e.attribute)} + @value-changed=${o(e=>this._set(t,"severity",e))} > - ${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`
+ ${this._form("entity-actions",t,e,Kt,Xt)} +
`}_renderRow(e,t,o){const a=Mt(e),n="string"==typeof e?{entity:e}:e,r=this._rowLabel(a),i=this._expanded.has(t);return R`
${r.text}
{e.stopPropagation(),this._set(t,"entity",e.detail?.value),this.requestUpdate()}} > @@ -2410,7 +6966,7 @@ const et=pe(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de class="down" .path=${"M11,4H13V16L18.5,10.5L19.92,11.92L12,19.84L4.08,11.92L5.5,10.5L11,16V4Z"} .label=${this._t("editor_move_down")} - .disabled=${t===n-1} + .disabled=${t===o-1} @click=${()=>this._move(t,t+1)} > this._toggle(t)} >
- ${i?this._renderOverrides(a,t):W} -
`}render(){const e=this.entities??[];return R`${e.map((t,n)=>this._renderRow(t,n,e.length))} + ${i?this._renderOverrides(n,t):W} +
`}render(){const e=this.entities??[];return R`${e.map((t,o)=>this._renderRow(t,o,e.length))} `}}tc.properties={hass:{attribute:!1},entities:{attribute:!1},unit:{attribute:!1},chartFields:{attribute:!1},_expanded:{state:!0}},tc.styles=r` + >`}}fb.properties={hass:{attribute:!1},entities:{attribute:!1},unit:{attribute:!1},chartFields:{attribute:!1},_expanded:{state:!0}},fb.styles=r` :host { display: block; } @@ -2488,29 +7044,29 @@ const et=pe(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",tc);class nc extends le{constructor(){super(...arguments),this._label=e=>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`_t(this.hass,e),this._computeLabel=e=>_t(this.hass,`editor_${e.name}`),this._computeRuleLabel=e=>_t(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:[]}),o=t?.constructor;await(o?.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 o=t.detail?.value??{};this._commit(po(this._config,e,o))}_form(e,t,o){return R`this._formChanged(n,e)} + @value-changed=${e=>this._formChanged(o,e)} >`}_panel(e,t){return R`
${t}
-
`}_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``}_subChanged(e,t){t.stopPropagation(),this._config&&this._commit(kt(this._config,e,t.detail?.value))}_chartFields(){const e=Gy[this._config?.style??"bar"];return e&&Yy(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 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._ruleForm("include")} ${this._ruleForm("exclude")}`}_ruleData(e){const t=this._config?.[e],o={};if(!t||"object"!=typeof t)return o;for(const[a,n]of Object.entries(t))o[`${e}_${a}`]=Array.isArray(n)?n:[n];return o}_ruleChanged(e,t){if(t.stopPropagation(),!this._config)return;const o=t.detail?.value??{},a=this._ruleData(e);let n=this._config;for(const r of no[e]){const t=`${e}_${r}`,i=Array.isArray(o[t])?o[t]:[];uo(a[t]??[],i)||(n=kt(n,[e,r],i.length?i:void 0))}n!==this._config&&this._commit(n)}_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(vt)}`)}renderColours(){return R`${this._toggles(Ut)}this._subChanged(["severity"],e)} - >`}_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}`)}
+ >
`}_firstEntityUnit(){const e=this._config?.entities?.[0];return"string"==typeof e?mb(this.hass,e):e?.unit??mb(this.hass,e?.entity,e?.attribute)}_selectStyle(e){if(!this._config)return;let t=kt(this._config,["style"],"bar"===e?void 0:e);const o=[].concat(t.group_by??[]),a=o.length>0&&o.every(e=>_u.includes(e));o.length>0&&a!==gb.includes(e)&&(t=kt(t,["group_by"],void 0)),this._commit(t)}renderStyle(){const e=this._config?.style??"bar";return R`${Vy.map(t=>{const o=it.filter(e=>Gy[e].group===t);return o.length?R`
${this._label(`editor_group_${t}`)}
- ${n.map(t=>{const n=Nl[t],o=Ol(n)?R``:Xt(t);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}`:W})} + ${vb.includes(e)?R`

${this._label("editor_gauge_help")}

+ ${this._form("gauge",function(e,t,o){const a="text"===o,n=`${bt(e)}|${t}|${a}`;let r=Rt.get(n);if(!r){const o={number:{min:-360,max:360,step:1,mode:"box",unit_of_measurement:"°"}};r=[{type:"grid",name:"",schema:[{name:"start_angle",selector:o},{name:"end_angle",selector:o}]},{name:"center",selector:{select:{mode:"dropdown",options:["value","band","name","text","none"].map(t=>({value:t,label:_t(e,`editor_center_mode_${t}`)}))}}},...a?[{name:"center_text",selector:{text:{}}}]:[],..."needle"===t?[{name:"tick_labels",selector:{boolean:{}}}]:[]],Rt.set(n,r)}return r}(this.hass,e,this._config?.center),Bt)}`:W}`}renderChart(){const e=Gy[this._config?.style??"bar"];return e&&Yy(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(Dt)} + ${this._form("display",function(e){const t=bt(e);return lo?.language!==t&&(lo={language:t,schema:ao(e)}),lo.schema}(this.hass),Ht)}`}_toggles(e){const t=ho(this._config,e);return R`
+ ${e.map(({name:o})=>{const a=this._label(`editor_${o}`),n=!0===t[o];return R`
+ this._toggle(e,o,!n)}>${a}this._toggle(e,n,t.target.checked)} + data-name=${o} + aria-label=${a} + .checked=${gt(n)} + @change=${t=>this._toggle(e,o,t.target.checked)} >
`})} -
`}_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)}
+
`}_toggle(e,t,o){if(!this._config)return;const a=ho(this._config,e);this._commit(po(this._config,e,{...a,[t]:o}))}renderActions(){return this._form("actions",Kt,It)}render(){return this._config?R`
${this._form("title",Jt,Pt)}
${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())}`:W}}nc.properties={hass:{attribute:!1},_config:{state:!0}},nc.styles=r` + ${this._panel("actions",this.renderActions())}`:W}}yb.properties={hass:{attribute:!1},_config:{state:!0}},yb.styles=r` :host { display: block; } @@ -2586,7 +7144,8 @@ const et=pe(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de margin: 16px 0 4px; font-weight: 500; } - .rules-help { + .rules-help, + .gauge-help { margin: 0 0 8px; color: var(--secondary-text-color); font-size: 0.875em; @@ -2633,7 +7192,7 @@ const et=pe(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",nc);const oc=r` + `,customElements.get("chartdeck-card-editor")||customElements.define("chartdeck-card-editor",yb);const bb=r` :host { display: block; --cd-duration: 900ms; @@ -2736,8 +7295,8 @@ const et=pe(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.5.0 ","color:#fff;background:#3b8bff;font-weight:700","color:#3b8bff");const ac={rows:16,tiles:8};function rc(e){const t=ac[e.layout];return Math.max(1,Math.floor((368+t)/((e.minColumnWidth??0)+t)))}class ic 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(!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`
e.entity_id.startsWith("sensor.")&&""!==e.state.trim()&&Number.isFinite(Number(e.state))),o=e=>"battery"===e.attributes?.device_class;return t.sort((e,t)=>o(e)!==o(t)?o(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(!it.includes(t))throw new Error(`Unknown style "${String(t)}". Use one of: ${it.join(", ")}`);const o=e.sort??"none";if(!lt.includes(o))throw new Error(`Unknown sort "${String(o)}". Use one of: ${lt.join(", ")}`);if(void 0!==e.entities&&!Array.isArray(e.entities))throw new Error('"entities" must be a list');const a=(e.entities??[]).map((t,o)=>ft(t,e,o)),n={name:!0,value:!0,icon:!0,target:!0,legend:!1,...e.show},r=ut(e.columns,"columns",1),i=ut(e.limit,"limit",1),s=De(e.include,"include"),l=De(e.exclude,"exclude");s&&ft("sensor.chartdeck_check",e,0);const c={style:t,entities:a,hideUnavailable:!0===e.hide_unavailable,cardOptions:e,show:n,sort:o,animate:!1!==e.animate,colorIcon:!0===e.color_icon,legendBands:ht(e.severity)??st};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),o=Gy[t.style];if(Yy(o)&&o.validate?.(e),this._watched=function(e){const t=new Set;for(const o of e.entities){t.add(o.entity);for(const e of[o.min,o.max,o.target,...o.severity.map(e=>e.from)])"string"==typeof e&&""!==e&&t.add(e)}for(const o of e.legendBands)"string"==typeof o.from&&t.add(o.from);return t}(t),Yy(o))for(const a of o.watch?.(e)??[])this._watched.add(a);this._config&&this._config.style!==t.style&&this._disconnectStyle(),this._config=t}get _styleName(){return this._config?.style??"bar"}get _style(){return Gy[this._styleName]}_legend(e){const t=this._config;if(!t?.show.legend||0===this._count())return[];const o=e?.decimals??1;return Oe(as(t.legendBands,this.hass),e=>kn(e,o,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,o=16+(e.title?73:0)+(this._legend().length>0?32:0);if(Yy(t))return o+t.height;const a=this._count(),n=void 0!==e.columns?Math.min(e.columns,xb(t)):"rows"===t.layout?1:Math.min(a,t.tilesPerRow??3,xb(t));return o+(0===a?0:Math.ceil(a/Math.max(1,n)))*t.height}getCardSize(){return Math.max(1,Math.ceil(this._contentHeight()/50))}getGridOptions(){const e=this._count();if(Yy(this._style))return{columns:12,min_columns:6,min_rows:2,...this._style.gridOptions,rows:"auto"};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"),o=this.hass;if(!t||!o)return!0;for(const a of this._watched)if(t.states[a]!==o.states[a])return!0;if(this._config?.include){const e=rs(this._config,o);if(e.length!==this._matched.length||e.some((e,t)=>e!==this._matched[t]))return!0;for(const a of e)if(t.states[a]!==o.states[a])return!0}return t.themes?.darkMode!==o.themes?.darkMode||t.themes?.theme!==o.themes?.theme||t.locale!==o.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?Gy[this._config.style]:void 0;e&&Yy(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?Gy[this._config.style]:void 0;e&&Yy(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[o,a]of this._detachers)t.has(o)||(a(),this._detachers.delete(o));for(const o of t)this._detachers.has(o)||this._detachers.set(o,Se(o,()=>this._items[Number(o.dataset.index)].config))}_renderEntity(e,t,o,a){const n=this._config,r=Ae(e.config),i=je(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 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,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`
`}render(){const e=this._config;if(!e)return W;const t=this.hass,o=this._style;let a,n,r;const i=t?function(e,t){const o=rs(e,t).map((t,o)=>ft(t,e.cardOptions,o)),a=[...e.entities,...o].map(e=>ns(e,t));return e.hideUnavailable?a.filter(e=>e.available):a}(e,t):[];if(this._matched=t?rs(e,t):[],0!==e.entities.length||e.include)if(t)if(0===i.length)this._items=[],a=R`
${_t(t,e.include?"no_matches":"none_available")}
`;else{n=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 o="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 a=(e.item.position-t.item.position)*o;if(0!==a)return a}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,Yy(o))a=R`
+ ${o.render({items:this._items,config:e,raw:e.cardOptions,hass:t,requestUpdate:()=>this.requestUpdate(),host:this})} +
`;else{const n="tiles"===o.layout,r=function(e,t){if(void 0===t)return"rows"===e.layout?"grid-template-columns: repeat(1, minmax(0, 1fr))":"";const o=(t-1)*_b[e.layout];return`grid-template-columns: repeat(auto-fill, minmax(max(min(${e.minColumnWidth??0}px, 100%), calc((100% - ${o}px) / ${t} - 0.5px)), 1fr))`}(o,e.columns);a=R`
- ${Me(this._items,e=>s.get(e),(e,o)=>this._renderEntity(e,o,n,t))} -
`}}else this._items=[],o=W;else this._items=[],o=R`
${at(t,"no_entities")}
`;const s=this._legend(a);return R` + ${Me(this._items,e=>s.get(e),(e,a)=>this._renderEntity(e,a,o,t))} +
`}}else this._items=[],a=W;else this._items=[],a=R`
${_t(t,"no_entities")}
`;const s=this._legend(n);return R`
- ${o} + ${a}
${s.length?R`
${s.map(e=>R`${e.text}`)}
`: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}; + `}}wb.properties={hass:{attribute:!1},preview:{type:Boolean},_config:{state:!0}},wb.styles=[bb,...Object.values(Gy).map(e=>e.styles)],customElements.get("chartdeck-card")||customElements.define("chartdeck-card",wb);const $b=window.customCards??=[];$b.some(e=>"chartdeck-card"===e.type)||$b.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{wb as ChartdeckCard}; diff --git a/src/chart/annotations.ts b/src/chart/annotations.ts new file mode 100644 index 0000000..291981b --- /dev/null +++ b/src/chart/annotations.ts @@ -0,0 +1,634 @@ +import { css, html, nothing, svg } from 'lit'; +import type { SVGTemplateResult, TemplateResult } from 'lit'; +import { baseColor } from '../bands'; +import { formatNumber, withUnit } from '../format'; +import { toReading } from '../history/fetch'; +import { boundaries, wallTime, zonedTime } from '../history/timezone'; +import type { Zone } from '../history/timezone'; +import type { Range } from '../history/types'; +import { localize, registerStrings } from '../localize'; +import type { ChartContext } from '../styles/index'; +import type { CardConfig, HomeAssistant, ResolvedBand } from '../types'; +import { formatDateTime } from './axes'; +import { VIEW_H, VIEW_W } from './frame'; +import type { ChartSeries, HistoryFrame } from './frame'; +import type { PlotScales } from './grid'; + +registerStrings('en', { + annotation_at: '{label} at {time}', +}); + +/** What an annotation marks. */ +export const ANNOTATION_TYPES = ['line', 'band', 'time', 'state', 'point'] as const; +export type AnnotationType = (typeof ANNOTATION_TYPES)[number]; + +/** Which reading a point label goes on. */ +export const POINT_AT = ['max', 'min', 'last', 'first'] as const; +export type PointAt = (typeof POINT_AT)[number]; + +/** + * One annotation, as written in YAML. Values (`value`, `from`, `to`) are a number or an entity id + * whose state is the number. + */ +export interface Annotation { + type: AnnotationType; + label?: string; + color?: string; + /** line: where it is drawn. */ + value?: number | string; + /** line: dashed (the default) or solid. */ + dash?: boolean; + /** band: its bottom and top; either may be left out to run to the chart's edge. */ + from?: number | string; + to?: number | string; + /** time: "07:00" (every day) or "2026-10-28 07:00" (once), one or a list. */ + times?: string | string[]; + /** state: the entity whose changes are marked, and the state that counts (any change if absent). */ + entity?: string; + state?: string; + /** state: shade the stretches while the entity is in the state, as well as marking each start. */ + shade?: boolean; + /** point: which reading gets a label. */ + at?: PointAt; +} + +/** Default colours, picked to stand apart from the first series colours. */ +const DEFAULT_COLOR: Record = { + line: 'red', + band: 'green', + time: 'grey', + state: 'amber', + point: 'primary', +}; + +/** Above this many marks from one annotation, only the lines are drawn: labels would be a smear. */ +const MAX_MARK_LABELS = 60; +/** Rows of mark labels along the top of the plot. */ +const MARK_LABEL_ROWS = 2; +/** At most this many state changes are kept per entity. */ +const MAX_CHANGES = 2000; + +const isNumber = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v); +const isEntityId = (v: unknown): v is string => typeof v === 'string' && /^[a-z_]+\.[a-z0-9_]+$/.test(v.trim()); + +/** A value that is a number or an entity id (numeric text counts as a number). */ +function checkValue(v: unknown, where: string): void { + if (v === undefined || isNumber(v) || isEntityId(v)) return; + if (typeof v === 'string' && v.trim() !== '' && Number.isFinite(Number(v))) return; + throw new Error(`"${where}" must be a number or an entity id`); +} + +const DAILY = /^(\d{1,2}):(\d{2})(?::(\d{2}))?$/; +const LOCAL = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{1,2}):(\d{2})(?::(\d{2}))?$/; + +/** Whether a time is one the card can place: "07:00", "2026-10-28 07:00", or a full ISO time. */ +export function isTimeText(text: string): boolean { + const s = text.trim(); + const daily = DAILY.exec(s); + if (daily) return Number(daily[1]) < 24 && Number(daily[2]) < 60; + return LOCAL.test(s) || (/\d{4}-\d{2}-\d{2}T.+(Z|[+-]\d{2}:?\d{2})$/.test(s) && Number.isFinite(Date.parse(s))); +} + +/** The list of times an annotation gives, from a list or comma-separated text. */ +export function timeList(times: Annotation['times']): string[] { + const list = Array.isArray(times) ? times : typeof times === 'string' ? times.split(',') : []; + return list.map((t) => String(t).trim()).filter((t) => t !== ''); +} + +/** Throw a readable error for a bad annotation. */ +export function validateAnnotations(raw: CardConfig): void { + const list = raw.annotations; + if (raw.zones !== undefined && typeof raw.zones !== 'boolean') throw new Error('"zones" must be true or false'); + if (list === undefined) return; + if (!Array.isArray(list)) throw new Error('"annotations" must be a list'); + list.forEach((item: unknown, i) => { + const where = (k: string) => `annotations[${i}].${k}`; + if (typeof item !== 'object' || item === null || Array.isArray(item)) { + throw new Error(`"annotations[${i}]" must have a "type"`); + } + const a = item as Record; + if (!ANNOTATION_TYPES.includes(a.type as AnnotationType)) { + throw new Error(`"${where('type')}" must be one of: ${ANNOTATION_TYPES.join(', ')}`); + } + for (const k of ['label', 'color', 'state']) { + if (a[k] !== undefined && typeof a[k] !== 'string') throw new Error(`"${where(k)}" must be text`); + } + for (const k of ['dash', 'shade']) { + if (a[k] !== undefined && typeof a[k] !== 'boolean') throw new Error(`"${where(k)}" must be true or false`); + } + checkValue(a.value, where('value')); + checkValue(a.from, where('from')); + checkValue(a.to, where('to')); + if (a.entity !== undefined && !isEntityId(a.entity)) throw new Error(`"${where('entity')}" must be an entity id`); + if (a.type === 'line' && a.value === undefined) throw new Error(`"${where('value')}" is needed for a line`); + if (a.type === 'band' && a.from === undefined && a.to === undefined) { + throw new Error(`"${where('from')}" or "${where('to')}" is needed for a band`); + } + if (a.type === 'time') { + if (a.times !== undefined && typeof a.times !== 'string' && !Array.isArray(a.times)) { + throw new Error(`"${where('times')}" must be a time or a list of times`); + } + const bad = timeList(a.times as Annotation['times']).find((t) => !isTimeText(t)); + if (bad !== undefined) throw new Error(`"${where('times')}" has "${bad}": use 07:00 or 2026-10-28 07:00`); + } + if (a.type === 'state' && a.entity === undefined) + throw new Error(`"${where('entity')}" is needed for a state mark`); + if (a.at !== undefined && !POINT_AT.includes(a.at as PointAt)) { + throw new Error(`"${where('at')}" must be one of: ${POINT_AT.join(', ')}`); + } + }); +} + +/** The card's annotations; call after `validateAnnotations`. */ +export function annotationsOf(raw: CardConfig): Annotation[] { + return Array.isArray(raw.annotations) ? (raw.annotations as Annotation[]) : []; +} + +/** Entities annotations read, so their changes redraw the card. */ +export function annotationEntities(raw: CardConfig): string[] { + const ids = new Set(); + for (const a of annotationsOf(raw)) { + for (const v of [a.value, a.from, a.to, a.type === 'state' ? a.entity : undefined]) { + if (isEntityId(v)) ids.add(v.trim()); + } + } + return [...ids]; +} + +/** A number, or an entity's state as one; undefined while it has none (unavailable, missing). */ +export function resolveValue(v: number | string | undefined, hass: HomeAssistant): number | undefined { + if (v === undefined) return undefined; + if (isNumber(v)) return v; + const own = toReading(v); + if (own !== null) return own; + const reading = toReading(hass.states[v.trim()]?.state); + return reading ?? undefined; +} + +/** Values the value axis should reach, so a threshold line is always on show. */ +export function annotationValues(raw: CardConfig, hass: HomeAssistant): number[] { + return annotationsOf(raw) + .filter((a) => a.type === 'line') + .map((a) => resolveValue(a.value, hass)) + .filter(isNumber); +} + +/** + * The moments a list of times falls on within `range`: each day's "07:00" in the zone, or a + * single date and time (wall-clock in the zone, or with its own offset). + */ +export function markTimes(times: string[], range: Range, zone: Zone): number[] { + const out: number[] = []; + const days = boundaries('day', range.start, range.end, zone, 800); + for (const text of times) { + const daily = DAILY.exec(text); + if (daily) { + for (const day of days) { + const w = wallTime(day, zone.timeZone); + out.push(zonedTime(zone.timeZone, w.year, w.month, w.day, +daily[1], +daily[2], +(daily[3] ?? 0))); + } + continue; + } + const local = LOCAL.exec(text); + const t = local + ? zonedTime(zone.timeZone, +local[1], +local[2], +local[3], +local[4], +local[5], +(local[6] ?? 0)) + : Date.parse(text); + if (Number.isFinite(t)) out.push(t); + } + return [...new Set(out.filter((t) => t >= range.start && t <= range.end))].sort((a, b) => a - b); +} + +/** One entity's states over time, oldest first. */ +export interface StateChange { + t: number; + s: string; +} + +/** + * The stretches an entity spent in `state` (or each change, when no state is given), clipped to + * `range`. A stretch still going at the end runs to `until`. + */ +export function stateSpans( + changes: StateChange[], + state: string | undefined, + range: Range, + until: number, +): { start: number; end: number; s: string }[] { + const out: { start: number; end: number; s: string }[] = []; + const wanted = state?.trim().toLowerCase(); + for (let i = 0; i < changes.length; i++) { + const c = changes[i]; + if (i > 0 && changes[i - 1].s === c.s) continue; + if (wanted !== undefined && c.s.toLowerCase() !== wanted) continue; + let j = i + 1; + while (j < changes.length && changes[j].s === c.s) j++; + const end = j < changes.length ? changes[j].t : until; + const start = Math.max(c.t, range.start); + if (end <= range.start || start > range.end) continue; + out.push({ start, end: Math.min(end, range.end), s: c.s }); + } + return out; +} + +/** Rough width of a mark label in px (10 px text, with its padding). */ +export function labelWidth(text: string): number { + return Math.ceil(text.length * 5.8) + 10; +} + +/** + * Rows for mark labels so none overlap: each sits right of its line (left of it near the right + * edge), in the first of `rows` rows with room; a label with no room is left out (its line stays). + * `items` are in px; the result is a row per item, or undefined for none. + */ +export function placeMarkLabels( + items: { x: number; width: number }[], + plotWidth: number, + rows = MARK_LABEL_ROWS, +): (number | undefined)[] { + const order = items.map((_, i) => i).sort((a, b) => items[a].x - items[b].x); + const ends: number[] = Array.from({ length: rows }, () => -Infinity); + const out: (number | undefined)[] = items.map(() => undefined); + for (const i of order) { + const { x, width } = items[i]; + const left = x + 2 + width > plotWidth ? x - 2 - width : x + 2; + const row = ends.findIndex((end) => left >= end + 4); + if (row < 0) continue; + ends[row] = left + width; + out[i] = row; + } + return out; +} + +// ---------------------------------------------------------------- state history + +interface StateCache { + shape: string; + from: number; + to: number; + status: 'loading' | 'ready' | 'error'; + data: Map; + generation: number; +} + +const caches = new WeakMap(); + +interface CompressedState { + s?: string; + lu?: number; + lc?: number; +} + +/** Forget a card's state history (it leaves the page). */ +export function stopAnnotations(host: HTMLElement): void { + const c = caches.get(host); + if (c) c.generation++; + caches.delete(host); +} + +/** + * The states of the entities state marks follow, over `range`: loaded once, then kept up to date + * from `hass` while the range reaches the present. Empty while loading or after a failure: the + * chart draws without the marks rather than not at all. + */ +export function stateHistory(ctx: ChartContext, range: Range, live: boolean): Map { + const ids = [ + ...new Set( + annotationsOf(ctx.raw) + .filter((a) => a.type === 'state' && a.entity) + .map((a) => a.entity!.trim()), + ), + ]; + if (ids.length === 0) return new Map(); + const shape = ids.join(','); + let cache = caches.get(ctx.host); + const stale = + !cache || + cache.shape !== shape || + range.start < cache.from || + (!live && range.end > cache.to + 60_000) || + (live && cache.to !== Infinity); + if (stale) { + const generation = (cache?.generation ?? 0) + 1; + const next: StateCache = { + shape, + from: range.start, + to: live ? Infinity : range.end, + status: 'loading', + data: new Map(), + generation, + }; + cache = next; + caches.set(ctx.host, next); + const call = ctx.hass.callWS?.>({ + type: 'history/history_during_period', + start_time: new Date(range.start).toISOString(), + end_time: new Date(range.end).toISOString(), + entity_ids: ids, + minimal_response: true, + no_attributes: true, + significant_changes_only: false, + }); + if (call) { + call.then( + (result) => { + if (caches.get(ctx.host) !== next || next.generation !== generation) return; + for (const id of ids) { + const rows = (result?.[id] ?? []) + .map((r) => ({ t: Math.round((r.lu ?? r.lc ?? 0) * 1000), s: String(r.s ?? '') })) + .filter((r) => r.t > 0); + next.data.set(id, rows.slice(-MAX_CHANGES)); + } + next.status = 'ready'; + ctx.requestUpdate(); + }, + () => { + if (caches.get(ctx.host) !== next) return; + next.status = 'error'; + }, + ); + } + } + const c = cache!; + if (c.status === 'ready' && live) { + // Keep up with changes since loading, as the history store does for readings. + for (const id of ids) { + const state = ctx.hass.states[id]; + if (!state) continue; + const t = Date.parse(state.last_changed ?? state.last_updated ?? ''); + const rows = c.data.get(id) ?? []; + const last = rows[rows.length - 1]; + if (Number.isFinite(t) && (!last || (t > last.t && last.s !== state.state))) { + rows.push({ t, s: state.state }); + c.data.set(id, rows); + } + } + } + return c.data; +} + +// ---------------------------------------------------------------- drawing + +export interface AnnotationLayers { + /** Shaded bands and zones, drawn behind the data. */ + behind: SVGTemplateResult | typeof nothing; + /** Threshold and time lines, drawn over the data. */ + front: SVGTemplateResult | typeof nothing; + /** Labels and point marks, in HTML over the plot. */ + overlay: TemplateResult | typeof nothing; +} + +const pct = (n: number) => `${Math.round(n * 10000) / 100}%`; + +function seriesFor(a: Annotation, series: readonly ChartSeries[]): ChartSeries | undefined { + return (a.entity && series.find((s) => s.entity.entityId === a.entity!.trim())) || series[0]; +} + +/** The reading a point label sits on. */ +export function pointFor(series: ChartSeries, at: PointAt, range: Range, grouped: boolean) { + // Carried values at the edges aren't readings taken in the span (groups always are). + const pts = series.points.filter( + (p) => p.v !== null && (grouped || (!p.carried && (p.t !== range.end || at === 'last'))), + ); + if (pts.length === 0) return undefined; + if (at === 'first') return pts[0]; + if (at === 'last') return pts[pts.length - 1]; + return pts.reduce((best, p) => ((at === 'max' ? p.v! > best.v! : p.v! < best.v!) ? p : best)); +} + +/** Severity bands drawn as faint zones behind the chart, from the first series' bands. */ +function zoneRects(bands: ResolvedBand[], scales: PlotScales): SVGTemplateResult[] { + const [lo, hi] = scales.domain; + return bands.map((band, i) => { + const from = Math.max(lo, Number.isFinite(band.from) ? band.from : lo); + const to = Math.min(hi, i + 1 < bands.length ? bands[i + 1].from : hi); + if (!(to > from)) return svg``; + const y1 = scales.y(to); + const y2 = scales.y(from); + return svg``; + }); +} + +/** + * Everything the card's annotations draw on a history chart: zones and bands behind, threshold + * and time lines in front, and their labels and point marks over the plot. `plotWidth` is in px, + * for spacing time labels. + */ +export function annotationLayers( + ctx: ChartContext, + frame: HistoryFrame, + scales: PlotScales, + plotWidth: number, +): AnnotationLayers { + const list = annotationsOf(ctx.raw); + const zones = ctx.raw.zones === true && frame.series[0] ? zoneRects(frame.series[0].entity.bands, scales) : []; + if (list.length === 0 && zones.length === 0) return { behind: nothing, front: nothing, overlay: nothing }; + const behind: SVGTemplateResult[] = [...zones]; + const front: SVGTemplateResult[] = []; + const labels: TemplateResult[] = []; + const { range } = frame; + const x = (t: number) => scales.x(t); + const [lo, hi] = scales.domain; + const states = stateHistory(ctx, range, frame.live); + const until = Math.min(range.end, Date.now()); + + const pending: { t: number; label: string; color: string }[] = []; + const marks = (a: Annotation, color: string, times: number[], kind: string) => { + const shown = times.slice(0, 2000); + front.push( + svg`${shown.map((t) => svg``)}`, + ); + if (a.label && shown.length <= MAX_MARK_LABELS) { + for (const t of shown) pending.push({ t, label: a.label, color }); + } + }; + + for (const a of list) { + const color = baseColor(a.color ?? DEFAULT_COLOR[a.type]); + if (a.type === 'line') { + const v = resolveValue(a.value, ctx.hass); + if (v === undefined || v < lo || v > hi) continue; + const y = scales.y(v); + front.push( + svg``, + ); + if (a.label) { + const top = y / VIEW_H; + labels.push( + html`${a.label}`, + ); + } + } else if (a.type === 'band') { + const from = a.from === undefined ? lo : resolveValue(a.from, ctx.hass); + const to = a.to === undefined ? hi : resolveValue(a.to, ctx.hass); + if (from === undefined || to === undefined) continue; + const bottom = Math.max(lo, Math.min(from, to)); + const top = Math.min(hi, Math.max(from, to)); + if (!(top > bottom)) continue; + const y1 = scales.y(top); + behind.push( + svg``, + ); + if (a.label) { + labels.push( + html`${a.label}`, + ); + } + } else if (a.type === 'time') { + marks(a, color, markTimes(timeList(a.times), range, frame.zone), 'time'); + } else if (a.type === 'state') { + const spans = stateSpans(states.get(a.entity!.trim()) ?? [], a.state, range, until); + if (a.shade) { + behind.push( + svg`${spans.map((s) => svg``)}`, + ); + } + // A stretch already going when the chart starts has no start to mark. + marks( + a, + color, + spans.map((s) => s.start).filter((t) => t > range.start), + 'state', + ); + } else if (a.type === 'point') { + const s = seriesFor(a, frame.series); + const p = s && pointFor(s, a.at ?? 'max', range, frame.group !== undefined); + if (!s || !p) continue; + const left = x(p.t) / VIEW_W; + const top = scales.y(p.v as number) / VIEW_H; + if (!(left >= 0 && left <= 1 && top >= 0 && top <= 1)) continue; + const value = withUnit(formatNumber(p.v as number, s.entity.decimals, ctx.hass), s.entity.unit); + const edge = left < 0.1 ? 'start' : left > 0.9 ? 'end' : ''; + labels.push( + html` + ${a.label ? html`${a.label} ` : nothing}${value}`, + ); + } + } + // Mark labels from every annotation share the top rows, so none lands on another. + const rows = placeMarkLabels( + pending.map((m) => ({ x: (x(m.t) / VIEW_W) * plotWidth, width: labelWidth(m.label) })), + plotWidth, + ); + pending.forEach((m, i) => { + const row = rows[i]; + if (row === undefined) return; + const left = x(m.t) / VIEW_W; + const flip = left * plotWidth + 2 + labelWidth(m.label) > plotWidth; + labels.push( + html`${m.label}`, + ); + }); + return { + behind: behind.length ? svg`${behind}` : nothing, + front: front.length ? svg`${front}` : nothing, + overlay: labels.length ? html`
${labels}
` : nothing, + }; +} + +export const annotationStyles = css` + .plot .zone { + opacity: 0.1; + } + .plot .band { + opacity: 0.14; + } + .plot .state-shade { + opacity: 0.12; + } + .plot .threshold { + stroke-width: 1.5; + } + .plot .threshold.dashed { + stroke-dasharray: 6 4; + } + .plot .mark-line { + stroke-width: 1; + stroke-dasharray: 3 3; + opacity: 0.9; + } + .annotation-labels { + position: absolute; + inset: 0; + pointer-events: none; + } + .annotation-label { + position: absolute; + font-size: 10px; + line-height: 1.2; + padding: 1px 4px; + border-radius: 3px; + white-space: nowrap; + max-width: 45%; + overflow: hidden; + text-overflow: ellipsis; + color: color-mix(in srgb, var(--cd-annotation) 75%, var(--primary-text-color)); + background: color-mix(in srgb, var(--card-background-color, #fff) 82%, transparent); + } + .annotation-label.threshold { + right: 2px; + transform: translateY(calc(-100% - 1px)); + } + .annotation-label.threshold.below { + transform: translateY(1px); + } + .annotation-label.band { + left: 2px; + transform: translateY(1px); + } + .annotation-label.mark { + transform: translateX(2px); + max-width: none; + } + .annotation-label.mark.end { + transform: translateX(calc(-100% - 2px)); + } + .annotation-dot { + position: absolute; + width: 7px; + height: 7px; + border-radius: 50%; + border: 2px solid var(--card-background-color, #fff); + transform: translate(-50%, -50%); + } + .annotation-label.point { + transform: translate(-50%, calc(-100% - 7px)); + font-weight: 500; + color: var(--primary-text-color); + border: 1px solid color-mix(in srgb, var(--cd-annotation) 60%, transparent); + background: color-mix(in srgb, var(--card-background-color, #fff) 90%, transparent); + } + .annotation-label.point .point-name { + color: var(--secondary-text-color); + font-weight: 400; + } + .annotation-label.point.below { + transform: translate(-50%, 7px); + } + .annotation-label.point.start { + transform: translate(-6px, calc(-100% - 7px)); + } + .annotation-label.point.end { + transform: translate(calc(-100% + 6px), calc(-100% - 7px)); + } + .annotation-label.point.start.below { + transform: translate(-6px, 7px); + } + .annotation-label.point.end.below { + transform: translate(calc(-100% + 6px), 7px); + } +`; diff --git a/src/chart/axes.ts b/src/chart/axes.ts index 3aba541..4cb5d6f 100644 --- a/src/chart/axes.ts +++ b/src/chart/axes.ts @@ -16,13 +16,25 @@ export interface AxisOptions { /** Fixed ends of the value axis; absent means fit the readings. */ min?: number; max?: number; + /** Even steps, or powers of ten (`y_scale: log`). */ + scale: YScale; } +export const Y_SCALES = ['linear', 'log'] as const; +export type YScale = (typeof Y_SCALES)[number]; + export const AXIS_LABELS = ['outside', 'inside'] as const; export type AxisLabels = (typeof AXIS_LABELS)[number]; /** A chart without axis keys: labels in a margin, lines across but not down. */ -export const DEFAULT_AXES: AxisOptions = { x: true, y: true, labels: 'outside', horizontal: true, vertical: false }; +export const DEFAULT_AXES: AxisOptions = { + x: true, + y: true, + labels: 'outside', + horizontal: true, + vertical: false, + scale: 'linear', +}; type AxisRaw = CardConfig & { x_axis?: unknown; @@ -31,6 +43,7 @@ type AxisRaw = CardConfig & { gridlines?: unknown; y_min?: unknown; y_max?: unknown; + y_scale?: unknown; }; const isNumber = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v); @@ -59,6 +72,15 @@ export function validateAxisOptions(config: CardConfig): void { if (isNumber(raw.y_min) && isNumber(raw.y_max) && raw.y_min >= raw.y_max) { throw new Error('"y_min" must be below "y_max"'); } + if (raw.y_scale !== undefined && !Y_SCALES.includes(raw.y_scale as YScale)) { + throw new Error(`"y_scale" must be one of: ${Y_SCALES.join(', ')}`); + } + if (raw.y_scale === 'log') { + // A log axis has no zero: its ends must be above it. + for (const k of ['y_min', 'y_max'] as const) { + if (isNumber(raw[k]) && raw[k] <= 0) throw new Error(`"${k}" must be above 0 on a log scale`); + } + } } /** The card's axis options with defaults filled in. Call after `validateAxisOptions`. */ @@ -73,6 +95,7 @@ export function axisOptions(config: CardConfig): AxisOptions { vertical: grid.vertical ?? DEFAULT_AXES.vertical, min: isNumber(raw.y_min) ? raw.y_min : undefined, max: isNumber(raw.y_max) ? raw.y_max : undefined, + scale: (raw.y_scale as YScale | undefined) ?? DEFAULT_AXES.scale, }; } @@ -97,6 +120,10 @@ export interface ValueAxis { domain: [number, number]; ticks: number[]; decimals: number; + /** Log axes: unlabelled steps between the labels, for fainter gridlines. */ + minor?: number[]; + /** Log axes: the decimals each tick needs, in step with `ticks`. */ + tickDecimals?: number[]; } /** diff --git a/src/chart/candles.ts b/src/chart/candles.ts new file mode 100644 index 0000000..9dc49ce --- /dev/null +++ b/src/chart/candles.ts @@ -0,0 +1,91 @@ +import { groupHistory, groupStatistics } from '../history/group'; +import type { Aggregate } from '../history/group'; +import type { Point, Source } from '../history/types'; + +/** + * Open, high, low and close for each period, from history (a reading holds until the next) or + * from long-term statistics rows (each an hour or five minutes, with its own mean, min and max). + */ + +export interface Candle { + /** Where the period starts and ends. */ + t: number; + end: number; + open: number; + high: number; + low: number; + close: number; +} + +/** + * Candles from history. Open is the value held as the period began (carried in from before it), + * close the last value in it, high and low every value that held in it. A period with nothing + * known, or one that starts after `until` (now), has no candle (null). + */ +export function historyCandles(points: Point[], bounds: number[], until: number): (Candle | null)[] { + const of = (aggregate: Aggregate) => groupHistory(points, bounds, aggregate, until); + const [open, high, low, close] = (['first', 'max', 'min', 'last'] as const).map(of); + return open.map((g, i) => { + if (g.v === null || high[i].v === null || low[i].v === null || close[i].v === null) return null; + return { t: g.t, end: g.end ?? g.t, open: g.v, high: high[i].v!, low: low[i].v!, close: close[i].v! }; + }); +} + +/** + * Candles from statistics rows: open is the first row's value (its mean, or its state for a + * sensor without one), close the last row's, high and low the highest max and lowest min (each + * row's value where it has none). A row moved to the range's start belongs to the period before. + */ +export function statisticsCandles(rows: Point[], bounds: number[], until: number): (Candle | null)[] { + const out: (Candle | null)[] = []; + let j = 0; + for (let i = 0; i + 1 < bounds.length; i++) { + const start = bounds[i]; + const end = bounds[i + 1]; + while (j < rows.length && rows[j].t < start) j++; + let candle: Candle | null = null; + for (let k = j; k < rows.length && rows[k].t < end; k++) { + const r = rows[k]; + if (r.v === null || r.carried || start >= until) continue; + const hi = typeof r.max === 'number' ? Math.max(r.max, r.v) : r.v; + const lo = typeof r.min === 'number' ? Math.min(r.min, r.v) : r.v; + if (!candle) candle = { t: start, end, open: r.v, high: hi, low: lo, close: r.v }; + else { + candle.high = Math.max(candle.high, hi); + candle.low = Math.min(candle.low, lo); + candle.close = r.v; + } + } + out.push(candle); + } + return out; +} + +/** + * One value per period for a series drawn under the candles (usage, a second price): grouped + * with `aggregate` as the line chart would. Statistics rows of a meter carry its reading, so its + * sum or change is how far that reading rose (resets handled), not the rows added up. + */ +export function underValues( + points: Point[], + bounds: number[], + aggregate: Aggregate, + source: Source, + until: number, +): Point[] { + if (source === 'history') return groupHistory(points, bounds, aggregate, until); + if (aggregate === 'sum' || aggregate === 'change') return groupHistory(points, bounds, 'change', until); + return groupStatistics(points, bounds, aggregate, until); +} + +/** Lowest low and highest high, or undefined with no candles. */ +export function candleExtent(candles: (Candle | null)[]): [number, number] | undefined { + let lo = Infinity; + let hi = -Infinity; + for (const c of candles) { + if (!c) continue; + lo = Math.min(lo, c.low); + hi = Math.max(hi, c.high); + } + return lo === Infinity ? undefined : [lo, hi]; +} diff --git a/src/chart/category-plot.ts b/src/chart/category-plot.ts new file mode 100644 index 0000000..b872fa1 --- /dev/null +++ b/src/chart/category-plot.ts @@ -0,0 +1,575 @@ +import { css, html, nothing, svg } from 'lit'; +import type { SVGTemplateResult, TemplateResult } from 'lit'; +import { hour12Of, niceStep, valueAxis } from './axes'; +import { chartWidth } from './grid'; +import type { XY } from './paths'; +import { linear } from './scale'; +import { choice } from '../editor/chart-editors'; +import type { FieldBinding } from '../editor/schemas'; +import { formatNumber } from '../format'; +import { registerStrings } from '../localize'; +import type { ChartContext } from '../styles/index'; +import type { CardConfig, HomeAssistant } from '../types'; + +/** + * A plot of one band per category (an entity, or an entity's day) along a value axis, across + * (horizontal: one row each, values left to right) or up (vertical: one column each, values + * bottom to top). Shared by the distribution charts (violin, raincloud, beeswarm): they draw into + * a band with `at(value, band, offset)` and never need to know which way round it is. + */ + +export const ORIENTATIONS = ['horizontal', 'vertical'] as const; +export type Orientation = (typeof ORIENTATIONS)[number]; + +/** Plot units along the value axis; each band is BAND units across. */ +export const VALUE_LEN = 1000; +export const BAND = 100; + +/** Row height (px) per category across, and how much of it a compact card keeps. */ +const ROW_PX = 64; +const COMPACT_ROW = 0.69; +/** Plot height (px) when the categories stand up, normal and compact. */ +const COLUMN_PX = 180; +const COMPACT_COLUMN_PX = 110; +/** Room a value label needs along the axis (px), when choosing how many fit. */ +const LABEL_PX = 64; + +export interface Category { + /** Index into the card's items, for its tap action. */ + index: number; + name: string; + /** A second line under the name: a median, a count. */ + detail: string; + color: string; + /** Read out for the category. */ + label: string; +} + +/** The value axis: its ends, where the labels go and how they read. */ +export interface ValueScale { + domain: [number, number]; + ticks: number[]; + format(v: number): string; +} + +export interface CategoryGeometry { + orientation: Orientation; + count: number; + /** The SVG's coordinate box. */ + width: number; + height: number; + /** Plot position of value `v` in band `i`, `f` across the band (−0.5…0.5, 0 is its middle). */ + at(v: number, i: number, f?: number): XY; + /** Roughly how many px the value axis and one band are on screen. */ + valuePx: number; + bandPx: number; +} + +/** The distribution charts' shared axis options: orientation, value labels, lines, fixed ends. */ +export interface CategoryOptions { + orientation: Orientation; + axis: boolean; + grid: boolean; + min?: number; + max?: number; +} + +const isNumber = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v); + +registerStrings('en', { + editor_orientation: 'Direction', + editor_orientation_horizontal: 'Across', + editor_orientation_vertical: 'Upright', + editor_value_axis: 'Value labels', + editor_value_grid: 'Lines at each value label', + editor_value_min: 'Lowest value shown', + editor_value_max: 'Highest value shown', + dist_median: 'Median', + dist_middle: 'Middle half', + dist_range: 'Lowest – highest', + dist_count: 'Readings', +}); + +/** The editor fields every distribution chart shares (see `categorySchema`). */ +export const CATEGORY_FIELDS: FieldBinding[] = [ + { name: 'orientation', path: ['orientation'], default: 'horizontal' }, + { name: 'value_axis', path: ['value_axis'], default: true }, + { name: 'value_grid', path: ['value_grid'], default: true }, + { name: 'value_min', path: ['value_min'] }, + { name: 'value_max', path: ['value_max'] }, + { name: 'tooltip', path: ['tooltip'], default: true }, + { name: 'compact', path: ['compact'], default: false }, +]; + +/** Direction, the value axis, the hover box and compact spacing, as ha-form schema. */ +export function categorySchema(hass: HomeAssistant | undefined): unknown[] { + return [ + { name: 'orientation', selector: choice(hass, 'orientation', ORIENTATIONS) }, + { + type: 'grid', + name: '', + schema: [ + { name: 'value_axis', selector: { boolean: {} } }, + { name: 'value_grid', selector: { boolean: {} } }, + { name: 'value_min', selector: { number: { mode: 'box', step: 'any' } } }, + { name: 'value_max', selector: { number: { mode: 'box', step: 'any' } } }, + ], + }, + { + type: 'grid', + name: '', + schema: [ + { name: 'tooltip', selector: { boolean: {} } }, + { name: 'compact', selector: { boolean: {} } }, + ], + }, + ]; +} + +/** Checks the shared options; `compact` and `tooltip` too, as these charts don't use the line chart's checks. */ +export function validateCategoryOptions(raw: CardConfig): void { + for (const k of ['compact', 'tooltip']) { + if (raw[k] !== undefined && typeof raw[k] !== 'boolean') throw new Error(`"${k}" must be true or false`); + } + if (raw.orientation !== undefined && !ORIENTATIONS.includes(raw.orientation as Orientation)) { + throw new Error(`"orientation" must be one of: ${ORIENTATIONS.join(', ')}`); + } + for (const k of ['value_axis', 'value_grid']) { + if (raw[k] !== undefined && typeof raw[k] !== 'boolean') throw new Error(`"${k}" must be true or false`); + } + for (const k of ['value_min', 'value_max']) { + if (raw[k] !== undefined && !isNumber(raw[k])) throw new Error(`"${k}" must be a number`); + } + if (isNumber(raw.value_min) && isNumber(raw.value_max) && raw.value_min >= raw.value_max) { + throw new Error('"value_min" must be below "value_max"'); + } +} + +export function categoryOptions(raw: CardConfig): CategoryOptions { + return { + orientation: (raw.orientation as Orientation | undefined) ?? 'horizontal', + axis: raw.value_axis !== false, + grid: raw.value_grid !== false, + min: isNumber(raw.value_min) ? raw.value_min : undefined, + max: isNumber(raw.value_max) ? raw.value_max : undefined, + }; +} + +/** Width of the name column across (px) for a card `width` wide: 30 %, between 64 and 140 px. */ +export function labelColumn(width: number): number { + return Math.min(140, Math.max(64, Math.round((width - 32) * 0.3))); +} + +/** Where everything goes for `count` categories on this card; `rowPx` is a row's height across. */ +export function categoryGeometry( + ctx: Pick, + count: number, + scale: Pick, + rowPx = ROW_PX, +): CategoryGeometry { + const o = categoryOptions(ctx.raw); + const compact = ctx.raw.compact === true; + const width = chartWidth(ctx); + const n = Math.max(1, count); + if (o.orientation === 'horizontal') { + const x = linear(scale.domain, [0, VALUE_LEN]); + return { + orientation: o.orientation, + count: n, + width: VALUE_LEN, + height: n * BAND, + at: (v, i, f = 0) => [x(v), (i + 0.5 + f) * BAND], + valuePx: Math.max(40, width - 32 - labelColumn(width) - 8), + // Many rows (a violin per day for a month) get thinner, so the card stays a sensible height. + bandPx: Math.min(compact ? Math.round(rowPx * COMPACT_ROW) : rowPx, n > 12 ? 28 : n > 6 ? 40 : Infinity), + }; + } + const y = linear(scale.domain, [VALUE_LEN, 0]); + const plotPx = Math.max(40, width - 32 - (o.axis ? 40 : 0)); + return { + orientation: o.orientation, + count: n, + width: n * BAND, + height: VALUE_LEN, + at: (v, i, f = 0) => [(i + 0.5 + f) * BAND, y(v)], + valuePx: compact ? COMPACT_COLUMN_PX : COLUMN_PX, + bandPx: plotPx / n, + }; +} + +/** How many value labels fit along an axis `px` long. */ +export function labelCount(px: number, orientation: Orientation): number { + return orientation === 'horizontal' ? Math.max(2, Math.min(8, Math.floor(px / LABEL_PX))) : px > 140 ? 4 : 3; +} + +/** A value axis over readings spanning `data`, labelled as the entity reads. */ +export function numberScale( + data: [number, number] | undefined, + raw: CardConfig, + count: number, + hass: HomeAssistant | undefined, +): ValueScale { + const o = categoryOptions(raw); + const axis = valueAxis(data, { min: o.min, max: o.max }, count); + return { domain: axis.domain, ticks: axis.ticks, format: (v) => formatNumber(v, axis.decimals, hass) }; +} + +/** Hours of the day, 0 to 24, labelled as clock times in the user's clock (24 or 12 hour). */ +export function clockScale(count: number, hass: HomeAssistant | undefined): ValueScale { + const step = niceHours(24 / Math.max(1, count)); + const ticks: number[] = []; + for (let h = 0; h <= 24; h += step) ticks.push(h); + let format: Intl.DateTimeFormat; + const options: Intl.DateTimeFormatOptions = { + hour: 'numeric', + minute: '2-digit', + timeZone: 'UTC', + hour12: hour12Of(hass), + }; + try { + format = new Intl.DateTimeFormat(hass?.locale?.language ?? hass?.language ?? 'en', options); + } catch { + format = new Intl.DateTimeFormat('en', options); + } + return { domain: [0, 24], ticks, format: (v) => format.format(Date.UTC(2000, 0, 1, 0, Math.round(v * 60))) }; +} + +/** 1, 2, 3, 4, 6 or 12 hours: steps that divide a day evenly. */ +function niceHours(span: number): number { + for (const h of [1, 2, 3, 4, 6, 12]) if (h >= span) return h; + return Math.max(12, niceStep(span, 1)); +} + +const pct = (n: number) => `${Math.round(n * 10000) / 100}%`; + +/** Tooltip state: which band the pointer is over, per card. */ +const hovered = new WeakMap(); + +export function hoveredBand(host: HTMLElement): number | undefined { + return hovered.get(host); +} + +export function stopCategoryHover(host: HTMLElement): void { + hovered.delete(host); +} + +/** Pointer handlers that track which band is under the pointer and redraw when it changes. */ +function hoverHandlers(ctx: Pick, geometry: CategoryGeometry) { + const set = (i: number | undefined) => { + if (hovered.get(ctx.host) === i) return; + if (i === undefined) hovered.delete(ctx.host); + else hovered.set(ctx.host, i); + ctx.requestUpdate(); + }; + return { + move(ev: PointerEvent) { + const rect = (ev.currentTarget as HTMLElement).getBoundingClientRect(); + const across = geometry.orientation === 'horizontal'; + const size = across ? rect.height : rect.width; + const at = across ? ev.clientY - rect.top : ev.clientX - rect.left; + if (!(size > 0)) return; + set(Math.min(geometry.count - 1, Math.max(0, Math.floor((at / size) * geometry.count)))); + }, + leave() { + set(undefined); + }, + }; +} + +/** A box of name–value rows beside band `i`, for the band under the pointer. */ +export function bandTooltip( + geometry: CategoryGeometry, + i: number, + title: string, + rows: [string, string][], + color: string, +): TemplateResult { + const middle = (i + 0.5) / geometry.count; + const across = geometry.orientation === 'horizontal'; + // Across, the box sits below its row (above it in the bottom half); up, beside its column. + const place = across + ? middle > 0.5 + ? `top:auto;bottom:${pct(1 - i / geometry.count)};right:0` + : `top:${pct((i + 1) / geometry.count)};right:0` + : middle > 0.5 + ? `top:0;right:${pct(1 - i / geometry.count)}` + : `top:0;left:${pct((i + 1) / geometry.count)}`; + return html``; +} + +export interface CategoryPlotOptions { + ctx: ChartContext; + geometry: CategoryGeometry; + categories: Category[]; + scale: ValueScale; + /** The drawing, in the geometry's coordinates. */ + content: SVGTemplateResult; + label: string; + /** HTML over the plot (a tooltip). */ + overlay?: TemplateResult | typeof nothing; + /** Whether to follow the pointer for a tooltip. */ + hover?: boolean; +} + +/** The plot, its value labels and gridlines, and a name (and detail) for each category. */ +export function categoryPlot({ + ctx, + geometry, + categories, + scale, + content, + label, + overlay = nothing, + hover = false, +}: CategoryPlotOptions): TemplateResult { + const o = categoryOptions(ctx.raw); + const across = geometry.orientation === 'horizontal'; + const position = (v: number) => { + const [x, y] = geometry.at(v, 0); + return across ? x / geometry.width : y / geometry.height; + }; + const ticks = scale.ticks.filter((v) => v >= scale.domain[0] && v <= scale.domain[1]); + const grid = o.grid + ? svg`${ticks.map((v) => { + const [x, y] = geometry.at(v, 0); + return across + ? svg`` + : svg``; + })}` + : nothing; + const values = + o.axis && ticks.length + ? html`
+ ${ + across + ? nothing + : html`` + } + ${ticks.map((v) => { + const at = position(v); + const edge = across ? (at < 0.04 ? 'start' : at > 0.96 ? 'end' : '') : at < 0.08 ? 'top' : ''; + return html`${scale.format(v)}`; + })} +
` + : nothing; + const handlers = hover ? hoverHandlers(ctx, geometry) : undefined; + const names = html`
+ ${categories.map( + (c) => + html`
+ ${c.name} + ${c.detail ? html`${c.detail}` : nothing} +
`, + )} +
`; + const size = across ? `height:${geometry.count * geometry.bandPx}px` : `height:${geometry.valuePx}px`; + const dense = across && geometry.bandPx < 40; + return html`
+ ${across ? names : values} +
+ + ${grid} + ${content} + + ${overlay} +
+ ${across ? values : names} +
`; +} + +export const categoryStyles = css` + .cat-plot { + display: grid; + gap: 0 8px; + } + .cat-plot.horizontal { + grid-template-columns: clamp(64px, 30%, 140px) minmax(0, 1fr); + } + .cat-plot.vertical { + grid-template-columns: auto minmax(0, 1fr); + } + .cat-plot .plot-area { + position: relative; + min-width: 0; + } + .cat-plot.horizontal .plot-area, + .cat-plot.vertical .plot-area { + grid-column: 2; + grid-row: 1; + } + .cat-plot .plot { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + overflow: visible; + } + .cat-plot .plot path, + .cat-plot .plot line, + .cat-plot .plot rect { + vector-effect: non-scaling-stroke; + } + .cat-plot .grid line { + stroke: var(--divider-color, rgba(127, 127, 127, 0.25)); + stroke-width: 1; + } + .cat-content { + opacity: 0; + transition: opacity var(--cd-duration) var(--cd-easing); + } + :host([ready]) .cat-content { + opacity: 1; + } + :host([static]) .cat-content { + transition: none; + } + .cat-labels { + display: flex; + min-width: 0; + } + .horizontal .cat-labels { + grid-column: 1; + grid-row: 1; + flex-direction: column; + } + .vertical .cat-labels { + grid-column: 2; + grid-row: 2; + padding-top: 4px; + } + .cat-label { + display: flex; + flex-direction: column; + justify-content: center; + min-width: 0; + flex: 1 1 0; + cursor: pointer; + border-radius: 4px; + } + .vertical .cat-label { + align-items: center; + text-align: center; + } + .cat-label:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: 1px; + } + .cat-name, + .cat-detail { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 100%; + } + .cat-name { + font-size: 0.875em; + } + .cat-name .swatch { + display: inline-block; + width: 8px; + height: 8px; + margin-right: 6px; + vertical-align: middle; + } + .cat-detail { + font-size: 0.75em; + color: var(--secondary-text-color); + font-variant-numeric: tabular-nums; + } + .frame.compact .cat-detail, + .cat-plot.dense .cat-detail { + display: none; + } + .cat-axis { + position: relative; + } + .horizontal .cat-axis { + grid-column: 2; + grid-row: 2; + height: 18px; + } + .horizontal .cat-axis .axis-label { + top: 5px; + transform: translateX(-50%); + } + .horizontal .cat-axis .axis-label.start { + transform: none; + } + .horizontal .cat-axis .axis-label.end { + transform: translateX(-100%); + } + .vertical .cat-axis { + grid-column: 1; + grid-row: 1; + min-width: 24px; + } + .vertical .cat-axis .axis-label { + right: 0; + transform: translateY(-50%); + } + .vertical .cat-axis .axis-label.top { + transform: none; + } + .cat-plot .axis-label { + position: absolute; + font-size: 11px; + line-height: 1; + color: var(--secondary-text-color); + white-space: nowrap; + pointer-events: none; + font-variant-numeric: tabular-nums; + } + .cat-sizer { + display: block; + height: 0; + overflow: hidden; + visibility: hidden; + font-size: 11px; + font-variant-numeric: tabular-nums; + white-space: nowrap; + } + .band-tooltip { + position: absolute; + min-width: 120px; + } + .band-tooltip .tooltip-time { + display: flex; + align-items: center; + gap: 6px; + } + .band-tooltip .swatch { + width: 8px; + height: 8px; + border-radius: 50%; + } +`; diff --git a/src/chart/climate-data.ts b/src/chart/climate-data.ts new file mode 100644 index 0000000..25db0e0 --- /dev/null +++ b/src/chart/climate-data.ts @@ -0,0 +1,185 @@ +import type { Point, SeriesRequest } from '../history/types'; + +/** The attributes a climate entity reports, and what the chart draws from each. */ +export const CLIMATE_ATTRIBUTES = { + current: 'current_temperature', + target: 'temperature', + low: 'target_temp_low', + high: 'target_temp_high', + action: 'hvac_action', +} as const; + +export type ClimatePart = keyof typeof CLIMATE_ATTRIBUTES; +const PARTS = Object.keys(CLIMATE_ATTRIBUTES) as ClimatePart[]; + +/** Everything to load for one thermostat: four numbers and the action's words, in one history call. */ +export function climateRequests(entity: string): Record { + const out = {} as Record; + for (const part of PARTS) { + out[part] = + part === 'action' + ? { entity, attribute: CLIMATE_ATTRIBUTES[part], text: true } + : { entity, attribute: CLIMATE_ATTRIBUTES[part] }; + } + return out; +} + +export type ClimateData = Record; + +/** + * One thermostat's loaded points, each carried on to `until` (the present, or the end of a past + * span): a thermostat holds its readings and its set point until they change. + */ +export function climateData( + loaded: Map, + requests: Record, + until: number, + key: (s: SeriesRequest) => string, +): ClimateData { + const out = {} as ClimateData; + for (const part of PARTS) { + const points = loaded.get(key(requests[part])) ?? []; + const last = points[points.length - 1]; + const held = part === 'action' ? last?.s !== undefined : last?.v !== null && last?.v !== undefined; + out[part] = last && held && last.t < until ? [...points, { ...last, t: until, carried: true }] : points; + } + return out; +} + +/** How a running thermostat is shaded. Idle and off aren't shaded at all. */ +export type ActionKind = 'heat' | 'cool' | 'dry' | 'fan'; +export const ACTION_KINDS: ActionKind[] = ['heat', 'cool', 'dry', 'fan']; + +const KIND_OF: Record = { + heating: 'heat', + preheating: 'heat', + cooling: 'cool', + // A heat pump defrosting runs its cycle backwards: it is taking heat out of the room. + defrosting: 'cool', + drying: 'dry', + fan: 'fan', +}; + +/** The shading for an `hvac_action`; undefined for idle, off, unknown words, and none. */ +export function actionKind(action: string | undefined): ActionKind | undefined { + return action === undefined ? undefined : KIND_OF[action]; +} + +/** A stretch of time the thermostat spent doing one thing. */ +export interface ActionSpan { + kind: ActionKind; + action: string; + start: number; + end: number; +} + +/** + * The stretches spent heating, cooling and so on, from the action's points. An action lasts + * from its point to the next point that says something else; the last one runs on to `end`. + * Only kinds in `kinds` are kept. + */ +export function actionSpans(points: Point[], end: number, kinds: readonly ActionKind[] = ACTION_KINDS): ActionSpan[] { + const out: ActionSpan[] = []; + let open: ActionSpan | undefined; + for (const p of points) { + if (open && p.s === open.action) continue; + if (open) { + open.end = p.t; + if (open.end > open.start) out.push(open); + open = undefined; + } + const kind = actionKind(p.s); + if (kind && kinds.includes(kind)) open = { kind, action: p.s as string, start: p.t, end }; + } + if (open && end > open.start) out.push({ ...open, end: Math.max(open.end, end) }); + return out; +} + +/** The point in effect at `t`: the last one at or before it (points sorted by time). */ +export function valueAt(points: readonly Point[], t: number): Point | undefined { + let lo = 0; + let hi = points.length; + while (lo < hi) { + const mid = (lo + hi) >> 1; + if (points[mid].t <= t) lo = mid + 1; + else hi = mid; + } + return lo > 0 ? points[lo - 1] : undefined; +} + +/** + * A set point as held values: unbroken runs, each value kept until the next reading (so a run + * ends where the set point went away, not at its last reading), with repeats dropped. + */ +export function holdRuns(points: readonly Point[]): { t: number; v: number }[][] { + const out: { t: number; v: number }[][] = []; + let run: { t: number; v: number }[] = []; + const close = (t: number) => { + if (!run.length) return; + const last = run[run.length - 1]; + if (t > last.t) run.push({ t, v: last.v }); + out.push(run); + run = []; + }; + for (const p of points) { + if (p.v === null) { + close(p.t); + continue; + } + const last = run[run.length - 1]; + if (last && last.v === p.v) continue; + run.push({ t: p.t, v: p.v }); + } + const end = points[points.length - 1]; + if (run.length) close(end.t); + return out; +} + +/** Both ends of a heat/cool range at each moment they were both set, as points carrying `[low, high]`. */ +export function rangePoints( + low: readonly Point[], + high: readonly Point[], +): { t: number; v: [number, number] | null }[] { + const highAt = new Map(high.map((p) => [p.t, p.v])); + return low.map((p) => { + const h = highAt.get(p.t); + return { t: p.t, v: p.v !== null && typeof h === 'number' ? [p.v, h] : null }; + }); +} + +type XY = [number, number]; +const f = (n: number) => (Math.round(n * 100) / 100).toString(); + +/** + * The area between the two ends of a heat/cool range, held like a set point: along the top + * (high) from left to right, then back along the bottom (low). One closed shape per unbroken run. + */ +export function rangeBandPath( + points: readonly { t: number; v: [number, number] | null }[], + x: (t: number) => number, + y: (v: number) => number, +): string { + let d = ''; + let run: { t: number; v: [number, number] }[] = []; + const flush = (t: number) => { + if (!run.length) return; + const last = run[run.length - 1]; + if (t > last.t) run.push({ t, v: last.v }); + if (run.length > 1) { + const top: XY[] = run.map((p) => [x(p.t), y(p.v[1])]); + const bottom: XY[] = run.map((p) => [x(p.t), y(p.v[0])]); + d += `M${f(top[0][0])},${f(top[0][1])}`; + for (let i = 1; i < top.length; i++) d += `H${f(top[i][0])}V${f(top[i][1])}`; + d += `V${f(bottom[bottom.length - 1][1])}`; + for (let i = bottom.length - 2; i >= 0; i--) d += `V${f(bottom[i][1])}H${f(bottom[i][0])}`; + d += 'Z'; + } + run = []; + }; + for (const p of points) { + if (p.v === null) flush(p.t); + else run.push({ t: p.t, v: p.v }); + } + if (run.length) flush(points[points.length - 1].t); + return d; +} diff --git a/src/chart/colors.ts b/src/chart/colors.ts new file mode 100644 index 0000000..199bc56 --- /dev/null +++ b/src/chart/colors.ts @@ -0,0 +1,19 @@ +import { baseColor } from '../bands'; +import { SERIES_COLORS } from './frame'; + +/** Series colours in order, for entities and groups without their own `color` (shared with the history charts). */ +export { SERIES_COLORS }; + +/** The n-th series colour (wrapping round), or `own` when set, ready for CSS. */ +export function seriesColor(index: number, own?: unknown): string { + return baseColor(typeof own === 'string' && own ? own : SERIES_COLORS[index % SERIES_COLORS.length]); +} + +/** + * `color` faded towards the card background: 100 is the colour itself, 0 the background. Works + * in light and dark themes alike, since it mixes with whatever the card is drawn on. + */ +export function shade(color: string, percent: number): string { + const p = Math.round(Math.min(100, Math.max(0, percent))); + return p >= 100 ? color : `color-mix(in srgb, ${color} ${p}%, var(--card-background-color, #fff))`; +} diff --git a/src/chart/compare.ts b/src/chart/compare.ts new file mode 100644 index 0000000..72595a6 --- /dev/null +++ b/src/chart/compare.ts @@ -0,0 +1,94 @@ +/** + * Maths shared by the comparison charts (grouped bars, bullet, lollipop, dumbbell, slope). Pure + * functions only, so each chart can carry this file without pulling in the others. + */ + +/** A reading at a time; `v: null` is a gap. Matches the history store's points. */ +export interface TimedValue { + t: number; + v: number | null; + min?: number | null; + max?: number | null; +} + +/** A step of 1, 2, 2.5 or 5 times a power of ten, near `span / count`. */ +export function niceStep(span: number, count = 5): number { + if (!Number.isFinite(span) || span <= 0 || count < 1) return 1; + const raw = span / count; + const power = 10 ** Math.floor(Math.log10(raw)); + const fraction = raw / power; + const nice = fraction <= 1 ? 1 : fraction <= 2 ? 2 : fraction <= 2.5 ? 2.5 : fraction <= 5 ? 5 : 10; + return nice * power; +} + +/** + * Round tick values covering `lo`..`hi`, about `count` of them, on a 1/2/2.5/5 step. The first and + * last are on or outside the data, so the axis encloses it. Equal ends get a span of 1 around them. + */ +export function niceTicks(lo: number, hi: number, count = 5): number[] { + if (!Number.isFinite(lo) || !Number.isFinite(hi)) return []; + if (lo > hi) [lo, hi] = [hi, lo]; + if (lo === hi) { + lo -= 0.5; + hi += 0.5; + } + const step = niceStep(hi - lo, count); + const first = Math.floor(lo / step + 1e-9) * step; + const last = Math.ceil(hi / step - 1e-9) * step; + const ticks: number[] = []; + for (let i = 0, v = first; v <= last + step / 2 && i < 100; i++, v = first + i * step) { + // Clear floating-point dust (0.30000000000000004) so labels format cleanly. + ticks.push(Number(v.toPrecision(12))); + } + return ticks; +} + +/** Lowest and highest of some numbers, skipping null and non-finite ones; undefined when none. */ +export function extentOf(values: (number | null | undefined)[]): [number, number] | undefined { + let lo = Infinity; + let hi = -Infinity; + for (const v of values) { + if (typeof v !== 'number' || !Number.isFinite(v)) continue; + if (v < lo) lo = v; + if (v > hi) hi = v; + } + return lo === Infinity ? undefined : [lo, hi]; +} + +/** A domain that always includes zero, for bars that grow from a zero line. */ +export function withZero([lo, hi]: [number, number]): [number, number] { + return [Math.min(0, lo), Math.max(0, hi)]; +} + +/** + * The reading in force at time `t`: the last non-gap reading at or before it. Before the first + * reading, or when a gap is in force at `t`, null. Points are oldest first. + */ +export function readingAt(points: TimedValue[], t: number): number | null { + let found: number | null = null; + for (const p of points) { + if (p.t > t) break; + found = p.v; + } + return found; +} + +/** The lowest and highest reading in some points (statistics' min and max count too). */ +export function lowHigh(points: TimedValue[]): [number, number] | undefined { + return extentOf(points.flatMap((p) => [p.v, p.min, p.max])); +} + +/** + * Change from `from` to `to` as a fraction of `from` (0.12 is +12 %). Undefined when either is + * missing or `from` is zero, where a percentage means nothing. + */ +export function relativeChange(from: number | null, to: number | null): number | undefined { + if (from === null || to === null || from === 0) return undefined; + return (to - from) / Math.abs(from); +} + +/** Which way a value moved: 'up', 'down' or 'flat' (within `epsilon`). */ +export function direction(from: number, to: number, epsilon = 1e-9): 'up' | 'down' | 'flat' { + if (Math.abs(to - from) <= epsilon) return 'flat'; + return to > from ? 'up' : 'down'; +} diff --git a/src/chart/data-labels.ts b/src/chart/data-labels.ts new file mode 100644 index 0000000..d96b6d8 --- /dev/null +++ b/src/chart/data-labels.ts @@ -0,0 +1,280 @@ +import { css, html, nothing } from 'lit'; +import type { TemplateResult } from 'lit'; +import { formatNumber } from '../format'; +import type { Point } from '../history/types'; +import type { CardConfig, HomeAssistant } from '../types'; + +/** Which readings get a value label. */ +export const LABEL_SHOW = ['all', 'last', 'minmax', 'ends'] as const; +export type LabelShow = (typeof LABEL_SHOW)[number]; + +/** Where a label sits against its point: `auto` puts peaks' labels above and dips' below. */ +export const LABEL_POSITIONS = ['auto', 'above', 'below', 'center'] as const; +export type LabelPosition = (typeof LABEL_POSITIONS)[number]; + +export interface LabelOptions { + show: LabelShow; + position: LabelPosition; + /** Leave out a label that would overlap one already placed (on unless set false). */ + hideOverlap: boolean; +} + +/** Without overlap hiding, a series with more readings than this gets no labels at all. */ +const MAX_UNHIDDEN = 40; +/** At most this many candidates per chart are tried; the rest could never fit. */ +const MAX_CANDIDATES = 600; +/** Gap between a point and its label, px. */ +const OFFSET_PX = 6; +/** Label height, px (10 px text and its padding). */ +const LABEL_H = 14; + +type LabelRaw = CardConfig & { + labels?: unknown; + labels_show?: unknown; + labels_position?: unknown; + labels_overlap?: unknown; +}; + +export function validateLabelOptions(config: CardConfig): void { + const raw = config as LabelRaw; + if (raw.labels !== undefined && typeof raw.labels !== 'boolean') throw new Error('"labels" must be true or false'); + if (raw.labels_show !== undefined && !LABEL_SHOW.includes(raw.labels_show as LabelShow)) { + throw new Error(`"labels_show" must be one of: ${LABEL_SHOW.join(', ')}`); + } + if (raw.labels_position !== undefined && !LABEL_POSITIONS.includes(raw.labels_position as LabelPosition)) { + throw new Error(`"labels_position" must be one of: ${LABEL_POSITIONS.join(', ')}`); + } + if (raw.labels_overlap !== undefined && typeof raw.labels_overlap !== 'boolean') { + throw new Error('"labels_overlap" must be true or false'); + } +} + +/** The card's label options, or undefined when it has no labels. */ +export function labelOptions(config: CardConfig): LabelOptions | undefined { + const raw = config as LabelRaw; + if (raw.labels !== true) return undefined; + return { + show: (raw.labels_show as LabelShow | undefined) ?? 'all', + position: (raw.labels_position as LabelPosition | undefined) ?? 'auto', + hideOverlap: raw.labels_overlap !== true, + }; +} + +/** + * Indexes of the readings to label, most important first: the last, then the highest and lowest, + * then the rest in time order. Gaps are never labelled. + */ +export function pickPoints(points: readonly Point[], show: LabelShow): number[] { + const real = points.map((p, i) => (p.v === null ? -1 : i)).filter((i) => i >= 0); + if (real.length === 0) return []; + const last = real[real.length - 1]; + const first = real[0]; + let hi = first; + let lo = first; + for (const i of real) { + if ((points[i].v as number) > (points[hi].v as number)) hi = i; + if ((points[i].v as number) < (points[lo].v as number)) lo = i; + } + const picked = + show === 'last' ? [last] : show === 'ends' ? [last, first] : show === 'minmax' ? [hi, lo] : [last, hi, lo, ...real]; + return [...new Set(picked)]; +} + +/** Whether a reading is a peak (label above) or a dip (label below) against its neighbours. */ +export function autoSide(points: readonly Point[], i: number): 'above' | 'below' { + const v = points[i].v as number; + const prev = points[i - 1]?.v ?? null; + const next = points[i + 1]?.v ?? null; + const around = [prev, next].filter((x): x is number => x !== null); + if (around.length === 0) return 'above'; + const mean = around.reduce((a, b) => a + b, 0) / around.length; + return v < mean ? 'below' : 'above'; +} + +/** A label to place: its anchor and size in px, and where it would rather go. */ +export interface LabelBox { + x: number; + y: number; + width: number; + /** Positions to try in order; the first that fits wins. */ + sides: LabelPosition[]; +} + +export interface Placed { + side: Exclude; + /** The label's box, px: left, top, right, bottom. */ + box: [number, number, number, number]; +} + +function boxFor(l: LabelBox, side: Exclude): [number, number, number, number] { + const left = l.x - l.width / 2; + const top = side === 'above' ? l.y - OFFSET_PX - LABEL_H : side === 'below' ? l.y + OFFSET_PX : l.y - LABEL_H / 2; + return [left, top, left + l.width, top + LABEL_H]; +} + +const overlaps = (a: number[], b: number[]) => a[0] < b[2] && b[0] < a[2] && a[1] < b[3] && b[1] < a[3]; + +/** + * Place labels in order, each on the first of its sides that overlaps none placed before it and + * stays inside `height` px. With `hide`, a label with no such side is left out (undefined); + * without, it takes its first side regardless. Labels are given most important first. + */ +export function placeLabels(labels: LabelBox[], height: number, hide: boolean): (Placed | undefined)[] { + const taken: number[][] = []; + return labels.map((l) => { + for (const s of l.sides) { + const side = s === 'auto' ? 'above' : s; + const box = boxFor(l, side); + if (!hide) { + return { side, box }; + } + if (box[1] < -2 || box[3] > height + 2) continue; + if (taken.some((t) => overlaps(t, box))) continue; + taken.push(box); + return { side, box }; + } + return undefined; + }); +} + +/** Rough width of a label in px: 10 px digits and its padding. */ +export function labelWidth(text: string): number { + return Math.ceil(text.length * 6) + 6; +} + +/** One series' readings to label, as the chart draws them. */ +export interface LabelSeries { + points: readonly Point[]; + decimals: number; + color: string; +} + +export interface LabelLayout { + /** Point position as a fraction of the plot's width and height (0 at the top). */ + at(p: Point): [number, number]; + /** Plot size in px, for spacing. */ + width: number; + height: number; + /** Leave out readings that aren't real ones (carried values at the edges). */ + skip?(p: Point): boolean; +} + +/** A label that would run off the left or right of the plot lines up with that edge instead. */ +export function edge(x: number, width: number, plotWidth: number): '' | 'start' | 'end' { + if (x - width / 2 < 0) return 'start'; + if (x + width / 2 > plotWidth) return 'end'; + return ''; +} + +const pct = (n: number) => `${Math.round(n * 10000) / 100}%`; + +/** + * Value labels for every series, chosen, positioned and kept apart as the options say. Drawn in + * HTML over the plot, in percent, so the stretched SVG doesn't distort them. + */ +export function dataLabels( + series: readonly LabelSeries[], + options: LabelOptions, + layout: LabelLayout, + hass?: HomeAssistant, +): TemplateResult | typeof nothing { + const boxes: (LabelBox & { text: string; color: string; fx: number; fy: number })[] = []; + const rest: typeof boxes = []; + for (const s of series) { + const real = layout.skip ? s.points.map((p) => (layout.skip!(p) ? { ...p, v: null } : p)) : s.points; + if (!options.hideOverlap && options.show === 'all' && real.filter((p) => p.v !== null).length > MAX_UNHIDDEN) { + continue; + } + pickPoints(real, options.show).forEach((i, rank) => { + const p = real[i]; + const [fx, fy] = layout.at(p); + if (!(fx >= 0 && fx <= 1 && fy >= 0 && fy <= 1)) return; + const text = formatNumber(p.v as number, s.decimals, hass); + const preferred: LabelPosition = options.position === 'auto' ? autoSide(real, i) : options.position; + const other: LabelPosition = preferred === 'above' ? 'below' : 'above'; + const box = { + x: fx * layout.width, + y: fy * layout.height, + width: labelWidth(text), + sides: preferred === 'center' ? ['center' as const] : [preferred, other], + text, + color: s.color, + fx, + fy, + }; + // Each series' key readings go before any series' ordinary ones. + (rank < 3 && options.show === 'all' ? boxes : options.show === 'all' ? rest : boxes).push(box); + }); + } + const all = [...boxes, ...rest].slice(0, MAX_CANDIDATES); + const placed = placeLabels(all, layout.height, options.hideOverlap); + const shown = all.map((b, i) => ({ b, p: placed[i] })).filter((x) => x.p !== undefined); + if (shown.length === 0) return nothing; + // Back in time order, so the DOM reads left to right. + shown.sort((a, b) => a.b.fx - b.b.fx); + return html`
+ ${shown.map( + ({ b, p }) => + html`${b.text}`, + )} +
`; +} + +/** + * Where a slice's label goes: on the slice's middle angle, `r` out from the centre. Angles are + * radians clockwise from 12 o'clock, as pie and donut charts draw them. Returns fractions of a + * square drawing. + */ +export function sliceAnchor(start: number, end: number, r: number): [number, number] { + const mid = (start + end) / 2; + return [0.5 + r * Math.sin(mid), 0.5 - r * Math.cos(mid)]; +} + +export const dataLabelStyles = css` + .point-labels { + position: absolute; + inset: 0; + pointer-events: none; + } + .point-label { + position: absolute; + transform: translate(-50%, calc(-100% - 6px)); + font-size: 10px; + line-height: 1; + padding: 2px 3px; + border-radius: 3px; + color: var(--primary-text-color); + background: color-mix(in srgb, var(--card-background-color, #fff) 80%, transparent); + box-shadow: inset 0 -1.5px 0 color-mix(in srgb, var(--cd-label, transparent) 70%, transparent); + white-space: nowrap; + font-variant-numeric: tabular-nums; + } + .point-label.below { + transform: translate(-50%, 6px); + } + .point-label.center { + transform: translate(-50%, -50%); + } + .point-label.start { + --cd-label-x: -4px; + } + .point-label.end { + --cd-label-x: calc(-100% + 4px); + } + .point-label.start, + .point-label.end { + transform: translate(var(--cd-label-x), calc(-100% - 6px)); + } + .point-label.start.below, + .point-label.end.below { + transform: translate(var(--cd-label-x), 6px); + } + .point-label.start.center, + .point-label.end.center { + transform: translate(var(--cd-label-x), -50%); + } +`; diff --git a/src/chart/day-clock.ts b/src/chart/day-clock.ts new file mode 100644 index 0000000..e1cd5ec --- /dev/null +++ b/src/chart/day-clock.ts @@ -0,0 +1,390 @@ +import { groupHistory } from '../history/group'; +import type { Aggregate } from '../history/group'; +import { addUnits, offsetAt, startOf } from '../history/timezone'; +import type { Zone } from '../history/timezone'; +import type { Point, Range } from '../history/types'; + +/** + * Maths for the 24-hour clock: when each entity was "active" on a day, where that falls on a + * 24-hour dial, and its numbers bucketed by the hour. Angles are radians clockwise from midnight + * at the top. Pure functions, so the drawing code stays thin. + */ + +export const TAU = Math.PI * 2; +export const DAY_MINUTES = 1440; +const MINUTE = 60_000; + +/** States that count as "active" when an entity sets no `active` list of its own. */ +export const DEFAULT_ACTIVE = [ + 'on', + 'open', + 'opening', + 'closing', + 'home', + 'playing', + 'heating', + 'cooling', + 'drying', + 'active', + 'running', + 'cleaning', + 'detected', +]; + +/** States that mean "no reading": a gap on the dial, neither active nor not. */ +const GAP_STATES = new Set(['unavailable', 'unknown']); + +export function isGap(state: string | undefined): boolean { + return state === undefined || state === '' || GAP_STATES.has(state); +} + +/** An entity's `active` option (one state or a list) as a set, or the defaults when unset. */ +export function activeSet(raw: unknown): Set { + if (typeof raw === 'string' && raw.trim() !== '') return new Set([raw.trim()]); + if (Array.isArray(raw)) { + const list = raw.filter((s): s is string => typeof s === 'string' && s.trim() !== '').map((s) => s.trim()); + if (list.length) return new Set(list); + } + return new Set(DEFAULT_ACTIVE); +} + +/** A stretch of time, ms since the epoch. `from` is set when it began before the range (carried in). */ +export interface Interval { + start: number; + end: number; + /** Began before the range: not a switch-on inside it. */ + carried?: boolean; +} + +export interface StateIntervals { + /** When the entity was in an active state. */ + active: Interval[]; + /** When its state was known at all (not unavailable, unknown or unrecorded). */ + known: Interval[]; + /** The last active state seen ("heating"), for the centre's label. */ + word?: string; +} + +function push(list: Interval[], start: number, end: number, carried: boolean): void { + const last = list[list.length - 1]; + if (last && last.end === start) last.end = end; + else list.push(carried ? { start, end, carried } : { start, end }); +} + +/** + * Text points (oldest first) as active and known intervals inside `range`. Each state lasts until + * the next point, the last one until `range.end`; repeats join. A point carried in from before the + * range (the store marks it) makes its interval `carried`, so a heater already on at midnight is + * not counted as switching on at midnight. + */ +export function stateIntervals(points: readonly Point[], range: Range, active: Set): StateIntervals { + const out: StateIntervals = { active: [], known: [] }; + for (let i = 0; i < points.length; i++) { + const p = points[i]; + const next = i + 1 < points.length ? points[i + 1].t : range.end; + const start = Math.max(range.start, p.t); + const end = Math.min(range.end, next); + if (end <= start || isGap(p.s)) continue; + const carried = p.carried === true || p.t < range.start; + push(out.known, start, end, carried); + if (active.has(p.s as string)) { + push(out.active, start, end, carried); + out.word = p.s; + } + } + return out; +} + +/** Intervals cut to `range`, empty ones left out. A cut start counts as carried in. */ +export function clip(intervals: readonly Interval[], range: Range): Interval[] { + const out: Interval[] = []; + for (const iv of intervals) { + const start = Math.max(range.start, iv.start); + const end = Math.min(range.end, iv.end); + if (end <= start) continue; + const carried = iv.carried === true || iv.start < range.start; + out.push(carried ? { start, end, carried } : { start, end }); + } + return out; +} + +export function totalMs(intervals: readonly Interval[]): number { + return intervals.reduce((n, iv) => n + iv.end - iv.start, 0); +} + +/** How many times it switched on: intervals that began inside the range. */ +export function switchOns(intervals: readonly Interval[]): number { + return intervals.filter((iv) => !iv.carried).length; +} + +/** + * Minutes on the wall clock from the day's midnight to `t`, in the zone: what the dial shows. + * On the day the clocks go forward, 01:59 is followed by 03:00, so the hour between is never + * reached; on the day they go back, 01:00–02:00 comes round twice. + */ +export function wallMinute(t: number, dayStart: number, timeZone: string): number { + const wall = t + offsetAt(t, timeZone); + const midnight = dayStart + offsetAt(dayStart, timeZone); + return (wall - midnight) / MINUTE; +} + +/** The instant (to the second) in (lo, hi] where the zone's offset changes from its value at lo. */ +function jump(lo: number, hi: number, timeZone: string): number { + const before = offsetAt(lo, timeZone); + while (hi - lo > 1000) { + const mid = Math.floor((lo + hi) / 2); + if (offsetAt(mid, timeZone) === before) lo = mid; + else hi = mid; + } + // Offsets change on a whole second, and `offsetAt` reads whole seconds: snap to it. + return Math.floor(hi / 1000) * 1000; +} + +/** + * An interval within one day as wall-clock minute spans [from, to] on the dial (0–1440). Usually + * one span; split in two when the clocks change inside it. Each span uses the offset in force + * during it, so an hour of heating across the clock change still draws as an hour. + */ +export function wallSpans(iv: Interval, dayStart: number, timeZone: string): [number, number][] { + const clamp = (m: number) => Math.min(DAY_MINUTES, Math.max(0, m)); + const a = iv.start; + const b = iv.end; + const oa = offsetAt(a, timeZone); + // The offset just before the end: the end itself may be the first instant after a change. + const ob = offsetAt(Math.max(a, b - 1000), timeZone); + const base = dayStart + offsetAt(dayStart, timeZone); + const at = (t: number, offset: number) => clamp((t + offset - base) / MINUTE); + if (oa === ob) return [[at(a, oa), at(b, oa)]]; + const j = jump(a, b - 1000, timeZone); + return [ + [at(a, oa), at(j, oa)], + [at(j, ob), at(b, ob)], + ].filter(([x, y]) => y > x) as [number, number][]; +} + +/** Wall-clock minutes as an angle on the dial: midnight at the top, clockwise. */ +export function minuteAngle(m: number): number { + return (m / DAY_MINUTES) * TAU; +} + +/** The days a clock covers: `days` whole days ending with the day `back` × `days` days ago. */ +export function clockDays(now: number, zone: Zone, days: number, back: number): number[] { + const today = startOf('day', now, zone); + const last = addUnits('day', today, -back * days, zone); + const out: number[] = []; + for (let i = days - 1; i >= -1; i--) out.push(addUnits('day', last, -i, zone)); + // Each day's start, then the end of the last one. + return out; +} + +/** One run of minutes on the average day: active on `on` of the `of` days it was known. */ +export interface AverageRun { + from: number; + to: number; + on: number; + of: number; +} + +/** Mark wall minutes (by their middle) a span covers. */ +function mark(slots: Uint8Array, [from, to]: [number, number]): void { + const first = Math.max(0, Math.ceil(from - 0.5)); + const last = Math.min(DAY_MINUTES, Math.ceil(to - 0.5)); + for (let i = first; i < last; i++) slots[i] = 1; +} + +/** + * The average day: for each wall minute, on how many of the days it was active, out of the days + * it was known (recorded and already happened). Runs of minutes with the same counts are joined. + * Minutes active on no day are left out. `bounds` are each day's start, then the last day's end. + */ +export function averageDay( + active: readonly Interval[], + known: readonly Interval[], + bounds: readonly number[], + timeZone: string, +): AverageRun[] { + const on = new Uint16Array(DAY_MINUTES); + const of = new Uint16Array(DAY_MINUTES); + for (let d = 0; d + 1 < bounds.length; d++) { + const day = { start: bounds[d], end: bounds[d + 1] }; + const a = new Uint8Array(DAY_MINUTES); + const k = new Uint8Array(DAY_MINUTES); + for (const iv of clip(known, day)) for (const s of wallSpans(iv, day.start, timeZone)) mark(k, s); + for (const iv of clip(active, day)) for (const s of wallSpans(iv, day.start, timeZone)) mark(a, s); + for (let i = 0; i < DAY_MINUTES; i++) { + of[i] += k[i]; + on[i] += a[i] & k[i]; + } + } + const runs: AverageRun[] = []; + for (let i = 0; i < DAY_MINUTES; i++) { + if (on[i] === 0) continue; + const last = runs[runs.length - 1]; + if (last && last.to === i && last.on === on[i] && last.of === of[i]) last.to = i + 1; + else runs.push({ from: i, to: i + 1, on: on[i], of: of[i] }); + } + return runs; +} + +/** Bucket lengths in minutes the bars can use. */ +export const BUCKETS = [15, 30, 60] as const; +export type Bucket = (typeof BUCKETS)[number]; + +/** + * Bucket boundaries across `range`, every `minutes` of real time from its start. Real time, not + * the wall clock, so the hour that repeats when the clocks go back is two buckets (both fold onto + * the same place on the dial, see `foldSlots`), and every bucket is the same length. + */ +export function bucketBounds(range: Range, minutes: number): number[] { + const step = minutes * MINUTE; + const out: number[] = []; + for (let t = range.start; t < range.end; t += step) out.push(t); + out.push(range.end); + return out; +} + +/** One bar on the dial: a slot of the day and its value. */ +export interface Slot { + /** Index round the dial: slot × bucket minutes is its wall-clock start. */ + index: number; + v: number; + /** Days that had a value here (1 on a single day). */ + days: number; +} + +const ADDITIVE: ReadonlySet = new Set(['sum', 'change']); + +function combine(values: number[], aggregate: Aggregate): number { + if (aggregate === 'max') return Math.max(...values); + if (aggregate === 'min') return Math.min(...values); + if (aggregate === 'first') return values[0]; + if (aggregate === 'last') return values[values.length - 1]; + const sum = values.reduce((a, b) => a + b, 0); + return ADDITIVE.has(aggregate) ? sum : sum / values.length; +} + +/** + * Numeric readings as bars round the dial, one per `bucket` minutes. Each day is grouped with the + * shared `groupHistory` (time-weighted mean, sum, change for meters…), up to `until` (now). A + * bucket lands on the dial by its wall-clock start; two buckets on one slot (the repeated hour + * when the clocks go back) are combined as their aggregate is (summed, or averaged). Over several + * days each slot is the mean of the days that had a value there. + */ +export function foldSlots( + points: readonly Point[], + bounds: readonly number[], + bucket: number, + aggregate: Aggregate, + until: number, + timeZone: string, +): Slot[] { + const perSlot = new Map(); + for (let d = 0; d + 1 < bounds.length; d++) { + const day = { start: bounds[d], end: bounds[d + 1] }; + if (day.start >= until) break; + const groups = groupHistory(points as Point[], bucketBounds(day, bucket), aggregate, until); + const today = new Map(); + for (const g of groups) { + if (g.v === null) continue; + const index = Math.floor(wallMinute(g.t, day.start, timeZone) / bucket); + if (index < 0 || index * bucket >= DAY_MINUTES) continue; + today.set(index, [...(today.get(index) ?? []), g.v]); + } + for (const [index, values] of today) { + perSlot.set(index, [...(perSlot.get(index) ?? []), combine(values, aggregate)]); + } + } + return [...perSlot.entries()] + .sort((a, b) => a[0] - b[0]) + .map(([index, values]) => ({ index, v: values.reduce((a, b) => a + b, 0) / values.length, days: values.length })); +} + +/** Whether an aggregate adds up (a total for the day makes sense) rather than averaging. */ +export function isAdditive(aggregate: Aggregate): boolean { + return ADDITIVE.has(aggregate); +} + +/** + * Bar heights 0–1. Totals (sum, change) are drawn from zero, so an empty hour is no bar; levels + * (mean, min, max) from the day's lowest to highest, with the lowest still a sliver, since a room + * at 19–21 °C would otherwise draw 24 bars of the same height. + */ +export function barHeights(slots: readonly Slot[], additive: boolean): number[] { + if (slots.length === 0) return []; + const values = slots.map((s) => s.v); + if (additive) { + const max = Math.max(...values.map(Math.abs)); + return values.map((v) => (max > 0 ? Math.abs(v) / max : 0)); + } + const lo = Math.min(...values); + const hi = Math.max(...values); + return values.map((v) => (hi > lo ? 0.15 + (0.85 * (v - lo)) / (hi - lo) : 1)); +} + +/** Ring radii, outermost first: each ring's inner and outer radius, and the hole left in the middle. */ +export interface RingLayout { + rings: { r0: number; r1: number }[]; + hole: number; +} + +/** + * Rings from `outer` inwards, state rings thinner than bar rings, with a gap between. Too many to + * fit and they all shrink alike, so the centre keeps at least `minHole`. + */ +export function ringLayout(kinds: readonly ('state' | 'numeric')[], outer = 92, minHole = 44): RingLayout { + const GAP = 4; + const want = kinds.map((k) => (k === 'state' ? 15 : 22)); + const total = want.reduce((a, b) => a + b, 0) + GAP * Math.max(0, kinds.length - 1); + const room = outer - minHole; + const scale = total > room ? room / total : 1; + const rings: { r0: number; r1: number }[] = []; + let r = outer; + for (const w of want) { + const width = w * scale; + rings.push({ r0: r - width, r1: r }); + r -= width + GAP * scale; + } + return { rings, hole: rings.length ? rings[rings.length - 1].r0 : outer }; +} + +/** "4 h 10 m", "25 m", "0 m": a length of time as the dial's centre and legend show it. */ +export function formatClockDuration(ms: number): string { + const m = Math.max(0, Math.round(ms / MINUTE)); + const h = Math.floor(m / 60); + const rest = m - h * 60; + if (h === 0) return `${rest} m`; + return rest ? `${h} h ${rest} m` : `${h} h`; +} + +/* Sector geometry, copied from the polar-area chart (feat/polar-area src/chart/polar.ts). */ + +/** The point at radius `r` and `angle` from the centre (0, 0), in SVG coordinates (y down). */ +export function polar(r: number, angle: number): [number, number] { + return [round(r * Math.sin(angle)), round(-r * Math.cos(angle))]; +} + +function round(n: number): number { + return Math.round(n * 100) / 100 || 0; +} + +/** The SVG path of a ring sector from `a0` to `a1` between radii `r0` (inner) and `r1` (outer). */ +export function sectorPath(r0: number, r1: number, a0: number, a1: number): string { + if (r1 <= 0 || a1 <= a0) return ''; + const inner = Math.max(0, r0); + if (a1 - a0 >= TAU - 1e-6) { + const ring = (r: number, sweep: 0 | 1) => { + const [x0, y0] = polar(r, 0); + const [x1, y1] = polar(r, Math.PI); + return `M${x0} ${y0}A${r} ${r} 0 1 ${sweep} ${x1} ${y1}A${r} ${r} 0 1 ${sweep} ${x0} ${y0}Z`; + }; + return inner > 0 ? `${ring(r1, 1)}${ring(inner, 0)}` : ring(r1, 1); + } + const large = a1 - a0 > Math.PI ? 1 : 0; + const [ox0, oy0] = polar(r1, a0); + const [ox1, oy1] = polar(r1, a1); + const outer = `M${ox0} ${oy0}A${r1} ${r1} 0 ${large} 1 ${ox1} ${oy1}`; + if (inner === 0) return `${outer}L0 0Z`; + const [ix1, iy1] = polar(inner, a1); + const [ix0, iy0] = polar(inner, a0); + return `${outer}L${ix1} ${iy1}A${inner} ${inner} 0 ${large} 0 ${ix0} ${iy0}Z`; +} diff --git a/src/chart/density.ts b/src/chart/density.ts new file mode 100644 index 0000000..8e7daf0 --- /dev/null +++ b/src/chart/density.ts @@ -0,0 +1,219 @@ +import type { Point } from '../history/types'; + +/** + * Distribution maths shared by the violin, raincloud and beeswarm charts: the readings in a + * series with how much each counts, weighted quantiles, the box-plot summary, and a kernel + * density estimate with its bandwidth. + */ + +/** One value in a distribution. */ +export interface Reading { + t: number; + v: number; + /** How much it counts: how long it held (ms) for history, 1 for a group or statistics row. */ + w: number; + /** A reading taken in the range, not one carried in from before it (dots show only these). */ + real: boolean; +} + +/** + * The readings in a series. History is a step function (a sensor reports when it changes), so + * each value counts for as long as it held, up to the next reading or a gap, and the value at the + * left edge counts from there: a room that sat at 20 °C all night and flickered to 21 ten times + * in a minute was at 20, not mostly at 21. Grouped values and statistics rows are already one per + * period and count once each. The point the chart adds at `until` (now) to carry the last value + * to the edge is not a reading. + */ +export function readingsOf(points: Point[], weighted: boolean, until: number): Reading[] { + const out: Reading[] = []; + for (let i = 0; i < points.length; i++) { + const p = points[i]; + if (p.v === null || !Number.isFinite(p.v)) continue; + const next = i + 1 < points.length ? points[i + 1].t : until; + if (weighted && i > 0 && i === points.length - 1 && p.t >= until) continue; + // A reading that held for no time at all (two in the same instant) still counts, barely. + const w = weighted ? Math.max(1, Math.min(next, until) - p.t) : 1; + out.push({ t: p.t, v: p.v, w, real: p.carried !== true }); + } + return out; +} + +/** Readings sorted by value. */ +export function byValue(readings: Reading[]): Reading[] { + return [...readings].sort((a, b) => a.v - b.v); +} + +/** + * The value below which `p` (0–1) of the weight lies, interpolating between readings placed at + * the middle of their own weight. With equal weights this is the usual "midpoint" quantile + * (Hazen): the median of 1, 2, 3, 4 is 2.5. `sorted` must be sorted by value. + */ +export function quantile(sorted: Reading[], p: number): number { + const n = sorted.length; + if (n === 0) return Number.NaN; + if (n === 1) return sorted[0].v; + const total = sorted.reduce((a, r) => a + r.w, 0); + const target = Math.min(1, Math.max(0, p)) * total; + let before = 0; + let prevAt = -Infinity; + let prevV = sorted[0].v; + for (const r of sorted) { + const at = before + r.w / 2; + if (target <= at) { + if (prevAt === -Infinity) return r.v; + return prevV + ((r.v - prevV) * (target - prevAt)) / (at - prevAt || 1); + } + before += r.w; + prevAt = at; + prevV = r.v; + } + return sorted[n - 1].v; +} + +/** What a box plot shows. Whiskers reach the furthest readings within 1.5 box-lengths of the box. */ +export interface Summary { + n: number; + min: number; + q1: number; + median: number; + q3: number; + max: number; + low: number; + high: number; + mean: number; +} + +export function summarize(readings: Reading[]): Summary | undefined { + if (readings.length === 0) return undefined; + const sorted = byValue(readings); + const q1 = quantile(sorted, 0.25); + const median = quantile(sorted, 0.5); + const q3 = quantile(sorted, 0.75); + const reach = 1.5 * (q3 - q1); + const inside = sorted.filter((r) => r.v >= q1 - reach && r.v <= q3 + reach); + const total = sorted.reduce((a, r) => a + r.w, 0); + return { + n: readings.length, + min: sorted[0].v, + max: sorted[sorted.length - 1].v, + q1, + median, + q3, + low: inside.length ? inside[0].v : q1, + high: inside.length ? inside[inside.length - 1].v : q3, + mean: total > 0 ? sorted.reduce((a, r) => a + r.v * r.w, 0) / total : median, + }; +} + +/** + * How wide each reading's bump is in the density estimate: Silverman's rule of thumb, + * 0.9 × min(standard deviation, IQR / 1.34) × n^(−1/5). + * + * - The smaller of the spread measures keeps a few outliers from blurring the main shape, and the + * n^(−1/5) narrows the bumps as there are more readings to show detail with. + * - Weighted readings count as Kish's effective number, (Σw)² / Σw²: a value held for twelve + * hours is not twelve hundred readings. + * - Never narrower than the sensor's own step (the smallest gap between two different values), so + * a sensor that reports in 0.5 °C steps draws one smooth shape, not a comb of spikes. + * - Readings that are all the same give a bump 1 % of the value wide (or 1 for zero). + */ +export function bandwidth(readings: Reading[]): number { + const n = readings.length; + if (n === 0) return 1; + const sorted = byValue(readings); + const total = sorted.reduce((a, r) => a + r.w, 0); + const mean = sorted.reduce((a, r) => a + r.v * r.w, 0) / total; + const sd = Math.sqrt(sorted.reduce((a, r) => a + r.w * (r.v - mean) ** 2, 0) / total); + const iqr = quantile(sorted, 0.75) - quantile(sorted, 0.25); + const spread = iqr > 0 ? Math.min(sd, iqr / 1.34) : sd; + const squares = sorted.reduce((a, r) => a + r.w * r.w, 0); + const effective = squares > 0 ? (total * total) / squares : n; + let step = Infinity; + for (let i = 1; i < sorted.length; i++) { + const gap = sorted[i].v - sorted[i - 1].v; + if (gap > 0 && gap < step) step = gap; + } + const h = 0.9 * spread * Math.max(1, effective) ** -0.2; + if (h > 0) return Math.max(h, step === Infinity ? 0 : step); + return step !== Infinity ? step : Math.abs(mean) * 0.01 || 1; +} + +/** Density at each of `xs`: the weighted sum of a Gaussian bump of width `h` at each reading. */ +export function kde(readings: Reading[], h: number, xs: number[]): number[] { + const total = readings.reduce((a, r) => a + r.w, 0); + if (total === 0 || h <= 0) return xs.map(() => 0); + const k = 1 / (total * h * Math.sqrt(2 * Math.PI)); + return xs.map((x) => { + let sum = 0; + for (const r of readings) { + const u = (x - r.v) / h; + if (u > -6 && u < 6) sum += r.w * Math.exp(-0.5 * u * u); + } + return sum * k; + }); +} + +/** + * The density's outline: `steps` + 1 points from two bandwidths below the lowest reading to two + * above the highest (where it has all but faded), kept inside `clip` (the axis). Each `d` is + * scaled so the widest point is 1. + */ +export function densityCurve( + readings: Reading[], + h: number, + clip: [number, number], + steps = 64, +): { x: number; d: number }[] { + if (readings.length === 0) return []; + let lo = Infinity; + let hi = -Infinity; + for (const r of readings) { + lo = Math.min(lo, r.v); + hi = Math.max(hi, r.v); + } + const from = Math.max(clip[0], lo - 2 * h); + const to = Math.min(clip[1], hi + 2 * h); + if (!(to > from)) return []; + const xs = Array.from({ length: steps + 1 }, (_, i) => from + ((to - from) * i) / steps); + const ds = kde(readings, h, xs); + const peak = Math.max(...ds); + return xs.map((x, i) => ({ x, d: peak > 0 ? ds[i] / peak : 0 })); +} + +/** A steady pseudo-random offset in −0.5…0.5 for item `i`, so jittered dots don't move on redraw. */ +export function jitter(i: number): number { + const s = Math.sin((i + 1) * 12.9898) * 43758.5453; + return s - Math.floor(s) - 0.5; +} + +/** At most `max` of the readings taken in the range, evenly spread through them (oldest first). */ +export function sampleReadings(readings: Reading[], max: number): Reading[] { + const real = readings.filter((r) => r.real); + if (real.length <= max) return real; + const out: Reading[] = []; + for (let i = 0; i < max; i++) out.push(real[Math.floor((i * real.length) / max)]); + return out; +} + +/** + * Readings split at `bounds` (period starts, then the last end): one list per period. A value + * that held across a boundary counts in each period for the time it held there; it is a reading + * taken in a period (`real`) only in the one it was taken in. Unweighted readings go by time. + */ +export function splitReadings(readings: Reading[], bounds: number[], weighted: boolean): Reading[][] { + const out: Reading[][] = bounds.slice(0, -1).map(() => []); + for (const r of readings) { + for (let i = 0; i + 1 < bounds.length; i++) { + const start = bounds[i]; + const end = bounds[i + 1]; + if (!weighted) { + if (r.t >= start && r.t < end) out[i].push(r); + continue; + } + const overlap = Math.min(end, r.t + r.w) - Math.max(start, r.t); + if (overlap <= 0) continue; + out[i].push({ t: Math.max(start, r.t), v: r.v, w: overlap, real: r.real && r.t >= start }); + } + } + return out; +} diff --git a/src/chart/drill.ts b/src/chart/drill.ts new file mode 100644 index 0000000..c19a00a --- /dev/null +++ b/src/chart/drill.ts @@ -0,0 +1,178 @@ +import { css, html, nothing } from 'lit'; +import type { TemplateResult } from 'lit'; +import { localize, registerStrings } from '../localize'; +import type { ChartContext } from '../styles/index'; +import type { CardConfig } from '../types'; +import { findNode } from './hierarchy'; +import type { TreeNode } from './hierarchy'; + +registerStrings('en', { + editor_drill_down: 'Tap a group to show what makes it up', + drill_into: '{label}. Show what makes it up', + drill_back: 'Back to {name}', + drill_top: 'All', +}); + +/** + * Drilling down: tapping a group in a hierarchy chart (a treemap box, a sunburst ring, an icicle + * cell) redraws the chart with that group as the whole, and a back button returns to the level + * above. Where each card is stays with the card element, so a redraw or a new state keeps it. + */ +const paths = new WeakMap(); + +/** Whether the card lets groups be tapped into (`drill_down`, on unless set to false). */ +export function drillEnabled(raw: CardConfig): boolean { + return raw.drill_down !== false; +} + +/** Checks for `drill_down`. */ +export function validateDrill(raw: CardConfig): void { + if (raw.drill_down !== undefined && typeof raw.drill_down !== 'boolean') { + throw new Error('"drill_down" must be true or false'); + } +} + +/** + * The group the card is showing and the path to it. A path that no longer leads anywhere (the + * group emptied, or the grouping changed) stops at the deepest group that still exists. + */ +export function drilledRoot(tree: TreeNode, path: string[]): { node: TreeNode; path: string[] } { + let node = tree; + const kept: string[] = []; + for (const name of path) { + const next = findNode(node, [name]); + if (!next || next.value <= 0) break; + node = next; + kept.push(name); + } + return { node, path: kept }; +} + +/** The card's current drill path (empty: the whole). */ +export function drillPath(host: HTMLElement): string[] { + return paths.get(host) ?? []; +} + +/** Show the group at `path` (empty: the whole) and redraw. */ +export function drillTo(ctx: ChartContext, path: string[]): void { + paths.set(ctx.host, path); + ctx.requestUpdate(); +} + +/** What a card draws: the group it is drilled into, or the whole tree when drilling is off. */ +export function drillView(ctx: ChartContext, tree: TreeNode): { node: TreeNode; path: string[] } { + if (!drillEnabled(ctx.raw)) return { node: tree, path: [] }; + return drilledRoot(tree, drillPath(ctx.host)); +} + +/** + * Attributes and handlers that make a group tappable: a click or Enter/Space drills into it. + * Undefined when drilling is off, so the group stays a plain shape. + */ +export function drillTarget(ctx: ChartContext, node: TreeNode, label: string) { + if (!drillEnabled(ctx.raw) || node.entity || node.value <= 0) return undefined; + const go = () => drillTo(ctx, node.path); + return { + label: localize(ctx.hass, 'drill_into', { label }), + click: (ev: Event) => { + ev.stopPropagation(); + go(); + }, + keydown: (ev: KeyboardEvent) => { + if (ev.key !== 'Enter' && ev.key !== ' ') return; + ev.preventDefault(); + go(); + }, + }; +} + +/** + * The row above a drilled-in chart: a back button named after the level above, and the group + * being shown with its total. Nothing at the top level. + */ +export function drillBar( + ctx: ChartContext, + tree: TreeNode, + view: { node: TreeNode; path: string[] }, + total: string, +): TemplateResult | typeof nothing { + if (!view.path.length) return nothing; + const up = view.path.slice(0, -1); + const parent = (up.length && findNode(tree, up)?.name) || localize(ctx.hass, 'drill_top'); + return html`
+ +
+ ${view.node.name} + ${total ? html`${total}` : nothing} +
+
`; +} + +/** Look of the back row and of tappable groups, shared by every chart that drills. */ +export const drillStyles = css` + .drill-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding-bottom: 8px; + min-width: 0; + } + .drill-back { + display: inline-flex; + align-items: center; + gap: 2px; + min-width: 0; + max-width: 55%; + padding: 4px 12px 4px 6px; + border: none; + border-radius: 16px; + background: color-mix(in srgb, var(--primary-color, #03a9f4) 12%, transparent); + color: var(--primary-color, #03a9f4); + font: inherit; + font-size: 0.875em; + font-weight: 500; + cursor: pointer; + } + .drill-back span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .drill-back:focus-visible { + outline: 2px solid var(--primary-color, #03a9f4); + outline-offset: 2px; + } + .drill-current { + display: flex; + flex-direction: column; + align-items: flex-end; + min-width: 0; + line-height: 1.2; + } + .drill-name { + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 100%; + } + .drill-total { + color: var(--secondary-text-color); + font-size: 0.875em; + } + .drillable { + cursor: pointer; + outline: none; + } +`; diff --git a/src/chart/extremes.ts b/src/chart/extremes.ts new file mode 100644 index 0000000..a7dd733 --- /dev/null +++ b/src/chart/extremes.ts @@ -0,0 +1,537 @@ +import { css, html, nothing, svg } from 'lit'; +import type { SVGTemplateResult, TemplateResult } from 'lit'; +import type { FieldBinding } from '../editor/schemas'; +import { formatNumber, withUnit } from '../format'; +import type { Grouping } from '../history/group'; +import type { Point, Range } from '../history/types'; +import { languageOf, localize, registerStrings } from '../localize'; +import type { ChartContext } from '../styles/index'; +import type { CardConfig, HomeAssistant } from '../types'; +import { formatDateTime, formatTime, timeZoneOf } from './axes'; +import { VIEW_H, VIEW_W } from './frame'; +import type { ChartSeries } from './frame'; +import type { PlotScales } from './grid'; + +/** How a chart shows each series' average: not at all, a dashed line across, or just the figure. */ +export const AVERAGE_MODES = ['none', 'line', 'figure'] as const; +export type AverageMode = (typeof AVERAGE_MODES)[number]; + +/** Which marks a chart draws. Every key has a default, so none is required in YAML. */ +export interface ExtremesOptions { + min: boolean; + max: boolean; + average: AverageMode; +} + +export const DEFAULT_EXTREMES: ExtremesOptions = { min: false, max: false, average: 'none' }; + +registerStrings('en', { + extremes_high: 'High', + extremes_low: 'Low', + extremes_average: 'Avg', + editor_extremes_max: 'Mark the highest reading', + editor_extremes_min: 'Mark the lowest reading', + editor_extremes_average: 'Average', + editor_extremes_average_none: "Don't show", + editor_extremes_average_line: 'Line across the chart', + editor_extremes_average_figure: 'Figure only', + editor_extremes: 'Mark highest, lowest and average', +}); + +type Raw = CardConfig & { extremes?: unknown }; + +/** Throw a readable error for a bad `extremes` option, on the card or an entity. */ +export function validateExtremes(config: CardConfig): void { + const raw = (config as Raw).extremes; + if (raw !== undefined) { + if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) { + throw new Error('"extremes" must have "min", "max" (true or false) and "average"'); + } + const e = raw as Record; + for (const k of ['min', 'max']) { + if (e[k] !== undefined && typeof e[k] !== 'boolean') throw new Error(`"extremes.${k}" must be true or false`); + } + if (e.average !== undefined && !AVERAGE_MODES.includes(e.average as AverageMode)) { + throw new Error(`"extremes.average" must be one of: ${AVERAGE_MODES.join(', ')}`); + } + } + for (const item of config.entities ?? []) { + if (typeof item !== 'object') continue; + const own = (item as { extremes?: unknown }).extremes; + if (own !== undefined && typeof own !== 'boolean') + throw new Error(`"extremes" of ${item.entity} must be true or false`); + } +} + +/** The card's marks with defaults filled in. Call after `validateExtremes`. */ +export function extremesOptions(config: CardConfig): ExtremesOptions { + const e = ((config as Raw).extremes ?? {}) as Partial; + return { + min: e.min ?? DEFAULT_EXTREMES.min, + max: e.max ?? DEFAULT_EXTREMES.max, + average: e.average ?? DEFAULT_EXTREMES.average, + }; +} + +/** Whether the card draws any mark at all. */ +export function extremesOn(o: ExtremesOptions): boolean { + return o.min || o.max || o.average !== 'none'; +} + +/** Whether one series gets the card's marks: on unless the entity sets `extremes: false`. */ +export function seriesHasExtremes(entity: object): boolean { + return (entity as { extremes?: unknown }).extremes !== false; +} + +/* ------------------------------------------------------------------ maths ------------------------------------------------------------------ */ + +/** + * The lowest and highest readings taken in the period, as points. Gaps don't count, nor does the + * reading carried in from before the start (it was taken earlier). On a tie the earliest wins, so + * the value carried on to the right edge never beats the reading it repeats. + */ +export function extremePoints(points: readonly Point[]): { min?: Point; max?: Point } { + let min: Point | undefined; + let max: Point | undefined; + for (const p of points) { + if (p.v === null || p.carried) continue; + if (!min || p.v < (min.v as number)) min = p; + if (!max || p.v > (max.v as number)) max = p; + } + return { min, max }; +} + +/** + * The average over the period. Raw history is irregular (a sensor reports when it changes), so + * each reading counts for as long as it held: a value that sat at 20 all night and flickered to 25 + * for a minute averages near 20, not 22.5. That includes the reading carried in at the start, and + * stops at `until` (now); unavailable stretches count for nothing. Grouped or statistics points + * are one per equal period (an hour, a day), so they get a plain mean. A single reading, or + * readings all at one instant, fall back to the plain mean too. + */ +export function averageOf(points: readonly Point[], timeWeighted: boolean, until = Infinity): number | undefined { + const values = points.filter((p) => p.v !== null && !(p.carried && !timeWeighted)); + if (values.length === 0) return undefined; + if (timeWeighted) { + let area = 0; + let span = 0; + for (let i = 0; i < points.length; i++) { + const p = points[i]; + if (p.v === null) continue; + const next = i + 1 < points.length ? points[i + 1].t : p.t; + const dt = Math.max(0, Math.min(next, until) - p.t); + area += p.v * dt; + span += dt; + } + if (span > 0) return area / span; + } + return values.reduce((sum, p) => sum + (p.v as number), 0) / values.length; +} + +/* ---------------------------------------------------------------- placing ---------------------------------------------------------------- */ + +/** One label to place: where its dot is (fractions of the plot), its text, and the side it prefers. */ +export interface LabelSpec { + x: number; + y: number; + text: string; + prefer: 'above' | 'below'; + /** It marks a dot there, which other labels keep clear of. */ + dot?: boolean; +} + +export interface PlacedLabel { + side: 'above' | 'below'; + /** Centred on the dot, or held against the plot's left or right edge near it. */ + align: 'center' | 'start' | 'end'; + /** 0 beside the dot; 1 a label's height further out, past a neighbour. */ + tier: number; +} + +/** A rectangle on the plot, in px from its top-left corner. */ +export interface Box { + l: number; + r: number; + t: number; + b: number; +} + +/** A label's rough size in px: 11 px text plus padding. Real text varies; this errs wide. */ +export const LABEL_H = 18; +const LABEL_GAP = 8; +const DOT_R = 6; +export const labelWidth = (text: string) => Math.ceil(text.length * 6.2) + 12; + +const overlaps = (a: Box, b: Box) => a.l < b.r && b.l < a.r && a.t < b.b && b.t < a.b; + +/** + * Where each label goes on a plot `width` × `height` px. Each is centred over its dot, or held + * against the edge it would run past, on its preferred side (the high above, the low below) + * unless that leaves the plot or covers a dot, an earlier label or one of `obstacles` (a header + * laid over the plot); then the other side. If neither side is clear it takes a side that fits. + */ +export function placeLabels( + labels: readonly LabelSpec[], + width: number, + height: number, + obstacles: readonly Box[] = [], +): PlacedLabel[] { + const dots: Box[] = labels + .filter((l) => l.dot) + .map((l) => ({ + l: l.x * width - DOT_R, + r: l.x * width + DOT_R, + t: l.y * height - DOT_R, + b: l.y * height + DOT_R, + })); + const taken: Box[] = [...obstacles, ...dots]; + return labels.map((label) => { + const w = labelWidth(label.text); + const px = label.x * width; + const py = label.y * height; + const align = px - w / 2 < 0 ? 'start' : px + w / 2 > width ? 'end' : 'center'; + const left = align === 'start' ? 0 : align === 'end' ? width - w : px - w / 2; + // Each side has a near spot and, on a crowded plot, one a label's height further out. + const box = (side: 'above' | 'below', tier = 0): Box => { + const gap = LABEL_GAP + tier * (LABEL_H + 2); + const t = side === 'above' ? py - gap - LABEL_H : py + gap; + return { l: left, r: left + w, t, b: t + LABEL_H }; + }; + const inside = (b: Box) => b.t >= 0 && b.b <= height; + const clear = (b: Box) => inside(b) && !taken.some((p) => overlaps(p, b)); + const other: 'above' | 'below' = label.prefer === 'above' ? 'below' : 'above'; + const spots: { side: 'above' | 'below'; tier: number }[] = [ + { side: label.prefer, tier: 0 }, + { side: other, tier: 0 }, + { side: label.prefer, tier: 1 }, + { side: other, tier: 1 }, + ]; + const spot = + spots.find((p) => clear(box(p.side, p.tier))) ?? + spots.find((p) => p.tier === 0 && inside(box(p.side))) ?? + spots[0]; + taken.push(box(spot.side, spot.tier)); + const side = spot.side; + const tier = spot.tier; + return { side, align, tier }; + }); +} + +/* ---------------------------------------------------------------- drawing ---------------------------------------------------------------- */ + +const DAY = 86_400_000; + +/** + * When a mark happened, in the user's language, clock and zone: a clock time over a day or less, + * with the date beyond. A grouped point names its group: the day for days and weeks, the month + * for months. + */ +export function extremeTime(t: number, range: Range, hass?: HomeAssistant, group?: Grouping): string { + if (group?.unit === 'day' || group?.unit === 'week' || group?.unit === 'month') { + const opts: Intl.DateTimeFormatOptions = + group.unit === 'month' + ? { month: 'short', year: 'numeric' } + : { weekday: 'short', day: 'numeric', month: 'short' }; + try { + return new Intl.DateTimeFormat(hass?.locale?.language ?? 'en', { ...opts, timeZone: timeZoneOf(hass) }).format(t); + } catch { + return new Intl.DateTimeFormat('en', opts).format(t); + } + } + return range.end - range.start > DAY ? formatDateTime(t, hass) : formatTime(t, hass); +} + +const pct = (n: number) => `${Math.round(n * 10000) / 100}%`; + +/** One series' marks, worked out for drawing. */ +interface SeriesMarks { + s: ChartSeries; + min?: Point; + max?: Point; + average?: number; +} + +/** Inputs for `extremesLayer`; the frame's `range`, `group` and `source` say how to average. */ +export interface ExtremesInput { + ctx: ChartContext; + series: readonly ChartSeries[]; + scales: PlotScales; + range: Range; + group?: Grouping; + /** Raw history (true) is averaged by time; statistics and groups by point. */ + timeWeighted: boolean; + /** The plot's size in px, for keeping labels inside it. */ + width: number; + height: number; + /** Nothing is known after this (now): the last reading holds until here. */ + until?: number; + /** Parts of the plot labels keep clear of, in px (a header laid over it). */ + obstacles?: Box[]; +} + +export interface ExtremesLayer { + /** Dashed average lines, for the plot's SVG. */ + lines: SVGTemplateResult | typeof nothing; + /** Dots and labels, for the HTML over the plot. */ + overlay: TemplateResult | typeof nothing; +} + +/** + * The marks for a history chart: a ringed dot and a label ("High 14.2 °C · 14:10") at each + * series' highest and lowest reading, and its average as a dashed line labelled at the right edge + * or as a figure in the plot's top-right corner. Any history chart can draw it: the lines go in + * its SVG, the overlay over the plot. + */ +export function extremesLayer(input: ExtremesInput): ExtremesLayer { + const { ctx, scales, width, height } = input; + const options = extremesOptions(ctx.raw); + if (!extremesOn(options)) return { lines: nothing, overlay: nothing }; + const marks: SeriesMarks[] = input.series + .filter((s) => seriesHasExtremes(s.entity.config)) + .map((s) => { + const { min, max } = extremePoints(s.points); + const average = options.average === 'none' ? undefined : averageOf(s.points, input.timeWeighted, input.until); + return { s, min: options.min ? min : undefined, max: options.max ? max : undefined, average }; + }); + const value = (v: number, s: ChartSeries) => withUnit(formatNumber(v, s.entity.decimals, ctx.hass), s.entity.unit); + const t = (k: string) => localize(ctx.hass, k); + const inPlot = (y: number) => y >= 0 && y <= 1; + + interface Mark { + s: ChartSeries; + kind: 'max' | 'min' | 'average'; + spec: LabelSpec; + } + const items: Mark[] = []; + for (const m of marks) { + for (const kind of ['max', 'min'] as const) { + const p = m[kind]; + if (!p) continue; + const y = scales.y(p.v as number) / VIEW_H; + if (!inPlot(y)) continue; + const word = t(kind === 'max' ? 'extremes_high' : 'extremes_low'); + items.push({ + s: m.s, + kind, + spec: { + x: scales.x(p.t) / VIEW_W, + y, + text: `${word} ${value(p.v as number, m.s)} · ${extremeTime(p.t, input.range, ctx.hass, input.group)}`, + prefer: kind === 'max' ? 'above' : 'below', + dot: true, + }, + }); + } + if (options.average === 'line' && m.average !== undefined) { + const y = scales.y(m.average) / VIEW_H; + if (inPlot(y)) { + items.push({ + s: m.s, + kind: 'average', + spec: { x: 1, y, text: `${t('extremes_average')} ${value(m.average, m.s)}`, prefer: 'above' }, + }); + } + } + } + const placed = placeLabels( + items.map((i) => i.spec), + width, + height, + input.obstacles, + ); + + const lines = + options.average === 'line' + ? svg`${marks.map((m) => { + if (m.average === undefined) return nothing; + const y = scales.y(m.average); + return svg``; + })}` + : nothing; + + const figures = + options.average === 'figure' && marks.some((m) => m.average !== undefined) + ? html`
+ ${marks.map((m) => + m.average === undefined + ? nothing + : html`
+ ${input.series.length > 1 ? html`` : nothing} + ${t('extremes_average')} ${value(m.average, m.s)} +
`, + )} +
` + : nothing; + + // Labels first, then the dots over them, so no label hides another series' dot. + const overlay = html`
+ ${items.map((item, i) => { + const { side, align, tier } = placed[i]; + const { x, y, text } = item.spec; + const across = align === 'start' ? 'left:0' : align === 'end' ? 'right:0' : `left:${pct(x)}`; + return html`${text}`; + })} + ${items.map((item) => + item.spec.dot + ? html`` + : nothing, + )} + ${figures} +
`; + return { lines, overlay }; +} + +/** The look of the marks, from the theme and each series' colour, light and dark. */ +export const extremesStyles = css` + .plot .average-line { + stroke-width: 1.5; + stroke-dasharray: 6 5; + opacity: 0.75; + } + .extremes-layer { + position: absolute; + inset: 0; + pointer-events: none; + opacity: 0; + transition: opacity var(--cd-duration) var(--cd-easing); + } + :host([ready]) .extremes-layer { + opacity: 1; + } + .extreme-dot { + position: absolute; + width: 8px; + height: 8px; + border-radius: 50%; + border: 2.5px solid; + background: var(--card-background-color, #fff); + transform: translate(-50%, -50%); + } + .extreme-label { + position: absolute; + height: ${LABEL_H}px; + box-sizing: border-box; + padding: 0 6px; + border-radius: 9px; + font-size: 11px; + line-height: ${LABEL_H}px; + white-space: nowrap; + font-variant-numeric: tabular-nums; + color: var(--primary-text-color); + background: color-mix(in srgb, var(--series) 16%, var(--card-background-color, #fff)); + } + .extreme-label.above { + --dy: calc(-100% - ${LABEL_GAP}px); + } + .extreme-label.below { + --dy: ${LABEL_GAP}px; + } + .extreme-label.above.far { + --dy: calc(-200% - ${LABEL_GAP + 2}px); + } + .extreme-label.below.far { + --dy: calc(100% + ${LABEL_GAP + 2}px); + } + .extreme-label { + transform: translateY(var(--dy)); + } + .extreme-label.center { + transform: translate(-50%, var(--dy)); + } + .extreme-label.average { + color: var(--secondary-text-color); + background: color-mix(in srgb, var(--card-background-color, #fff) 80%, transparent); + } + .extreme-figures { + position: absolute; + top: 2px; + right: 4px; + padding: 2px 6px; + border-radius: 6px; + font-size: 12px; + color: var(--secondary-text-color); + background: color-mix(in srgb, var(--card-background-color, #fff) 75%, transparent); + font-variant-numeric: tabular-nums; + } + .extreme-figure { + display: flex; + align-items: center; + gap: 6px; + white-space: nowrap; + } + .extreme-figure .swatch { + width: 8px; + height: 8px; + } +`; + +/* ---------------------------------------------------------------- editor ---------------------------------------------------------------- */ + +/** The card's marks in its Chart section. */ +export const EXTREMES_FIELDS: FieldBinding[] = [ + { name: 'extremes_max', path: ['extremes', 'max'], default: DEFAULT_EXTREMES.max }, + { name: 'extremes_min', path: ['extremes', 'min'], default: DEFAULT_EXTREMES.min }, + { name: 'extremes_average', path: ['extremes', 'average'], default: DEFAULT_EXTREMES.average }, +]; + +export function extremesSchema(hass?: HomeAssistant) { + return [ + { + type: 'grid', + name: '', + schema: [ + { name: 'extremes_max', selector: { boolean: {} } }, + { name: 'extremes_min', selector: { boolean: {} } }, + ], + }, + { + name: 'extremes_average', + selector: { + select: { + mode: 'dropdown', + options: AVERAGE_MODES.map((value) => ({ + value, + label: localize(hass, `editor_extremes_average_${value}`), + })), + }, + }, + }, + ]; +} + +/** Each series' own switch, shown in its options while the card draws any mark. */ +export const ENTITY_EXTREMES_FIELD: FieldBinding = { name: 'extremes', path: ['extremes'], default: true }; + +const entitySchemas = new Map(); + +/** + * A series' options with the marks switch added after `base` (built once per language and base, + * so ha-form keeps the same schema). + */ +export function withEntityExtremes(base: unknown[], hass: HomeAssistant | undefined): unknown[] { + const k = languageOf(hass); + let schema = entitySchemas.get(k); + if (!schema || schema[0] !== base[0]) { + schema = [...base, { name: 'extremes', selector: { boolean: {} } }]; + entitySchemas.set(k, schema); + } + return schema; +} + +const entityFieldLists = new WeakMap(); + +export function withEntityExtremesFields(base: FieldBinding[]): FieldBinding[] { + let fields = entityFieldLists.get(base); + if (!fields) { + fields = [...base, ENTITY_EXTREMES_FIELD]; + entityFieldLists.set(base, fields); + } + return fields; +} diff --git a/src/chart/frame.ts b/src/chart/frame.ts index d9976e7..341a145 100644 --- a/src/chart/frame.ts +++ b/src/chart/frame.ts @@ -100,6 +100,8 @@ export interface FrameOptions { * the card's. */ aggregate?: (entity: ResolvedEntity) => Aggregate | undefined; + /** Keep each state's words (`Point.s`) instead of reading numbers, for charts of states like "on" or "heat". */ + text?: boolean; } /** Load (or reuse) the card's history and pair it with its entities. */ @@ -111,7 +113,11 @@ export function historyFrame(ctx: ChartContext, now = Date.now(), options: Frame const group = parseGrouping(ctx.raw); // Grouped, the chart starts where its first group does: the last 7 days by day from midnight. 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 requests = ctx.items.map((e) => ({ + entity: e.entityId, + attribute: e.config.attribute, + ...(options.text ? { text: true } : {}), + })); const store = storeFor(ctx.host, ctx.requestUpdate); const statistics = group && source === 'statistics' diff --git a/src/chart/grid.ts b/src/chart/grid.ts index dc55737..6e1d1a6 100644 --- a/src/chart/grid.ts +++ b/src/chart/grid.ts @@ -8,6 +8,7 @@ import type { AxisOptions, TimeUnit, ValueAxis } from './axes'; import type { Hover } from './tooltip'; import { VIEW_H, VIEW_W, plot } from './frame'; import { linear } from './scale'; +import { logAxis, logScale, logTickText } from './log-scale'; /** Gaps between value labels on a full-height plot: about one per 40 px. */ const VALUE_GAPS = 4; @@ -76,12 +77,23 @@ export interface PlotAxes { label?: (t: number) => string; } -/** Work out the axes for readings spanning `data` over `range`, sized to the card. */ -export function plotAxes(ctx: ChartContext, range: Range, data: [number, number] | undefined): PlotAxes { +/** + * Work out the axes for readings spanning `data` over `range`, sized to the plot (or to `width` + * px, for a chart drawn smaller than the card) and `gaps` value labels. + */ +export function plotAxes( + ctx: ChartContext, + range: Range, + data: [number, number] | undefined, + width?: number, + gaps = VALUE_GAPS, +): PlotAxes { const options = axisOptions(ctx.raw); - const value = valueAxis(data, options, VALUE_GAPS); + // Only the line chart draws on a log scale: columns and stacked areas stand on zero. + const log = options.scale === 'log' && ctx.raw.style === 'line'; + const value = log ? logAxis(data, options, gaps) : valueAxis(data, options, gaps); // Count labels across the plot itself, not the card: the value labels take part of the width. - const count = Math.max(2, Math.min(8, Math.floor(plotWidth(ctx) / TIME_LABEL_PX))); + const count = Math.max(2, Math.min(8, Math.floor((width ?? plotWidth(ctx)) / TIME_LABEL_PX))); const { unit, ticks } = timeTicks(range, count, zoneOffset(timeZoneOf(ctx.hass))); return { options, @@ -91,7 +103,7 @@ export function plotAxes(ctx: ChartContext, range: Range, data: [number, number] range, scales: { x: linear([range.start, range.end], [0, VIEW_W]), - y: linear(value.domain, [VIEW_H, 0]), + y: (log ? logScale : linear)(value.domain, [VIEW_H, 0]), domain: value.domain, }, }; @@ -101,6 +113,7 @@ export function plotAxes(ctx: ChartContext, range: Range, data: [number, number] function gridLines(axes: PlotAxes): SVGTemplateResult { const { options, scales } = axes; return svg` + ${options.horizontal ? (axes.value.minor ?? []).map((v) => svg``) : nothing} ${options.horizontal ? axes.value.ticks.map((v) => svg``) : nothing} ${options.vertical ? axes.times.map((t) => svg``) : nothing} `; @@ -108,14 +121,22 @@ function gridLines(axes: PlotAxes): SVGTemplateResult { const pct = (n: number) => `${Math.round(n * 10000) / 100}%`; +/** The text of the i-th value label: log axes give each tick its own decimals and shorten big ones. */ +function tickText(value: ValueAxis, i: number, ctx: ChartContext): string { + const v = value.ticks[i]; + return value.tickDecimals + ? logTickText(v, value.tickDecimals[i], ctx.hass) + : formatNumber(v, value.decimals, ctx.hass); +} + function valueLabels(axes: PlotAxes, ctx: ChartContext, format?: (v: number) => string) { const { scales, value } = axes; - return value.ticks.map((v) => { + return value.ticks.map((v, i) => { 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`${format ? format(v) : formatNumber(v, value.decimals, ctx.hass)}${format ? format(v) : tickText(value, i, ctx)}`; }); } @@ -166,6 +187,23 @@ export interface ChartAreaOptions { /** 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; + /** Fuller pointer handling (zoom and pan), wrapping the tooltip's; used instead of `hover`. */ + gestures?: Gestures; + /** Extra classes on the plot area, for a feature's own look (`zoomable`, `zoomed`). */ + classes?: string; + /** Under the plot and its time labels, lined up with the plot (an overview strip). */ + below?: TemplateResult | typeof nothing; +} + +/** Every pointer handler the plot area can take. */ +export interface Gestures { + down(ev: PointerEvent): void; + move(ev: PointerEvent): void; + up(ev: PointerEvent): void; + cancel(ev: PointerEvent): void; + leave(ev: PointerEvent): void; + wheel(ev: WheelEvent): void; + dblclick(ev: MouseEvent): void; } /** @@ -184,6 +222,9 @@ export function chartArea({ right, xLabel, yLabel, + gestures, + classes = '', + below = nothing, }: ChartAreaOptions) { const { options } = axes; const outside = options.labels === 'outside'; @@ -195,7 +236,7 @@ export function chartArea({ const sizer = y ? html`` : nothing; @@ -210,21 +251,23 @@ export function chartArea({ const rLabels = r ? html`
${outside ? rSizer : nothing}${rightLabels(r, ctx)}
` : nothing; - return html`
+ const wrap = `plot-wrap axes-${options.labels} ${y ? 'has-y' : ''} ${x ? 'has-x' : ''} ${r ? 'has-right' : ''} ${below === nothing ? '' : 'has-overview'}`; + return html`
${outside && y ? html`
${sizer}${yTicks}
` : nothing}
${plot(content, label, defs, gridLines(axes))} ${outside ? nothing : html`${yLabels}${rLabels}${xLabels}`} ${overlay}
- ${outside ? html`${rLabels}${xLabels}` : nothing} + ${outside ? html`${rLabels}${xLabels}` : nothing} ${below}
`; } @@ -245,6 +288,9 @@ export const gridStyles = css` stroke: var(--divider-color, rgba(127, 127, 127, 0.25)); stroke-width: 1; } + .plot .grid line.minor { + opacity: 0.45; + } .axis-label { position: absolute; font-size: 11px; diff --git a/src/chart/hierarchy.ts b/src/chart/hierarchy.ts new file mode 100644 index 0000000..19ddf37 --- /dev/null +++ b/src/chart/hierarchy.ts @@ -0,0 +1,186 @@ +import type { ResolvedEntity } from '../resolve'; +import type { CardConfig, HomeAssistant } from '../types'; + +/** What `group_by` can read from Home Assistant's registries, outermost first. */ +export const GROUP_LEVELS = ['floor', 'area', 'device', 'label'] as const; +export type GroupLevel = (typeof GROUP_LEVELS)[number]; + +/** `group_by` from YAML: one level or a list of them. Kept in house order (floor before area…). */ +export function parseGroupBy(raw: unknown): GroupLevel[] { + if (raw === undefined || raw === null || raw === '') return []; + const list = Array.isArray(raw) ? raw : [raw]; + if (list.some((l) => !GROUP_LEVELS.includes(l as GroupLevel))) { + throw new Error(`"group_by" must be one or more of: ${GROUP_LEVELS.join(', ')}`); + } + return GROUP_LEVELS.filter((l) => list.includes(l)); +} + +/** Checks for `group_by` and each entity's own `group`. */ +export function validateGroups(raw: CardConfig): void { + parseGroupBy(raw.group_by); + (raw.entities ?? []).forEach((e, i) => { + const group = typeof e === 'object' && e ? (e as { group?: unknown }).group : undefined; + if (group !== undefined && group !== null && typeof group !== 'string') { + throw new Error(`Entity ${i + 1}: "group" must be text, such as "Kitchen" or "Ground floor / Kitchen"`); + } + }); +} + +/** An entity's own `group`, split at each "/" into levels: "Ground floor / Kitchen". */ +export function splitGroup(text: unknown): string[] { + if (typeof text !== 'string') return []; + return text + .split('/') + .map((s) => s.trim()) + .filter(Boolean); +} + +/** The registry fields grouping reads, which the card's own HomeAssistant type leaves out. */ +interface Registries { + entities?: Record; + devices?: Record< + string, + { name?: string | null; name_by_user?: string | null; area_id?: string | null; labels?: string[] } + >; + areas?: Record; + floors?: Record; + labels?: Record; +} + +/** "living_room" → "Living room": a readable stand-in when only an id is known. */ +function fromId(id: string): string { + const text = id.replace(/_/g, ' ').trim(); + return text.charAt(0).toUpperCase() + text.slice(1); +} + +/** The name of `entityId`'s floor, area, device or first label, from the registries. */ +export function levelName(entityId: string, level: GroupLevel, hass: HomeAssistant): string | undefined { + const reg = hass as unknown as Registries; + const entry = reg.entities?.[entityId]; + const device = entry?.device_id ? reg.devices?.[entry.device_id] : undefined; + const areaId = entry?.area_id ?? device?.area_id ?? undefined; + switch (level) { + case 'area': + return areaId ? (reg.areas?.[areaId]?.name ?? fromId(areaId)) : undefined; + case 'floor': { + const floorId = areaId ? reg.areas?.[areaId]?.floor_id : undefined; + return floorId ? (reg.floors?.[floorId]?.name ?? fromId(floorId)) : undefined; + } + case 'device': + return device ? (device.name_by_user ?? device.name ?? undefined) : undefined; + case 'label': { + const label = [...(entry?.labels ?? []), ...(device?.labels ?? [])][0]; + return label ? (reg.labels?.[label]?.name ?? fromId(label)) : undefined; + } + } +} + +/** + * Which groups an entity sits in, outermost first. Its own `group` wins; otherwise each + * `group_by` level is read from the registries, and a level it has none of is `other`. + */ +export function groupPath(e: ResolvedEntity, levels: GroupLevel[], hass: HomeAssistant, other: string): string[] { + const own = splitGroup((e.config as { group?: unknown }).group); + if (own.length) return own; + return levels.map((level) => levelName(e.entityId, level, hass) ?? other); +} + +export interface TreeNode { + name: string; + /** Group names from the top down to this node; empty for the root. A leaf has its group's. */ + path: string[]; + /** The sum of the positive values below (a leaf: its own value, or 0 without one). */ + value: number; + depth: number; + children: TreeNode[]; + /** Leaves only: the entity and its index into the chart's items. */ + entity?: ResolvedEntity; + index?: number; +} + +/** A leaf's size: its value when that is a positive number, else nothing. */ +export function leafValue(e: ResolvedEntity): number { + return e.available && e.value !== null && e.value > 0 ? e.value : 0; +} + +/** + * The entities as a tree: a root holding the groups, each holding its subgroups and then its + * entities. Every level is ordered largest first; equal values keep the order they came in. + */ +export function buildTree(items: ResolvedEntity[], paths: string[][], rootName: string): TreeNode { + const root: TreeNode = { name: rootName, path: [], value: 0, depth: 0, children: [] }; + items.forEach((entity, index) => { + let node = root; + for (const name of paths[index] ?? []) { + let child = node.children.find((c) => c.entity === undefined && c.name === name); + if (!child) { + child = { name, path: [...node.path, name], value: 0, depth: node.depth + 1, children: [] }; + node.children.push(child); + } + node = child; + } + node.children.push({ + name: entity.name, + path: node.path, + value: 0, + depth: node.depth + 1, + children: [], + entity, + index, + }); + }); + const total = (node: TreeNode): number => { + node.value = node.entity ? leafValue(node.entity) : node.children.reduce((sum, c) => sum + total(c), 0); + const order = new Map(node.children.map((c, i) => [c, i])); + node.children.sort((a, b) => b.value - a.value || order.get(a)! - order.get(b)!); + return node.value; + }; + total(root); + return root; +} + +/** The group at `path` below `root`, or undefined when there is no such group (any more). */ +export function findNode(root: TreeNode, path: string[]): TreeNode | undefined { + let node: TreeNode | undefined = root; + for (const name of path) { + node = node.children.find((c) => c.entity === undefined && c.name === name); + if (!node) return undefined; + } + return node; +} + +/** How many levels lie below `node` (0 for a leaf). */ +export function treeHeight(node: TreeNode): number { + return node.children.length ? 1 + Math.max(...node.children.map(treeHeight)) : 0; +} + +export interface Span { + node: TreeNode; + /** Levels below the partition's root: 0 is the root itself. */ + level: number; + /** Where the node starts and ends, as fractions of the root's value (0–1). */ + x0: number; + x1: number; +} + +/** + * Every node under `root` with the share of the whole it covers, for sunbursts and icicles: + * each child gets a slice of its parent's span in proportion to its value. Nodes with nothing to + * show (a zero value) are left out. + */ +export function partition(root: TreeNode): Span[] { + const out: Span[] = []; + const walk = (node: TreeNode, level: number, x0: number, x1: number) => { + out.push({ node, level, x0, x1 }); + if (node.value <= 0) return; + let x = x0; + for (const child of node.children) { + if (child.value <= 0) continue; + const w = ((x1 - x0) * child.value) / node.value; + walk(child, level + 1, x, x + w); + x += w; + } + }; + walk(root, 0, 0, 1); + return out; +} diff --git a/src/chart/log-scale.ts b/src/chart/log-scale.ts new file mode 100644 index 0000000..6d677e3 --- /dev/null +++ b/src/chart/log-scale.ts @@ -0,0 +1,140 @@ +import { formatNumber } from '../format'; +import type { Point } from '../history/types'; +import type { HomeAssistant } from '../types'; +import type { ValueAxis } from './axes'; + +/** + * A logarithmic value axis. Only readings above zero have a place on it: zero and negatives are + * left out of the drawing (a gap in the line), though the tooltip still reads them out. + */ + +/** Which steps inside each power of ten carry a label, finest first. */ +const MANTISSAS = [[1, 2, 3, 4, 5, 6, 7, 8, 9], [1, 2, 5], [1]]; +/** With more decades than labels, label every n-th power of ten. */ +const DECADE_STEPS = [2, 3, 5, 10, 20, 50, 100]; + +/** Clean up floating-point fuzz (2 × 0.1 → 0.2, 10 ** -3 → 0.001). */ +const tidy = (v: number) => Number(v.toPrecision(12)); +const pow = (e: number) => tidy(10 ** e); +const decadeOf = (v: number) => Math.floor(Math.log10(v) + 1e-9); + +/** Readings at or below zero as gaps (min and max too), for drawing on a log axis. */ +export function positivePoints(points: Point[]): Point[] { + return points.map((p) => { + const bad = (v: number | null | undefined) => typeof v === 'number' && v <= 0; + if (!bad(p.v) && !bad(p.min) && !bad(p.max)) return p; + return { + ...p, + v: bad(p.v) ? null : p.v, + ...(p.min !== undefined ? { min: bad(p.min) ? null : p.min } : {}), + ...(p.max !== undefined ? { max: bad(p.max) ? null : p.max } : {}), + }; + }); +} + +/** The values `m × 10ⁿ` between lo and hi: m from `mantissas`, n every `every` decades counting from lo's. */ +function steps(lo: number, hi: number, mantissas: number[], every = 1): number[] { + const out: number[] = []; + const first = decadeOf(lo); + for (let e = first; e <= decadeOf(hi) + every; e += every) { + for (const m of mantissas) { + const v = tidy(m * 10 ** e); + if (v >= lo * (1 - 1e-9) && v <= hi * (1 + 1e-9)) out.push(v); + } + } + return out; +} + +/** The largest step at or below `v` (`down`), or the smallest at or above it. */ +function snap(v: number, mantissas: number[], down: boolean): number { + const e = decadeOf(v); + const around = steps(pow(e - 2), pow(e + 2), mantissas); + const fit = down ? around.filter((s) => s <= v * (1 + 1e-9)) : around.filter((s) => s >= v * (1 - 1e-9)); + return down ? fit[fit.length - 1] : fit[0]; +} + +export interface LogAxis extends ValueAxis { + /** Unlabelled steps between the labels, for fainter gridlines. */ + minor: number[]; + /** Decimals each tick needs (0.01 → 2, 20 → 0), in step with `ticks`. */ + tickDecimals: number[]; +} + +/** + * The log axis for positive readings spanning `data`. Ends that aren't fixed move out to the + * nearest labelled step. Labels go at every 1–9, else 1, 2 and 5, else each power of ten, else + * every few powers of ten: the finest that gives at most `count + 1` labels. A fixed end at or + * below zero, or no readings, falls back to sensible room (the caller validates fixed ends). + */ +export function logAxis( + data: [number, number] | undefined, + fixed: { min?: number; max?: number }, + count: number, +): LogAxis { + let [lo, hi] = data ?? [1, 100]; + if (fixed.min !== undefined && fixed.min > 0) lo = fixed.min; + if (fixed.max !== undefined && fixed.max > 0) hi = fixed.max; + if (!(hi > 0)) hi = lo > 0 ? lo * 10 : 100; + if (!(lo > 0)) lo = hi / 1000; + const fixMin = fixed.min !== undefined && fixed.min === lo; + const fixMax = fixed.max !== undefined && fixed.max === hi; + if (hi <= lo) { + // Flat readings, or all past a fixed end: a power of ten's room. + if (fixMin) hi = lo * 10; + else if (fixMax) lo = hi / 10; + else { + lo = lo / Math.sqrt(10); + hi = hi * Math.sqrt(10); + } + } + const budget = Math.max(2, count + 1); + const choices: { mantissas: number[]; every: number }[] = [ + ...MANTISSAS.map((mantissas) => ({ mantissas, every: 1 })), + ...DECADE_STEPS.map((every) => ({ mantissas: [1], every })), + ]; + let chosen = choices[choices.length - 1]; + let domain: [number, number] = [lo, hi]; + let ticks: number[] = []; + for (const choice of choices) { + // Ends go to the next labelled step; when labels skip powers of ten, the next power of ten. + const a = fixMin ? lo : snap(lo, choice.mantissas, true); + const b = fixMax ? hi : snap(hi, choice.mantissas, false); + const t = steps(a, b, choice.mantissas, choice.every); + chosen = choice; + domain = [a, b]; + ticks = t; + if (t.length <= budget) break; + } + const minorSet = chosen.every > 1 ? [1] : MANTISSAS[0]; + const minor = + chosen.mantissas.length === 9 + ? [] + : steps(domain[0], domain[1], minorSet) + .filter((v) => !ticks.includes(v)) + .slice(0, 200); + const tickDecimals = ticks.map((v) => Math.max(0, -decadeOf(v))); + return { domain, ticks, decimals: Math.max(0, ...tickDecimals), minor, tickDecimals }; +} + +/** Map positive values onto `range` by their logarithm; zero and below have no place (NaN). */ +export function logScale(domain: [number, number], range: [number, number]): (v: number) => number { + const l0 = Math.log10(domain[0]); + const l1 = Math.log10(domain[1]); + const [r0, r1] = range; + const k = l1 === l0 ? 0 : (r1 - r0) / (l1 - l0); + return (v) => (v > 0 ? r0 + (Math.log10(v) - l0) * k : NaN); +} + +/** Above this, a log tick reads short: 10k, 1M. */ +const COMPACT_FROM = 10_000; + +/** A log axis label: as many decimals as the step needs, and big numbers in short form. */ +export function logTickText(v: number, decimals: number, hass?: HomeAssistant): string { + if (Math.abs(v) < COMPACT_FROM) return formatNumber(v, decimals, hass); + const language = hass?.locale?.language ?? hass?.language ?? 'en'; + try { + return new Intl.NumberFormat(language, { notation: 'compact', maximumFractionDigits: 1 }).format(v); + } catch { + return new Intl.NumberFormat('en', { notation: 'compact', maximumFractionDigits: 1 }).format(v); + } +} diff --git a/src/chart/marks.ts b/src/chart/marks.ts new file mode 100644 index 0000000..c40b687 --- /dev/null +++ b/src/chart/marks.ts @@ -0,0 +1,116 @@ +import { css, html, nothing } from 'lit'; +import type { TemplateResult } from 'lit'; +import { baseColor } from '../bands'; +import { formatNumber, withUnit } from '../format'; +import { isInteractive } from '../actions'; +import { localize } from '../localize'; +import type { ResolvedEntity } from '../resolve'; +import { position } from '../scale'; +import type { HomeAssistant } from '../types'; + +/** One qualitative range behind a bullet bar, in track fractions (0..1). */ +export interface RangeSegment { + from: number; + to: number; + color: string; + /** 0 for the lowest band, counting up. */ + level: number; +} + +/** + * The entity's colour bands cut to its min..max, as track fractions. Bands wholly outside the + * limits are left out; the first visible one starts at the track's left edge. + */ +export function rangeSegments(e: Pick): RangeSegment[] { + const out: RangeSegment[] = []; + e.bands.forEach((band, i) => { + const next = e.bands[i + 1]; + const from = Number.isFinite(band.from) ? position(band.from, e.min, e.max) : 0; + const to = next ? position(next.from, e.min, e.max) : 1; + if (to <= from) return; + out.push({ from: out.length ? from : 0, to, color: band.color, level: 0 }); + }); + out.forEach((s, i) => (s.level = i)); + return out; +} + +/** + * The background of one range: a grey tint that deepens band by band ('shades'), or the band's + * own colour, faint ('colours'). + */ +export function rangeFill(segment: RangeSegment, count: number, mode: 'shades' | 'colours'): string { + if (mode === 'colours') return `color-mix(in srgb, ${baseColor(segment.color)} 22%, transparent)`; + const strength = count <= 1 ? 8 : 6 + Math.round((segment.level / (count - 1)) * 12); + return `color-mix(in srgb, var(--primary-text-color, #212121) ${strength}%, transparent)`; +} + +/** The drawing variables every entity's mark reads; the card forces them to 0 until it is ready. */ +export function markVars(e: ResolvedEntity): string { + const color = baseColor(e.band.color); + return ( + `--start:${e.span[0]};--end:${e.span[1]};--pos:${e.position};` + + `--target:${e.targetPosition ?? 0};--c-start:${color}` + ); +} + +/** "target 18 kWh", or nothing when the entity has no target. */ +export function targetText(e: ResolvedEntity, hass: HomeAssistant): string | undefined { + if (e.target === null) return undefined; + return localize(hass, 'compare_target', { target: withUnit(formatNumber(e.target, e.decimals, hass), e.unit) }); +} + +/** + * The element for one entity: `.entity` with its `data-index`, a meter role and the drawing + * variables, so tap, hold and double-tap actions and screen readers work as in the value styles. + */ +export function entityMark(e: ResolvedEntity, index: number, inner: TemplateResult, extra = ''): TemplateResult { + const interactive = isInteractive(e.config); + return html`
+ ${inner} +
`; +} + +/** Look shared by the bullet and lollipop charts. */ +export const markStyles = css` + .compare .entity.interactive { + cursor: pointer; + } + .compare .entity:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: 2px; + border-radius: 4px; + } + .compare .name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + } + .compare .value { + font-weight: 500; + white-space: nowrap; + } + .compare .entity.unavailable .value { + color: var(--secondary-text-color); + font-weight: 400; + font-size: 0.875em; + white-space: normal; + } + .compare .sub { + color: var(--secondary-text-color); + font-size: 0.8em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } +`; diff --git a/src/chart/multiples.ts b/src/chart/multiples.ts new file mode 100644 index 0000000..611fd93 --- /dev/null +++ b/src/chart/multiples.ts @@ -0,0 +1,174 @@ +import { css, html, nothing } from 'lit'; +import type { TemplateResult } from 'lit'; +import { formatNumber, withUnit } from '../format'; +import { localize } from '../localize'; +import type { ChartContext } from '../styles/index'; +import type { CardConfig } from '../types'; +import type { ChartSeries, HistoryFrame } from './frame'; +import { chartWidth, plotAxes } from './grid'; +import type { PlotAxes } from './grid'; +import { extent } from './scale'; +import { nearestIndex } from './tooltip'; + +/** How small multiples share their value axis: one scale for all, or each its own. */ +export const MULTIPLES_SCALES = ['auto', 'shared', 'independent'] as const; +export type MultiplesScale = (typeof MULTIPLES_SCALES)[number]; + +export const MAX_MULTIPLES_COLUMNS = 6; +/** Narrowest a small chart gets when the card picks the number of columns, px. */ +export const MIN_PANEL_PX = 150; +/** Room between small charts, px (matches the grid's gap). */ +const GAP_PX = 12; +/** Fewer value labels on a small chart. */ +const PANEL_GAPS = 2; + +export interface MultiplesOptions { + scale: MultiplesScale; + /** Columns in the grid; undefined lets the card fit as many as are at least MIN_PANEL_PX. */ + columns?: number; +} + +export function validateMultiples(raw: CardConfig): void { + if (raw.multiples !== undefined && typeof raw.multiples !== 'boolean') { + throw new Error('"multiples" must be true or false'); + } + if (raw.multiples_scale !== undefined && !MULTIPLES_SCALES.includes(raw.multiples_scale as MultiplesScale)) { + throw new Error(`"multiples_scale" must be one of: ${MULTIPLES_SCALES.join(', ')}`); + } + const c = raw.multiples_columns; + if (c !== undefined && !(Number.isInteger(c) && (c as number) >= 1 && (c as number) <= MAX_MULTIPLES_COLUMNS)) { + throw new Error(`"multiples_columns" must be a whole number from 1 to ${MAX_MULTIPLES_COLUMNS}`); + } +} + +/** The card's small-multiples options, or undefined when it draws one chart. */ +export function multiplesOptions(raw: CardConfig): MultiplesOptions | undefined { + if (raw.multiples !== true) return undefined; + return { + scale: (raw.multiples_scale as MultiplesScale | undefined) ?? 'auto', + columns: raw.multiples_columns as number | undefined, + }; +} + +/** A set number of columns drops to fewer before a chart gets narrower than this, px. */ +export const MIN_SET_PANEL_PX = 110; + +/** + * Columns the grid will have for `count` charts on a card `width` px wide: as many as fit at + * MIN_PANEL_PX, or the number asked for while each stays at least MIN_SET_PANEL_PX. + */ +export function multiplesColumns(count: number, width: number, columns?: number): number { + const fits = (px: number) => Math.max(1, Math.floor((width + GAP_PX) / (px + GAP_PX))); + const cols = columns === undefined ? fits(MIN_PANEL_PX) : Math.min(columns, fits(MIN_SET_PANEL_PX)); + return Math.max(1, Math.min(count, cols)); +} + +/** Width of one small chart, px, for `cols` columns on a card `width` px wide. */ +export function panelWidth(width: number, cols: number): number { + return Math.max(0, (width - (cols - 1) * GAP_PX) / cols); +} + +/** A small chart's title value: its reading at the guide while one is shown, else its current value. */ +function titleValue(s: ChartSeries, t: number | undefined, ctx: ChartContext): string { + if (t === undefined) return s.entity.display; + const i = nearestIndex(s.points, t); + const p = i < 0 ? undefined : s.points[i]; + if (!p || p.v === null) return '—'; + return withUnit(formatNumber(p.v, s.entity.decimals, ctx.hass), s.entity.unit); +} + +/** + * One small chart per series in a grid, sharing the time axis and (with `scale: shared`) the + * value axis, so many sensors compare at a glance. `draw` draws one series into its own plot, + * with its axes and its width in px. Each chart's title is the series' name and value, which + * opens it like the header does; while the pointer is over any chart, every title shows its + * reading at that moment. + */ +export function multiplesGrid( + ctx: ChartContext, + frame: HistoryFrame, + options: MultiplesOptions, + hover: number | undefined, + draw: (series: ChartSeries, axes: PlotAxes, width: number) => TemplateResult, +): TemplateResult { + const width = Math.max(0, chartWidth(ctx) - 32); + const cols = multiplesColumns(frame.series.length, width, options.columns); + const panel = panelWidth(width, cols); + // Automatic: one scale when every entity reads in the same unit; a scale each when they don't + // (temperatures on a power scale would be flat lines). + const units = new Set(frame.series.map((s) => s.entity.unit).filter((u) => u !== '')); + const share = options.scale === 'shared' || (options.scale === 'auto' && units.size <= 1); + const shared = share ? extent(frame.series.map((s) => s.points)) : undefined; + const { show } = ctx.config; + const grid = `grid-template-columns:repeat(${cols}, minmax(0, 1fr))`; + return html`
+ ${frame.series.map((s) => { + const axes = plotAxes(ctx, frame.range, shared ?? extent([s.points]), panel, PANEL_GAPS); + const title = + show.name || show.value + ? html`
+ + ${show.name ? html`${s.entity.name}` : nothing} + ${show.value ? html`${titleValue(s, hover, ctx)}` : nothing} +
` + : nothing; + // An entity with no readings in the span (unavailable, missing) says so in its own place. + const body = s.points.some((p) => p.v !== null) + ? draw(s, axes, panel) + : html`
${localize(ctx.hass, 'chart_no_data')}
`; + return html`
${title}${body}
`; + })} +
`; +} + +export const multiplesStyles = css` + .multiples { + display: grid; + gap: 8px ${GAP_PX}px; + } + .panel { + min-width: 0; + } + .panel-title { + display: flex; + align-items: baseline; + gap: 6px; + min-width: 0; + padding-bottom: 2px; + cursor: pointer; + border-radius: 4px; + } + .panel-title:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: 2px; + } + .panel-title .series-name { + flex: 0 1 auto; + } + .panel-title .series-value { + margin-left: auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + font-size: 0.9375em; + font-variant-numeric: tabular-nums; + } + .multiples .plot-wrap, + .multiples .chart-message { + height: 100px; + } + .frame.compact .multiples .plot-wrap, + .frame.compact .multiples .chart-message { + height: 70px; + } + .multiples .chart-message { + font-size: 0.8125em; + } + .frame.compact .multiples { + gap: 4px 10px; + } + /* The guide shows on every small chart; the box only on the one being pointed at. */ + .panel:not(:hover) .tooltip { + display: none; + } +`; diff --git a/src/chart/polar.ts b/src/chart/polar.ts new file mode 100644 index 0000000..bd3e91a --- /dev/null +++ b/src/chart/polar.ts @@ -0,0 +1,58 @@ +/** Angles are in radians, measured clockwise from 12 o'clock, as a compass reads. */ + +const TAU = Math.PI * 2; + +/** The point at radius `r` and `angle` from the centre (0, 0), in SVG coordinates (y down). */ +export function polar(r: number, angle: number): [number, number] { + return [round(r * Math.sin(angle)), round(-r * Math.cos(angle))]; +} + +function round(n: number): number { + return Math.round(n * 100) / 100 || 0; +} + +/** + * The SVG path of a ring sector from `a0` to `a1` between radii `r0` (inner) and `r1` (outer). + * An inner radius of 0 makes a pie wedge; a span of a full turn makes a whole ring (or disc). + */ +export function sectorPath(r0: number, r1: number, a0: number, a1: number): string { + if (r1 <= 0 || a1 <= a0) return ''; + const inner = Math.max(0, r0); + if (a1 - a0 >= TAU - 1e-6) { + // A full turn: two half arcs each way, since one arc can't start and end on the same point. + const ring = (r: number, sweep: 0 | 1) => { + const [x0, y0] = polar(r, 0); + const [x1, y1] = polar(r, Math.PI); + return `M${x0} ${y0}A${r} ${r} 0 1 ${sweep} ${x1} ${y1}A${r} ${r} 0 1 ${sweep} ${x0} ${y0}Z`; + }; + return inner > 0 ? `${ring(r1, 1)}${ring(inner, 0)}` : ring(r1, 1); + } + const large = a1 - a0 > Math.PI ? 1 : 0; + const [ox0, oy0] = polar(r1, a0); + const [ox1, oy1] = polar(r1, a1); + const outer = `M${ox0} ${oy0}A${r1} ${r1} 0 ${large} 1 ${ox1} ${oy1}`; + if (inner === 0) return `${outer}L0 0Z`; + const [ix1, iy1] = polar(inner, a1); + const [ix0, iy0] = polar(inner, a0); + return `${outer}L${ix1} ${iy1}A${inner} ${inner} 0 ${large} 0 ${ix0} ${iy0}Z`; +} + +/** The angle of spoke `i` of `n` evenly spaced round a circle, the first straight up. */ +export function axisAngle(i: number, n: number): number { + return n > 0 ? (i * TAU) / n : 0; +} + +/** `points` as an SVG polygon `points` attribute. */ +export function polygonPoints(points: [number, number][]): string { + return points.map(([x, y]) => `${x},${y}`).join(' '); +} + +/** + * Where a label at `angle` should hang from its anchor point: text left of the centre line ends + * at the point, right of it starts there, and on the line itself it is centred. + */ +export function labelAnchor(angle: number): 'start' | 'middle' | 'end' { + const x = Math.sin(angle); + if (Math.abs(x) < 0.2) return 'middle'; + return x > 0 ? 'start' : 'end'; +} diff --git a/src/chart/sankey.ts b/src/chart/sankey.ts new file mode 100644 index 0000000..6aac283 --- /dev/null +++ b/src/chart/sankey.ts @@ -0,0 +1,277 @@ +/** + * Sankey maths: which flows can be drawn, which column each node goes in, and where every node + * and link sits. Pure functions in pixel units, so the style only has to draw what comes back. + */ + +/** One flow from the card's entities: a link from one named node to another. */ +export interface Flow { + /** Index into the card's items, for tap actions. */ + index: number; + from: string; + to: string; + /** Current value; null, negative or non-finite counts as nothing flowing. */ + value: number | null; +} + +export interface SankeyNode { + name: string; + /** Order of first appearance in the flows, for its colour and its place in its column. */ + order: number; + column: number; + value: number; + in: number; + out: number; + x: number; + y: number; + h: number; +} + +export interface SankeyLink { + flow: Flow; + value: number; + source: SankeyNode; + target: SankeyNode; + /** Band width in px. */ + width: number; + /** Centre of the band where it leaves the source and where it reaches the target. */ + y0: number; + y1: number; +} + +export interface SankeyLayout { + nodes: SankeyNode[]; + links: SankeyLink[]; + columns: number; +} + +export interface LayoutOptions { + width: number; + height: number; + /** Space kept left of the first column and right of the last, for labels. */ + left: number; + right: number; + nodeWidth: number; + /** Space between nodes in a column. */ + gap: number; + /** Extra space a node needs above it in its column (a label sitting on top). */ + above?(node: SankeyNode, columns: number): number; +} + +/** Nodes a node can reach by following links forwards. */ +function reaches(edges: Map, from: string, to: string): boolean { + const seen = new Set(); + const stack = [from]; + while (stack.length) { + const n = stack.pop()!; + if (n === to) return true; + if (seen.has(n)) continue; + seen.add(n); + stack.push(...(edges.get(n) ?? [])); + } + return false; +} + +/** + * The flows that can be drawn, in order: each needs a `from` and a different `to`, and a flow + * that would close a loop (A → B → A) is left out, as are the ones after it that would. + */ +export function acceptFlows>(flows: T[]): T[] { + const edges = new Map(); + const kept: T[] = []; + for (const f of flows) { + if (!f.from || !f.to || f.from === f.to) continue; + if (reaches(edges, f.to, f.from)) continue; + edges.set(f.from, [...(edges.get(f.from) ?? []), f.to]); + kept.push(f); + } + return kept; +} + +/** The first loop in a list of from → to pairs, as the names round it ("A", "B", "A"), if any. */ +export function findCycle(pairs: [string, string][]): string[] | undefined { + const edges = new Map(); + for (const [from, to] of pairs) { + if (from === to) return [from, to]; + edges.set(from, [...(edges.get(from) ?? []), to]); + } + const state = new Map(); + const path: string[] = []; + const visit = (n: string): string[] | undefined => { + if (state.get(n) === 'open') return [...path.slice(path.indexOf(n)), n]; + if (state.get(n) === 'done') return undefined; + state.set(n, 'open'); + path.push(n); + for (const next of edges.get(n) ?? []) { + const found = visit(next); + if (found) return found; + } + path.pop(); + state.set(n, 'done'); + return undefined; + }; + for (const n of edges.keys()) { + const found = visit(n); + if (found) return found; + } + return undefined; +} + +/** Nothing flowing counts as 0: no reading, a negative one, or not a number. */ +export function flowValue(v: number | null): number { + return v !== null && Number.isFinite(v) && v > 0 ? v : 0; +} + +/** + * Each node's column: the longest path to it from a node nothing flows into. A node that only + * receives stays in the column after its source, rather than being pushed to the far right. + * Expects loop-free flows (see `acceptFlows`). + */ +export function nodeColumns(flows: Pick[]): Map { + const column = new Map(); + for (const f of flows) { + column.set(f.from, column.get(f.from) ?? 0); + column.set(f.to, column.get(f.to) ?? 0); + } + // Relax until settled; with no loops, at most one pass per node. + for (let pass = 0; pass <= column.size; pass++) { + let changed = false; + for (const f of flows) { + const next = column.get(f.from)! + 1; + if (next > column.get(f.to)!) { + column.set(f.to, next); + changed = true; + } + } + if (!changed) break; + } + return column; +} + +/** + * Lay out the flows in a `width` × `height` box. Columns come from every flow (so a node keeps + * its column while a flow is at 0), but only nodes with something flowing are drawn, and empty + * columns close up. Node heights share one scale, set by the fullest column; each column is + * centred. A node's links leave and arrive in the order of the nodes at their other ends, so + * bands cross as little as they can. + */ +export function sankeyLayout(flows: Flow[], o: LayoutOptions): SankeyLayout { + const accepted = acceptFlows(flows); + const columnOf = nodeColumns(accepted); + const order = new Map(); + for (const f of accepted) { + if (!order.has(f.from)) order.set(f.from, order.size); + if (!order.has(f.to)) order.set(f.to, order.size); + } + const live = accepted.filter((f) => flowValue(f.value) > 0); + const byName = new Map(); + const node = (name: string) => { + let n = byName.get(name); + if (!n) { + n = { name, order: order.get(name)!, column: columnOf.get(name)!, value: 0, in: 0, out: 0, x: 0, y: 0, h: 0 }; + byName.set(name, n); + } + return n; + }; + const links: SankeyLink[] = live.map((flow) => { + const value = flowValue(flow.value); + const source = node(flow.from); + const target = node(flow.to); + source.out += value; + target.in += value; + return { flow, value, source, target, width: 0, y0: 0, y1: 0 }; + }); + const nodes = [...byName.values()].sort((a, b) => a.order - b.order); + for (const n of nodes) n.value = Math.max(n.in, n.out); + + // Close up empty columns. + const used = [...new Set(nodes.map((n) => n.column))].sort((a, b) => a - b); + for (const n of nodes) n.column = used.indexOf(n.column); + const columns = used.length; + if (columns === 0) return { nodes, links, columns }; + + const stacks = used.map((_, c) => nodes.filter((n) => n.column === c)); + const above = (n: SankeyNode) => o.above?.(n, columns) ?? 0; + const fixed = stacks.map((s) => s.reduce((sum, n) => sum + above(n), 0) + o.gap * (s.length - 1)); + const totals = stacks.map((s) => s.reduce((sum, n) => sum + n.value, 0)); + const scale = Math.max(0, Math.min(...stacks.map((_, c) => (o.height - fixed[c]) / totals[c]))); + const span = o.width - o.left - o.right - o.nodeWidth; + stacks.forEach((stack, c) => { + let y = (o.height - (totals[c] * scale + fixed[c])) / 2; + const x = o.left + (columns > 1 ? (span * c) / (columns - 1) : span / 2); + for (const n of stack) { + y += above(n); + n.x = x; + n.y = y; + n.h = n.value * scale; + y += n.h + o.gap; + } + }); + + const mid = (n: SankeyNode) => n.y + n.h / 2; + for (const l of links) l.width = l.value * scale; + for (const n of nodes) { + const outs = links.filter((l) => l.source === n).sort((a, b) => mid(a.target) - mid(b.target)); + let y = n.y; + for (const l of outs) { + l.y0 = y + l.width / 2; + y += l.width; + } + const ins = links.filter((l) => l.target === n).sort((a, b) => mid(a.source) - mid(b.source)); + y = n.y; + for (const l of ins) { + l.y1 = y + l.width / 2; + y += l.width; + } + } + return { nodes, links, columns }; +} + +/** + * A band from source to target as a filled shape, its edges curving half way across. Filled + * rather than stroked, so a wide band between close columns keeps clean edges. At least 1 px. + */ +export function linkPath(l: SankeyLink, nodeWidth: number): string { + const x0 = l.source.x + nodeWidth; + const x1 = l.target.x; + const mx = (x0 + x1) / 2; + const h = Math.max(1, l.width) / 2; + const r = (n: number) => Math.round(n * 10) / 10; + const [a, b, c, d] = [l.y0 - h, l.y1 - h, l.y1 + h, l.y0 + h].map(r); + return ( + `M${r(x0)},${a}C${r(mx)},${a} ${r(mx)},${b} ${r(x1)},${b}` + + `L${r(x1)},${c}C${r(mx)},${c} ${r(mx)},${d} ${r(x0)},${d}Z` + ); +} + +/** Rough text width in px: no measuring in a render, so a generous average glyph width. */ +export function textWidth(text: string, fontSize: number): number { + return text.length * fontSize * 0.58; +} + +/** `text` cut to fit `max` px, ending in "…" when cut; '' when not even a letter fits. */ +export function fitText(text: string, max: number, fontSize: number): string { + if (textWidth(text, fontSize) <= max) return text; + const chars = Math.floor(max / (fontSize * 0.58)) - 1; + return chars >= 1 ? `${text.slice(0, chars).trimEnd()}…` : ''; +} + +/** + * Move label centres in one column apart so labels `size` px tall don't overlap, staying within + * 0..height where they can. Returns the new centres in the same order as given. + */ +export function spreadLabels(centres: number[], size: number, height: number): number[] { + const idx = centres.map((_, i) => i).sort((a, b) => centres[a] - centres[b]); + const out = [...centres]; + let prev = -Infinity; + for (const i of idx) { + out[i] = Math.max(out[i], prev + size, size / 2); + prev = out[i]; + } + // Pushed past the bottom: shift back up from the end. + let next = Infinity; + for (const i of [...idx].reverse()) { + out[i] = Math.min(out[i], next - size, height - size / 2); + next = out[i]; + } + return out; +} diff --git a/src/chart/stream.ts b/src/chart/stream.ts new file mode 100644 index 0000000..9d720b2 --- /dev/null +++ b/src/chart/stream.ts @@ -0,0 +1,206 @@ +import { groupHistory, groupStatistics } from '../history/group'; +import type { Point, Range, Source } from '../history/types'; + +/** + * Stacking maths for the streamgraph: series resampled onto shared times, ordered, and stacked + * on a baseline that is zero, centred, shares of the total, or "wiggle" (the streamgraph's own). + */ + +export const BASELINES = ['wiggle', 'centre', 'share', 'zero'] as const; +export type Baseline = (typeof BASELINES)[number]; + +export const STREAM_ORDERS = ['inside_out', 'listed'] as const; +export type StreamOrder = (typeof STREAM_ORDERS)[number]; + +/** One layer of the stack: its bottom and top edge at each sample. */ +export interface Layer { + lower: number[]; + upper: number[]; +} + +/** + * Even bins across `range`, up to `until` (now): each bin's start, then the last end. `count` is + * how many bins; a range already over (or not begun) still gets at least one. + */ +export function sampleBounds(range: Range, until: number, count: number): number[] { + const end = Math.max(range.start + 1, Math.min(range.end, until)); + const n = Math.max(1, Math.round(count)); + const step = (end - range.start) / n; + const out: number[] = []; + for (let i = 0; i < n; i++) out.push(range.start + i * step); + out.push(end); + return out; +} + +/** + * One value per bin: the time-weighted mean of history (a reading holds until the next, so a + * short spike counts for as long as it lasted), or the mean of the statistics rows in it. Gaps + * stay null. + */ +export function binSeries(points: Point[], bounds: number[], source: Source, until: number): (number | null)[] { + const grouped = + source === 'statistics' + ? groupStatistics(points, bounds, 'mean', until) + : groupHistory(points, bounds, 'mean', until); + return grouped.map((p) => p.v); +} + +/** + * Values ready to stack: a gap is nothing (0), and so is a negative reading, which a stack of + * shares can't show (power flowing back out, a sensor glitch). + */ +export function stackable(values: (number | null)[]): number[] { + return values.map((v) => (v === null || !Number.isFinite(v) || v < 0 ? 0 : v)); +} + +/** + * Inside-out order (Byron & Wattenberg): series that peak early go in first, each added to + * whichever side of the stack is lighter, so the biggest swells sit in the middle and the + * edges stay calm. Returns series indices, bottom to top. + */ +export function insideOut(values: number[][]): number[] { + const peaks = values.map((row, i) => { + let at = 0; + for (let j = 1; j < row.length; j++) if (row[j] > row[at]) at = j; + return { i, at, sum: row.reduce((a, v) => a + v, 0) }; + }); + peaks.sort((a, b) => a.at - b.at || a.i - b.i); + let top = 0; + let bottom = 0; + const tops: number[] = []; + const bottoms: number[] = []; + for (const p of peaks) { + if (top < bottom) { + top += p.sum; + tops.push(p.i); + } else { + bottom += p.sum; + bottoms.push(p.i); + } + } + return [...bottoms.reverse(), ...tops]; +} + +/** + * The bottom of the stack at each sample. + * + * - zero: an ordinary stacked area, from nothing up. + * - centre: the stack centred on zero (a "silhouette"). + * - wiggle: the baseline that keeps the layers' slopes smallest overall (Byron & Wattenberg, + * as in d3's stackOffsetWiggle), so each stream reads as flat as it can; then centred. + * - share: zero, with every column already scaled to 100. + * + * `ordered` is the values bottom to top. + */ +export function baseline(ordered: number[][], kind: Baseline): number[] { + const m = ordered[0]?.length ?? 0; + const totals = Array.from({ length: m }, (_, j) => ordered.reduce((a, row) => a + row[j], 0)); + if (kind === 'zero' || kind === 'share') return new Array(m).fill(0); + if (kind === 'centre') return totals.map((t) => -t / 2); + const g = new Array(m).fill(0); + for (let j = 1; j < m; j++) { + let s1 = 0; + let s2 = 0; + for (let i = 0; i < ordered.length; i++) { + const now = ordered[i][j]; + let s3 = (now - ordered[i][j - 1]) / 2; + for (let k = 0; k < i; k++) s3 += ordered[k][j] - ordered[k][j - 1]; + s1 += now; + s2 += s3 * now; + } + g[j] = g[j - 1] - (s1 ? s2 / s1 : 0); + } + // Centre the whole stream on zero, so it sits in the middle of the plot. + let lo = Infinity; + let hi = -Infinity; + for (let j = 0; j < m; j++) { + lo = Math.min(lo, g[j]); + hi = Math.max(hi, g[j] + totals[j]); + } + const shift = m ? -(lo + hi) / 2 : 0; + return g.map((v) => v + shift); +} + +/** Each column as a share of its total, in per cent; a column with nothing in it stays empty. */ +export function toShares(values: number[][]): number[][] { + const m = values[0]?.length ?? 0; + const totals = Array.from({ length: m }, (_, j) => values.reduce((a, row) => a + row[j], 0)); + return values.map((row) => row.map((v, j) => (totals[j] > 0 ? (v / totals[j]) * 100 : 0))); +} + +/** + * Stack `values` (one row per series, same length) in `order` (bottom to top) on `kind`'s + * baseline. Layers come back indexed like `values`, not in stacking order. + */ +export function stack(values: number[][], order: number[], kind: Baseline): Layer[] { + const rows = kind === 'share' ? toShares(values) : values; + const ordered = order.map((i) => rows[i]); + let lower = baseline(ordered, kind); + const layers: Layer[] = new Array(values.length); + for (const i of order) { + const upper = lower.map((b, j) => b + rows[i][j]); + layers[i] = { lower, upper }; + lower = upper; + } + return layers; +} + +/** Lowest bottom and highest top across the layers, or undefined when there are none. */ +export function layersExtent(layers: Layer[]): [number, number] | undefined { + let lo = Infinity; + let hi = -Infinity; + for (const l of layers) { + for (const v of l.lower) lo = Math.min(lo, v); + for (const v of l.upper) hi = Math.max(hi, v); + } + return lo === Infinity ? undefined : [lo, hi]; +} + +/** The sample where a layer is thickest, for its label. */ +export function widest(layer: Layer): { at: number; thickness: number } { + let at = 0; + let thickness = -Infinity; + layer.upper.forEach((u, j) => { + const t = u - layer.lower[j]; + if (t > thickness) { + thickness = t; + at = j; + } + }); + return { at, thickness: Math.max(0, thickness) }; +} + +/** A name to put on a stream: where its middle is (px) and how wide the text is. */ +export interface LabelSpot { + key: number; + x: number; + y: number; + width: number; + /** How thick the stream is there (px): thicker streams get their label first. */ + thickness: number; +} + +/** Text height in px, for keeping labels apart. */ +const LABEL_H = 13; + +/** + * Where labels go without overlapping: thickest stream first, each centred on its spot but + * kept inside the plot; one that would touch a label already placed is left out. Returns each + * placed label's left edge (px), by key. + */ +export function placeLabels(spots: LabelSpot[], plotWidth: number): Map { + const placed: { left: number; right: number; top: number; bottom: number }[] = []; + const out = new Map(); + for (const s of [...spots].sort((a, b) => b.thickness - a.thickness || a.key - b.key)) { + if (s.width > plotWidth - 4) continue; + const left = Math.min(Math.max(2, s.x - s.width / 2), plotWidth - s.width - 2); + const box = { left, right: left + s.width, top: s.y - LABEL_H / 2, bottom: s.y + LABEL_H / 2 }; + const hit = placed.some( + (p) => box.left < p.right + 4 && box.right > p.left - 4 && box.top < p.bottom && box.bottom > p.top, + ); + if (hit) continue; + placed.push(box); + out.set(s.key, left); + } + return out; +} diff --git a/src/chart/swarm.ts b/src/chart/swarm.ts new file mode 100644 index 0000000..1814331 --- /dev/null +++ b/src/chart/swarm.ts @@ -0,0 +1,105 @@ +/** + * Beeswarm packing: dots placed along a value axis at their value, each nudged sideways as little + * as it can be so no two overlap. Everything here is in px, so dots stay round on screen. + */ + +export interface SwarmDot { + /** Position along the value axis (px). */ + at: number; + /** Radius (px). */ + r: number; +} + +/** Room left between neighbouring dots (px). */ +const GAP = 0.75; + +/** + * Sideways offsets (px, 0 is the axis line) for `dots`, in the order given. Dots are placed from + * one end of the axis to the other; each takes the free spot nearest the line, touching a dot + * already placed or on the line itself (the classic greedy beeswarm), the positive side first on a tie. + */ +export function swarm(dots: SwarmDot[]): number[] { + const order = dots.map((_, i) => i).sort((a, b) => dots[a].at - dots[b].at || a - b); + const out = new Array(dots.length).fill(0); + const placed: { at: number; r: number; y: number }[] = []; + let first = 0; + const largest = maxR(dots); + for (const i of order) { + const { at, r } = dots[i]; + // Placed dots are in order along the axis: skip those too far behind to touch this one. + while (first < placed.length && at - placed[first].at > r + largest + GAP) first++; + const near = placed.slice(first).filter((q) => Math.abs(q.at - at) < q.r + r + GAP); + const candidates = [0]; + for (const q of near) { + const reach = q.r + r + GAP; + const dy = Math.sqrt(Math.max(0, reach * reach - (q.at - at) ** 2)); + candidates.push(q.y + dy, q.y - dy); + } + const ok = candidates + .sort((a, b) => Math.abs(a) - Math.abs(b) || b - a) + .find((y) => near.every((q) => (q.at - at) ** 2 + (q.y - y) ** 2 >= (q.r + r + GAP) ** 2 - 1e-6)); + const y = ok ?? 0; + out[i] = y; + placed.push({ at, r, y }); + } + return out; +} + +/** The largest radius among `dots` (for how far back a neighbour might reach). */ +function maxR(dots: { r: number }[]): number { + let m = 0; + for (const d of dots) if (d.r > m) m = d.r; + return m; +} + +/** How far the swarm spreads from its line (px): the furthest dot's edge. */ +export function swarmReach(dots: SwarmDot[], offsets: number[]): number { + let m = 0; + dots.forEach((d, i) => { + m = Math.max(m, Math.abs(offsets[i]) + d.r); + }); + return m; +} + +/** + * Pack dots into a band `half` px either side of the line. A swarm too tall for its band is + * packed again with smaller dots (down to `minR`); one still too tall after that is squeezed + * into the band, letting its dots overlap rather than spill into the next row. + * Returns the offsets and the scale the radii were drawn at. + */ +export function fitSwarm(dots: SwarmDot[], half: number, minR = 1): { offsets: number[]; scale: number } { + let scale = 1; + let offsets = swarm(dots); + const reachAt = (s: number) => + swarmReach( + dots.map((d) => ({ ...d, r: d.r * s })), + offsets, + ); + const smallest = Math.min(...dots.map((d) => d.r)); + const floor = Math.min(1, minR / Math.max(smallest, 1e-9)); + for (let tries = 0; tries < 4; tries++) { + const reach = reachAt(scale); + if (reach <= half) return { offsets, scale }; + const next = Math.max(floor, scale * Math.sqrt(half / reach) * 0.95); + if (next >= scale) break; + scale = next; + offsets = swarm(dots.map((d) => ({ ...d, r: d.r * scale }))); + } + const reach = reachAt(scale); + if (reach > half) { + const room = Math.max(0, half - Math.max(...dots.map((d) => d.r * scale))); + const furthest = Math.max(...offsets.map(Math.abs)); + if (furthest > 0) offsets = offsets.map((y) => (y * room) / furthest); + } + return { offsets, scale }; +} + +/** + * Dot radius for a value when dots are sized by value: the dot's area grows from 12 % at the + * lowest value in the row (`lo`) to all of it at the highest (`hi`). + */ +export function sizeFor(v: number, lo: number, hi: number, r: number): number { + if (!(hi > lo)) return r; + const share = Math.min(1, Math.max(0, (v - lo) / (hi - lo))); + return r * Math.sqrt(0.12 + 0.88 * share); +} diff --git a/src/chart/sync.ts b/src/chart/sync.ts new file mode 100644 index 0000000..ea904dd --- /dev/null +++ b/src/chart/sync.ts @@ -0,0 +1,130 @@ +import type { HistoryFrame } from './frame'; +import { setSteps, stepsFor } from '../history/period'; +import type { ChartContext } from '../styles/index'; +import type { CardConfig } from '../types'; + +/** + * Synced charts: cards with the same `sync` group name share what the pointer is over, and + * previous / next steps between charts covering the same kind of period. They talk over one + * window event, so cards anywhere on the dashboard (in stacks, grids, other columns) find each + * other without knowing about one another. + */ +export const SYNC_EVENT = 'chartdeck-sync'; + +/** What one chart tells the others in its group. */ +export type SyncMessage = + /** The time under the pointer, or undefined once it has left. */ + | { type: 'hover'; t: number | undefined } + /** Steps back from the period, for charts with the same period. */ + | { type: 'period'; period: string; steps: number } + /** A zoom window over the span (fractions), for charts that zoom; same period only. */ + | { type: 'zoom'; period: string; from: number; to: number }; + +export interface SyncDetail { + group: string; + source: HTMLElement; + message: SyncMessage; +} + +export function validateSyncOptions(raw: CardConfig): void { + if (raw.sync !== undefined && (typeof raw.sync !== 'string' || raw.sync.trim() === '')) { + throw new Error('"sync" must be a group name'); + } +} + +/** The card's sync group, if it has one. */ +export function syncGroup(raw: CardConfig): string | undefined { + return typeof raw.sync === 'string' && raw.sync.trim() !== '' ? raw.sync.trim() : undefined; +} + +interface SyncState { + group: string; + listener: (ev: Event) => void; + /** The last hover time this card sent, so it sends only changes. */ + sent?: number; + /** The time another chart's pointer is over. */ + remote?: number; + /** The period and steps this card last drew, to notice its own buttons being used. */ + period?: string; + steps?: number; + /** Handlers for message kinds features add (zoom). */ + handlers: Map void>; + ctx: ChartContext; +} + +const states = new WeakMap(); + +/** Send a message to the other charts in the card's group. */ +export function syncSend(host: HTMLElement, message: SyncMessage): void { + const s = states.get(host); + if (!s) return; + window.dispatchEvent(new CustomEvent(SYNC_EVENT, { detail: { group: s.group, source: host, message } })); +} + +/** Stop listening (the card leaves the page, or its group is cleared). */ +export function stopSync(host: HTMLElement): void { + const s = states.get(host); + if (s) window.removeEventListener(SYNC_EVENT, s.listener); + states.delete(host); +} + +const periodKey = (frame: Pick) => JSON.stringify(frame.period); + +/** + * Join (or keep up) the card's group for this draw. Call it from `render` with the frame being + * drawn and the time the card's own pointer is over: it tells the group when that changes, or + * when the card's previous / next buttons stepped, and returns the time another chart's pointer + * is over, for the card to show its guide there. + */ +export function syncChart(ctx: ChartContext, frame: HistoryFrame, hover: number | undefined): number | undefined { + const group = syncGroup(ctx.raw); + let s = states.get(ctx.host); + if (!group) { + if (s) stopSync(ctx.host); + return undefined; + } + if (!s || s.group !== group) { + if (s) stopSync(ctx.host); + const state: SyncState = { group, handlers: new Map(), ctx, listener: () => undefined }; + state.listener = (ev: Event) => receive(state, (ev as CustomEvent).detail); + window.addEventListener(SYNC_EVENT, state.listener); + states.set(ctx.host, state); + s = state; + } + s.ctx = ctx; + if (hover !== s.sent) { + s.sent = hover; + syncSend(ctx.host, { type: 'hover', t: hover }); + } + const period = periodKey(frame); + const steps = frame.period.navigate ? stepsFor(ctx.host, frame.period) : 0; + if (s.period === period && s.steps !== steps) syncSend(ctx.host, { type: 'period', period, steps }); + s.period = period; + s.steps = steps; + // The other chart's pointer, while this one's own isn't on the chart. + return hover === undefined ? s.remote : undefined; +} + +/** Let a feature (zoom) act on its kind of message from the group. */ +export function onSync(host: HTMLElement, type: SyncMessage['type'], handler: (m: SyncMessage) => void): void { + states.get(host)?.handlers.set(type, handler); +} + +function receive(s: SyncState, detail: SyncDetail | undefined): void { + if (!detail || detail.group !== s.group || detail.source === s.ctx.host) return; + const m = detail.message; + if (m.type === 'hover') { + if (m.t === s.remote) return; + s.remote = m.t; + s.ctx.requestUpdate(); + } else if (m.type === 'period') { + const frame = { period: JSON.parse(m.period) as HistoryFrame['period'] }; + // Only a chart covering the same kind of period, with its own buttons, follows the steps. + if (m.period !== s.period || !frame.period.navigate || stepsFor(s.ctx.host, frame.period) === m.steps) return; + setSteps(s.ctx.host, frame.period, m.steps); + // Taken from the group, not this card's buttons: nothing to pass on. + s.steps = m.steps; + s.ctx.requestUpdate(); + } + s.handlers.get(m.type)?.(m); +} diff --git a/src/chart/timeline.ts b/src/chart/timeline.ts new file mode 100644 index 0000000..2efc484 --- /dev/null +++ b/src/chart/timeline.ts @@ -0,0 +1,213 @@ +import { baseColor, onFillText } from '../bands'; +import type { Point, Range } from '../history/types'; + +/** One stretch of time an entity spent in one state. `state` is undefined where nothing was recorded. */ +export interface StateSpan { + start: number; + end: number; + state: string | undefined; +} + +/** States that mean "no reading": drawn as a gap, not a bar. */ +export const GAP_STATES = new Set(['unavailable', 'unknown']); + +/** Whether a span is a gap (nothing recorded, unavailable or unknown). */ +export function isGap(state: string | undefined): boolean { + return state === undefined || state === '' || GAP_STATES.has(state); +} + +/** + * Text points as spans: each state lasts until the next point, the last one until `until`. + * Repeats of the same state (an attribute changed, not the state) join into one span; points + * at or after `until` and empty spans are left out. + */ +export function stateSpans(points: readonly Point[], until: number): StateSpan[] { + const spans: StateSpan[] = []; + for (let i = 0; i < points.length; i++) { + const p = points[i]; + if (p.t >= until) break; + const end = Math.min(until, i + 1 < points.length ? points[i + 1].t : until); + if (end <= p.t) continue; + const last = spans[spans.length - 1]; + if (last && last.state === p.s && last.end === p.t) last.end = end; + else spans.push({ start: p.t, end, state: p.s }); + } + return spans; +} + +/** The span covering time `t`, if any. */ +export function spanAt(spans: readonly StateSpan[], t: number): StateSpan | undefined { + return spans.find((s) => t >= s.start && (t < s.end || (t === s.end && s === spans[spans.length - 1]))); +} + +/** + * Colours for common Home Assistant states, palette names or CSS colours. Words not listed get a + * colour picked from the word itself, so one state has the same colour on every row. + */ +export const DEFAULT_STATE_COLORS: Record = { + on: 'amber', + open: 'amber', + opening: 'amber', + closing: 'amber', + home: 'green', + playing: 'green', + charging: 'green', + auto: 'green', + heat_cool: 'green', + heat: '#ff8100', + heating: '#ff8100', + preheating: '#ff8100', + cool: '#2b9af9', + cooling: '#2b9af9', + dry: '#efbd07', + drying: '#efbd07', + fan_only: '#14b8a6', + fan: '#14b8a6', + paused: 'purple', + buffering: 'purple', + detected: 'red', + problem: 'red', + triggered: 'red', + unlocked: 'red', + jammed: 'red', + locked: 'green', + armed_home: 'green', + armed_away: 'green', + armed_night: 'green', + armed_vacation: 'green', + armed_custom_bypass: 'green', + arming: 'amber', + pending: 'amber', + cleaning: 'primary', + returning: 'purple', +}; + +/** States that mean "nothing happening": drawn faint, so the active ones stand out. */ +export const QUIET_STATES = new Set([ + 'off', + 'closed', + 'not_home', + 'away', + 'idle', + 'standby', + 'docked', + 'clear', + 'disarmed', + 'none', + 'false', +]); + +/** Colours for words with no default, in turn by the word. */ +const WORD_COLORS = ['primary', 'purple', '#14b8a6', '#ec4899', 'amber', 'green', 'blue', 'red']; + +/** A small stable number for a word. */ +export function wordHash(word: string): number { + let h = 0; + for (let i = 0; i < word.length; i++) h = (h * 31 + word.charCodeAt(i)) >>> 0; + return h; +} + +export interface StateColor { + /** A CSS colour (theme variables included). */ + color: string; + /** Drawn faint: a "nothing happening" state with no colour of its own. */ + quiet: boolean; + /** Text colour for a label on the bar. */ + text: 'dark' | 'light' | 'theme'; +} + +/** + * The colour of one state. The card's own `state_colors` win (a palette name or any CSS + * colour). Otherwise Home Assistant's theme colour for that domain and state is used when the + * theme has one (`--state-light-on-color`, `--state-climate-heat-color`), falling back to the + * defaults above, then grey for a quiet state, then a colour chosen by the word. + */ +export function stateColor(state: string, domain: string, own: Record | undefined): StateColor { + const set = own?.[state]; + if (set) return { color: baseColor(set), quiet: false, text: onFillText(set) }; + const quiet = QUIET_STATES.has(state) && !Object.prototype.hasOwnProperty.call(DEFAULT_STATE_COLORS, state); + const base = Object.prototype.hasOwnProperty.call(DEFAULT_STATE_COLORS, state) + ? DEFAULT_STATE_COLORS[state] + : quiet + ? 'grey' + : WORD_COLORS[wordHash(state) % WORD_COLORS.length]; + const fallback = baseColor(base); + const css = /^[a-z0-9_]+$/.test(state) && /^[a-z0-9_]+$/.test(domain); + return { + color: css ? `var(--state-${domain}-${state}-color, ${fallback})` : fallback, + quiet, + text: quiet ? 'theme' : onFillText(base), + }; +} + +/** A length of time, short: "45 s", "12 min", "3 h 30", "2 d 4 h". */ +export function formatDuration(ms: number): string { + const s = Math.max(0, Math.round(ms / 1000)); + if (s < 60) return `${s} s`; + const m = Math.round(s / 60); + if (m < 60) return `${m} min`; + const h = Math.floor(m / 60); + if (h < 24) { + const rest = m - h * 60; + return rest ? `${h} h ${String(rest).padStart(2, '0')}` : `${h} h`; + } + const d = Math.floor(h / 24); + const rest = h - d * 24; + return rest ? `${d} d ${rest} h` : `${d} d`; +} + +/** Rough width in px of a label at the timeline's 11 px font, with its padding. */ +export function labelWidth(text: string): number { + return text.length * 6.2 + 10; +} + +/** Where a span sits across the plot, as fractions of the range (clamped to it). */ +export function spanBox(span: StateSpan, range: Range): { left: number; width: number } { + const total = range.end - range.start; + if (!(total > 0)) return { left: 0, width: 0 }; + const a = Math.max(range.start, span.start); + const b = Math.min(range.end, span.end); + return { left: (a - range.start) / total, width: Math.max(0, b - a) / total }; +} + +/** One row of the timeline: an entity, or a heading over a group of them. */ +export type TimelineRow = { heading: string } | { item: T }; + +/** + * Rows in listed order, with entities that share a `group` brought together under a heading + * where the group is first listed. Entities without a group stay where they are, unheaded. + */ +export function timelineRows(items: readonly T[], groupOf: (item: T) => string | undefined): TimelineRow[] { + const rows: TimelineRow[] = []; + const done = new Set(); + for (const item of items) { + const group = groupOf(item); + if (group === undefined) { + rows.push({ item }); + continue; + } + if (done.has(group)) continue; + done.add(group); + rows.push({ heading: group }); + for (const other of items) if (groupOf(other) === group) rows.push({ item: other }); + } + return rows; +} + +/** More distinct numbers than this and a row is a measurement, not a set of states. */ +export const MAX_NUMBER_STATES = 8; + +/** + * Whether spans look like a measurement (a temperature, a power reading) rather than states: + * every recorded state is a number, and there are more different ones than a few modes would + * give. Those draw as a hint to use a line chart instead of a bar per reading. + */ +export function isMeasurement(spans: readonly StateSpan[]): boolean { + const seen = new Set(); + for (const s of spans) { + if (isGap(s.state)) continue; + if (!Number.isFinite(Number(s.state)) || (s.state as string).trim() === '') return false; + seen.add(s.state as string); + } + return seen.size > MAX_NUMBER_STATES; +} diff --git a/src/chart/treemap-layout.ts b/src/chart/treemap-layout.ts new file mode 100644 index 0000000..9c28dee --- /dev/null +++ b/src/chart/treemap-layout.ts @@ -0,0 +1,84 @@ +/** A rectangle in whatever units the caller lays out in. */ +export interface Rect { + x: number; + y: number; + w: number; + h: number; +} + +/** The worst (largest) aspect ratio in a row of `areas` laid along a side of length `side`. */ +function worst(areas: number[], side: number): number { + const sum = areas.reduce((a, b) => a + b, 0); + if (sum <= 0 || side <= 0) return Infinity; + const max = Math.max(...areas); + const min = Math.min(...areas); + const s2 = side * side; + return Math.max((s2 * max) / (sum * sum), (sum * sum) / (s2 * min)); +} + +/** + * Squarified treemap (Bruls, Huizing and van Wijk): split `rect` into one rectangle per value, + * each with an area in proportion to its value, kept as close to square as the values allow. + * Rectangles come back in the order of `values`; zero, negative and non-finite values get an + * empty rectangle at the corner. + */ +export function squarify(values: number[], rect: Rect): Rect[] { + const out: Rect[] = values.map(() => ({ x: rect.x, y: rect.y, w: 0, h: 0 })); + const items = values + .map((v, i) => ({ v: Number.isFinite(v) && v > 0 ? v : 0, i })) + .filter((it) => it.v > 0) + .sort((a, b) => b.v - a.v || a.i - b.i); + const total = items.reduce((a, it) => a + it.v, 0); + if (total <= 0 || rect.w <= 0 || rect.h <= 0) return out; + const scale = (rect.w * rect.h) / total; + let { x, y, w, h } = rect; + let row: { area: number; i: number }[] = []; + + const place = () => { + const sum = row.reduce((a, r) => a + r.area, 0); + if (w >= h) { + // Lay the row as a column down the left side. + const cw = sum / h; + let cy = y; + for (const r of row) { + const rh = r.area / cw; + out[r.i] = { x, y: cy, w: cw, h: rh }; + cy += rh; + } + x += cw; + w -= cw; + } else { + // Lay the row along the top. + const rh = sum / w; + let cx = x; + for (const r of row) { + const rw = r.area / rh; + out[r.i] = { x: cx, y, w: rw, h: rh }; + cx += rw; + } + y += rh; + h -= rh; + } + row = []; + }; + + for (const it of items) { + const area = it.v * scale; + const side = Math.min(w, h); + const current = row.map((r) => r.area); + if (row.length === 0 || worst([...current, area], side) <= worst(current, side)) { + row.push({ area, i: it.i }); + } else { + place(); + row.push({ area, i: it.i }); + } + } + if (row.length) place(); + return out; +} + +/** `rect` shrunk by `pad` on every side, never below zero size. */ +export function inset(rect: Rect, pad: number): Rect { + const p = Math.min(pad, rect.w / 2, rect.h / 2); + return { x: rect.x + p, y: rect.y + p, w: rect.w - 2 * p, h: rect.h - 2 * p }; +} diff --git a/src/chart/trend-data.ts b/src/chart/trend-data.ts new file mode 100644 index 0000000..27b1f73 --- /dev/null +++ b/src/chart/trend-data.ts @@ -0,0 +1,214 @@ +import { baseColor } from '../bands'; +import { AGGREGATES, groupHistory, groupStatistics } from '../history/group'; +import type { Aggregate } from '../history/group'; +import type { Point, Range, Source } from '../history/types'; +import type { ResolvedBand } from '../types'; +import { padDomain } from './scale'; + +/** + * The data side of the trend style: readings combined into evenly spaced buckets, the value + * range the graph spans (with soft and hard bounds and a minimum range), the colour stops for + * a graph coloured by bands, and the lowest, highest and average reading. + */ + +/** Buckets per hour when `points_per_hour` isn't set: one per two hours, as mini-graph-card. */ +export const DEFAULT_POINTS_PER_HOUR = 0.5; +/** Most buckets a graph draws; past this the buckets widen. */ +export const MAX_BUCKETS = 500; +/** Most bars a bar graph draws; past this the bars widen. */ +export const MAX_BARS = 96; + +/** A value-axis end: `hard` always applies; a soft one (`~N` in YAML) moves out to fit readings past it. */ +export interface Bound { + value: number; + soft: boolean; +} + +/** `5` or `"5"` is a hard bound, `"~5"` a soft one; anything else is an error. */ +export function parseBound(raw: unknown, name: string): Bound | undefined { + if (raw === undefined || raw === null || raw === '') return undefined; + if (typeof raw === 'number' && Number.isFinite(raw)) return { value: raw, soft: false }; + if (typeof raw === 'string') { + const text = raw.trim(); + const soft = text.startsWith('~'); + const n = Number(soft ? text.slice(1) : text); + if (text !== '' && text !== '~' && Number.isFinite(n)) return { value: n, soft }; + } + throw new Error(`"${name}" must be a number, or ~ and a number for a soft limit (e.g. "~0")`); +} + +export interface DomainOptions { + min?: Bound; + max?: Bound; + /** The range is never narrower than this, so small wobbles don't fill the graph. */ + minRange?: number; + /** Bars: readings all on one side of zero stand on zero. */ + fromZero?: boolean; +} + +/** + * The values the graph spans, bottom to top. Readings are padded a little so the line's stroke + * isn't cut by the card's edge; a hard bound is never padded past, and a soft bound only moves + * when a reading goes beyond it. + */ +export function trendDomain(data: [number, number] | undefined, options: DomainOptions = {}): [number, number] { + let [lo, hi] = data ?? [options.min?.value ?? 0, options.max?.value ?? 1]; + if (options.fromZero) { + if (lo > 0) lo = 0; + if (hi < 0) hi = 0; + } + if (options.minRange !== undefined && hi - lo < options.minRange) { + const middle = (lo + hi) / 2; + lo = middle - options.minRange / 2; + hi = middle + options.minRange / 2; + } + let [padLo, padHi] = padDomain([lo, hi], 0.1); + if (options.fromZero && lo === 0) padLo = 0; + if (options.fromZero && hi === 0) padHi = 0; + const { min, max } = options; + if (min) padLo = min.soft && lo < min.value ? padLo : min.value; + if (max) padHi = max.soft && hi > max.value ? padHi : max.value; + if (padHi <= padLo) padHi = padLo + 1; + return [padLo, padHi]; +} + +/** Bucket boundaries across `range`: each start, then the last end. Never more than `max` buckets. */ +export function bucketBounds(range: Range, pointsPerHour: number, max = MAX_BUCKETS): number[] { + const span = range.end - range.start; + if (span <= 0 || pointsPerHour <= 0) return []; + const count = Math.max(1, Math.min(max, Math.round((span / 3_600_000) * pointsPerHour))); + const width = span / count; + const bounds: number[] = []; + for (let i = 0; i <= count; i++) bounds.push(i === count ? range.end : Math.round(range.start + i * width)); + return bounds; +} + +/** + * One value per bucket, combined with `aggregate` (time-weighted for the average). History is + * readings that hold until the next one; statistics are rows. A bucket after `until` (now) is a + * gap. The point the frame adds at `until` to carry the last reading on is dropped first: it + * isn't a reading, and a sum would count it twice. + */ +export function bucketSeries( + points: Point[], + bounds: number[], + aggregate: Aggregate, + source: Source, + until: number, +): Point[] { + if (bounds.length < 2) return points; + let readings = points; + const last = points[points.length - 1]; + const before = points[points.length - 2]; + if (source === 'history' && last && before && last.t === until && last.v === before.v) readings = points.slice(0, -1); + return source === 'statistics' + ? groupStatistics(readings, bounds, aggregate, until) + : groupHistory(readings, bounds, aggregate, until); +} + +/** + * Where each bucket is drawn: at the middle of the part of it that has happened, so the last, + * still-filling bucket sits between its start and now. The first and last values are carried + * out to the graph's edges (marked `carried`, so hovering and dots skip them), as mini-graph-card + * draws edge to edge. + */ +export function placeBuckets(buckets: Point[], range: Range, until: number): TrendPoint[] { + const placed: TrendPoint[] = []; + for (const b of buckets) { + const stop = Math.min(b.end ?? b.t, until); + if (b.t >= until) break; + placed.push({ ...b, start: b.t, end: stop, t: Math.round((b.t + stop) / 2) }); + } + const real = placed.filter((p) => p.v !== null); + if (real.length === 0) return placed; + const first = placed[0]; + const last = placed[placed.length - 1]; + const out = [...placed]; + if (first.v !== null && first.t > range.start) out.unshift({ t: range.start, v: first.v, carried: true }); + const edge = Math.min(range.end, until); + if (last.v !== null && last.t < edge) out.push({ t: edge, v: last.v, carried: true }); + return out; +} + +/** A drawn point. A bucket keeps the span it covers (`start` to `end`) for hovering. */ +export interface TrendPoint extends Point { + start?: number; +} + +/** A gradient stop: `offset` 0 at the bottom of the graph, 1 at the top. */ +export interface ColorStop { + offset: number; + color: string; +} + +/** + * Stops for a vertical gradient that colours the graph by bands. `hard` changes colour exactly at + * each band's start; `smooth` blends from one band's colour to the next across the band, so the + * colour reaches each band's own at its start. + */ +export function bandStops(bands: ResolvedBand[], domain: [number, number], transition: 'smooth' | 'hard'): ColorStop[] { + const [lo, hi] = domain; + const at = (v: number) => Math.min(1, Math.max(0, (v - lo) / (hi - lo))); + const stops: ColorStop[] = []; + bands.forEach((band, i) => { + const color = baseColor(band.color); + const from = Number.isFinite(band.from) ? at(band.from) : 0; + const to = i + 1 < bands.length ? at(bands[i + 1].from) : 1; + if (transition === 'hard') { + stops.push({ offset: from, color }, { offset: to, color }); + } else if (i === 0 || i === bands.length - 1 || from < to) { + stops.push({ offset: from, color }); + if (i === bands.length - 1) stops.push({ offset: 1, color }); + } + }); + return stops; +} + +export interface Extreme { + t: number; + v: number; +} + +export interface Extrema { + min: Extreme; + max: Extreme; + /** Time-weighted when the readings hold until the next (history), else a plain mean. */ + mean: number; +} + +/** + * The lowest and highest reading and the average, over the readings drawn. Carried edge points + * aren't readings and are skipped (except to weight the last reading's time). + */ +export function extrema(points: Point[], weighted: boolean): Extrema | undefined { + let min: Extreme | undefined; + let max: Extreme | undefined; + let sum = 0; + let weight = 0; + for (let i = 0; i < points.length; i++) { + const p = points[i]; + if (p.v === null) continue; + if (!p.carried) { + if (!min || p.v < min.v) min = { t: p.t, v: p.v }; + if (!max || p.v > max.v) max = { t: p.t, v: p.v }; + } + if (weighted) { + const next = points[i + 1]; + const w = next ? next.t - p.t : 0; + sum += p.v * w; + weight += w; + } else if (!p.carried) { + sum += p.v; + weight += 1; + } + } + if (!min || !max) return undefined; + return { min, max, mean: weight > 0 ? sum / weight : (min.v + max.v) / 2 }; +} + +/** Throw a readable error for a bad per-entity aggregate. */ +export function checkAggregate(raw: unknown, where: string): void { + if (raw !== undefined && !AGGREGATES.includes(raw as Aggregate)) { + throw new Error(`${where} must be one of: ${AGGREGATES.join(', ')}`); + } +} diff --git a/src/chart/wind.ts b/src/chart/wind.ts new file mode 100644 index 0000000..114ce40 --- /dev/null +++ b/src/chart/wind.ts @@ -0,0 +1,230 @@ +import { COMPASS_POINTS, normalizeDegrees } from '../history/bearing'; +import type { Point } from '../history/types'; + +/** How many compass sectors a wind rose can have. */ +export const SECTOR_COUNTS = [4, 8, 16] as const; +export type SectorCount = (typeof SECTOR_COUNTS)[number]; + +/** + * The sector a direction falls in. Sector `i` of `n` is centred on `i * 360 / n`, so with 16 the + * north sector runs from 348.75° up to (not including) 11.25°, and 11.25° itself is NNE. + */ +export function sectorOf(degrees: number, n: number): number { + const width = 360 / n; + return Math.floor(normalizeDegrees(degrees) / width + 0.5) % n; +} + +/** A sector's compass name: N, NNE… for 16, N, NE… for 8, N, E, S, W for 4. */ +export function sectorName(i: number, n: number): string { + return COMPASS_POINTS[(i * (16 / n)) % 16]; +} + +/** One stretch of time with a steady direction (and speed, when there is one). */ +export interface WindSample { + /** Degrees, the direction the wind blows from. */ + dir: number; + speed: number | null; + /** How long it held, ms. */ + ms: number; +} + +/** The value a series holds at `t`: its last reading at or before `t`, unless that is too old. */ +function heldAt(points: Point[], i: number, t: number, maxHold: number): number | null { + if (i < 0) return null; + const p = points[i]; + return t - p.t < maxHold ? p.v : null; +} + +/** + * Pair each direction with the speed in effect at the same moment, weighted by how long the pair + * held. Sensors report on change, so both series are step-held: a new sample starts whenever + * either one changes (the union of their change times), and runs until the next change or + * `until`. Counting rows instead would favour gusty spells, which report more often. + * + * A gap (null) in either series drops that stretch. Without a `speed` series every stretch with a + * direction counts, speed null. `maxHold` stops a reading carrying on past it: statistics rows + * describe one period each, so a missing hour is a gap rather than the last hour held. + */ +export function pairWind(dir: Point[], speed: Point[] | undefined, until: number, maxHold = Infinity): WindSample[] { + const times = new Set([until]); + for (const series of speed ? [dir, speed] : [dir]) { + for (const p of series) { + times.add(p.t); + if (Number.isFinite(maxHold)) times.add(p.t + maxHold); + } + } + const sorted = [...times].filter((t) => t <= until).sort((a, b) => a - b); + const out: WindSample[] = []; + let d = -1; + let s = -1; + for (let k = 0; k + 1 < sorted.length; k++) { + const a = sorted[k]; + const ms = sorted[k + 1] - a; + while (d + 1 < dir.length && dir[d + 1].t <= a) d++; + const direction = heldAt(dir, d, a, maxHold); + if (direction === null) continue; + let v: number | null = null; + if (speed) { + while (s + 1 < speed.length && speed[s + 1].t <= a) s++; + v = heldAt(speed, s, a, maxHold); + if (v === null) continue; + } + const last = out[out.length - 1]; + // Neighbours that read the same merge, so a quiet sensor makes one sample, not hundreds. + if (last && last.dir === direction && last.speed === v) last.ms += ms; + else out.push({ dir: direction, speed: v, ms }); + } + return out; +} + +/** Default speed bands and calm limit for a unit, with how the bands are labelled. */ +export interface SpeedDefaults { + /** Where each band starts, lowest first; the last runs on with no top. */ + from: number[]; + /** Below this the air counts as calm. */ + calm: number; +} + +/** + * Speed bands for a sensor's unit. In m/s: 0–3, 3–6, 6–9, 9–12, 12+. In km/h, mph and knots the + * bands start where Beaufort forces 3, 4, 5 and 6 do (a gentle breeze up to a strong breeze), and + * in ft/s they are about the m/s bands. Calm is 0.5 m/s in each unit. Other units (or none) get + * the m/s numbers. + */ +export function speedDefaults(unit: string | undefined): SpeedDefaults { + switch ((unit ?? '').trim().toLowerCase()) { + case 'km/h': + case 'kmh': + case 'kph': + return { from: [0, 12, 20, 29, 39], calm: 1.8 }; + case 'mph': + return { from: [0, 8, 13, 19, 25], calm: 1.1 }; + case 'kn': + case 'kt': + case 'kts': + case 'knot': + case 'knots': + return { from: [0, 7, 11, 17, 22], calm: 1 }; + case 'ft/s': + return { from: [0, 10, 20, 30, 40], calm: 1.6 }; + default: + return { from: [0, 3, 6, 9, 12], calm: 0.5 }; + } +} + +/** Index of the band a speed falls in: the last that starts at or below it (the first if none). */ +export function bandIndex(speed: number, from: readonly number[]): number { + let found = 0; + for (let i = 0; i < from.length; i++) { + if (from[i] <= speed) found = i; + else break; + } + return found; +} + +/** A wind rose's numbers: shares of the time, per sector and speed band. */ +export interface Rose { + /** All the time counted, ms (calm included). */ + total: number; + /** Share of the time calm, 0–1. */ + calm: number; + /** `sectors[i][b]`: share of the time (0–1) the wind blew from sector i in speed band b. */ + sectors: number[][]; + /** Each sector's share, all bands together. */ + totals: number[]; + /** The sector with the most time, or -1 when there is none. */ + prevailing: number; + /** Time-weighted mean speed (calm included), or null without speeds. */ + meanSpeed: number | null; +} + +/** + * Bin samples into `n` sectors and the speed bands starting at `from`. A sample below `calm` (when + * it has a speed) counts towards calm, not a sector: a vane's reading means little in still air, + * and many sensors report 0° (north) then. Samples without a speed all go in band 0. + */ +export function windRose(samples: WindSample[], n: number, from: readonly number[], calm = 0): Rose { + const bands = Math.max(1, from.length); + const ms = Array.from({ length: n }, () => new Array(bands).fill(0)); + let total = 0; + let calmMs = 0; + let speedMs = 0; + let speedSum = 0; + for (const sample of samples) { + total += sample.ms; + if (sample.speed !== null) { + speedMs += sample.ms; + speedSum += sample.speed * sample.ms; + if (sample.speed < calm) { + calmMs += sample.ms; + continue; + } + } + const b = sample.speed === null ? 0 : bandIndex(sample.speed, from); + ms[sectorOf(sample.dir, n)][b] += sample.ms; + } + const share = (v: number) => (total > 0 ? v / total : 0); + const sectors = ms.map((row) => row.map(share)); + const totals = sectors.map((row) => row.reduce((a, b) => a + b, 0)); + let prevailing = -1; + totals.forEach((t, i) => { + if (t > 0 && (prevailing < 0 || t > totals[prevailing])) prevailing = i; + }); + return { + total, + calm: share(calmMs), + sectors, + totals, + prevailing, + meanSpeed: speedMs > 0 ? speedSum / speedMs : null, + }; +} + +/** The highest speed in the readings: a statistics row's max where it has one, else its value. */ +export function strongest(points: Point[]): number | null { + let best: number | null = null; + for (const p of points) { + const v = typeof p.max === 'number' && Number.isFinite(p.max) ? p.max : p.v; + if (v !== null && (best === null || v > best)) best = v; + } + return best; +} + +/** + * Round ring values (in %) for a rose whose longest petal is `max` %: steps of 1, 2, 2.5 or 5 + * times a power of ten, about three rings, the last at or just past the longest petal. + */ +export function ringSteps(max: number): number[] { + if (!(max > 0)) return []; + const raw = max / 3; + const power = 10 ** Math.floor(Math.log10(raw)); + const step = [1, 2, 2.5, 5, 10].map((m) => m * power).find((s) => s >= raw - 1e-9) ?? 10 * power; + const count = Math.max(1, Math.ceil(max / step - 1e-9)); + return Array.from({ length: count }, (_, i) => Math.round((i + 1) * step * 1000) / 1000); +} + +/* + * Geometry. Angles are radians clockwise from north (12 o'clock), as a compass reads, with the + * centre at (0, 0) and y pointing down as in SVG. These two are the same as `polar` and + * `sectorPath` in the polar area chart's `src/chart/polar.ts`; kept here until the two meet. + */ + +const round = (n: number) => Math.round(n * 100) / 100 || 0; + +/** The point at radius `r` and `angle` from the centre. */ +export function polarPoint(r: number, angle: number): [number, number] { + return [round(r * Math.sin(angle)), round(-r * Math.cos(angle))]; +} + +/** The SVG path of a ring sector from `a0` to `a1` between radii `r0` and `r1`. */ +export function petalPath(r0: number, r1: number, a0: number, a1: number): string { + if (r1 <= r0 || a1 <= a0) return ''; + const large = a1 - a0 > Math.PI ? 1 : 0; + const [ox0, oy0] = polarPoint(r1, a0); + const [ox1, oy1] = polarPoint(r1, a1); + const outer = `M${ox0} ${oy0}A${r1} ${r1} 0 ${large} 1 ${ox1} ${oy1}`; + if (r0 <= 0) return `${outer}L0 0Z`; + const [ix1, iy1] = polarPoint(r0, a1); + const [ix0, iy0] = polarPoint(r0, a0); + return `${outer}L${ix1} ${iy1}A${r0} ${r0} 0 ${large} 0 ${ix0} ${iy0}Z`; +} diff --git a/src/chart/zoom.ts b/src/chart/zoom.ts new file mode 100644 index 0000000..2e0ebb6 --- /dev/null +++ b/src/chart/zoom.ts @@ -0,0 +1,698 @@ +import { css, html, nothing, svg } from 'lit'; +import type { SVGTemplateResult, TemplateResult } from 'lit'; +import { localize, registerStrings } from '../localize'; +import type { Point, Range } from '../history/types'; +import type { ChartContext } from '../styles/index'; +import type { CardConfig } from '../types'; +import { formatDateTime } from './axes'; +import { VIEW_W } from './frame'; +import type { ChartSeries, HistoryFrame } from './frame'; +import type { Gestures } from './grid'; +import { runs } from './paths'; +import { extent, linear, padDomain } from './scale'; +import { downsample } from './downsample'; +import type { Hover } from './tooltip'; + +registerStrings('en', { + zoom_reset: 'Reset zoom', + zoom_window: 'Shown: {from} to {to}', + zoom_window_label: 'Part of the chart shown', + editor_zoom: 'Zoom and pan (pinch, Ctrl + scroll or drag)', + editor_overview: 'Overview strip below the chart', +}); + +/** The part of a chart's span on show, as fractions of it: 0–1 is all of it. */ +export interface ZoomWindow { + from: number; + to: number; +} + +export const FULL: ZoomWindow = { from: 0, to: 1 }; + +/** Drawing height of the overview strip's coordinate box. */ +export const OVERVIEW_H = 40; +/** Points per series in the overview: it is small, so a rough line is enough. */ +const OVERVIEW_POINTS = 200; +/** A mouse press moving less than this (px) is a click, not a drag. */ +const DRAG_PX = 4; +/** Two taps closer than this (ms) are a double tap. */ +const DOUBLE_TAP_MS = 300; +/** Grab distance either side of a brush edge, in px. */ +const HANDLE_PX = 10; + +export interface ZoomOptions { + /** Pinch, Ctrl + scroll and drag on the plot. */ + zoom: boolean; + /** The overview strip under the chart, with a draggable window. */ + overview: boolean; +} + +export function validateZoomOptions(raw: CardConfig): void { + for (const k of ['zoom', 'overview']) { + if (raw[k] !== undefined && typeof raw[k] !== 'boolean') throw new Error(`"${k}" must be true or false`); + } +} + +export function zoomOptions(raw: CardConfig): ZoomOptions { + return { zoom: raw.zoom === true, overview: raw.overview === true }; +} + +// ---------------------------------------------------------------- maths + +const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v)); + +/** Keep a window inside the span and no narrower than `minSpan` (a fraction). */ +export function clampWindow(w: ZoomWindow, minSpan: number): ZoomWindow { + const min = clamp(minSpan, 0, 1); + let span = clamp(w.to - w.from, min, 1); + if (!Number.isFinite(span)) span = 1; + const from = clamp(Number.isFinite(w.from) ? w.from : 0, 0, 1 - span); + return { from, to: from + span }; +} + +/** Whether a window shows less than the whole span. */ +export function isZoomed(w: ZoomWindow): boolean { + return w.from > 1e-9 || w.to < 1 - 1e-9; +} + +/** + * Zoom by `factor` (over 1 zooms in) about the point `at` of the way across the visible window, + * so the time under the pointer stays under it. + */ +export function zoomAt(w: ZoomWindow, at: number, factor: number, minSpan: number): ZoomWindow { + const span = w.to - w.from; + const pivot = w.from + clamp(at, 0, 1) * span; + const next = clamp(span / factor, minSpan, 1); + const from = pivot - clamp(at, 0, 1) * next; + return clampWindow({ from, to: from + next }, minSpan); +} + +/** Move a window by `delta` (a fraction of the whole span), stopping at either end. */ +export function panBy(w: ZoomWindow, delta: number): ZoomWindow { + const span = w.to - w.from; + const from = clamp(w.from + delta, 0, 1 - span); + return { from, to: from + span }; +} + +/** + * Two fingers: the moments under each finger when they went down stay under them as they move. + * `start` and `now` are the fingers' positions as fractions of the plot's width. + */ +export function pinchWindow( + w0: ZoomWindow, + start: [number, number], + now: [number, number], + minSpan: number, +): ZoomWindow { + const span0 = w0.to - w0.from; + const ta = w0.from + start[0] * span0; + const tb = w0.from + start[1] * span0; + const gap = now[1] - now[0]; + // Fingers crossed or together: keep the zoom, follow their middle. + if (Math.abs(gap) < 0.02 || Math.abs(start[1] - start[0]) < 0.02) { + return panBy(w0, -(((now[0] + now[1]) / 2 - (start[0] + start[1]) / 2) * span0)); + } + const span = clamp((tb - ta) / gap, minSpan, 1); + const from = ta - now[0] * span; + return clampWindow({ from, to: from + span }, minSpan); +} + +/** The window made by dragging from `a` to `b` (fractions of the visible window). */ +export function selectWindow(w: ZoomWindow, a: number, b: number, minSpan: number): ZoomWindow { + const span = w.to - w.from; + const lo = w.from + clamp(Math.min(a, b), 0, 1) * span; + const hi = w.from + clamp(Math.max(a, b), 0, 1) * span; + if (hi - lo >= minSpan) return { from: lo, to: hi }; + const mid = (lo + hi) / 2; + return clampWindow({ from: mid - minSpan / 2, to: mid + minSpan / 2 }, minSpan); +} + +/** The times a window covers. */ +export function windowRange(range: Range, w: ZoomWindow): Range { + const span = range.end - range.start; + return { start: range.start + w.from * span, end: range.start + w.to * span }; +} + +/** + * The points a zoomed chart needs: those inside `view`, and one either side so its lines run on + * to the edges. Points are sorted by time. + */ +export function visiblePoints(points: Point[], view: Range): Point[] { + if (points.length === 0) return points; + let lo = 0; + let hi = points.length; + while (lo < hi) { + const mid = (lo + hi) >> 1; + // A grouped point covers `t` to `end`: it is visible while any of it is. + if ((points[mid].end ?? points[mid].t) < view.start) lo = mid + 1; + else hi = mid; + } + const first = Math.max(0, lo - 1); + let last = lo; + while (last < points.length && points[last].t <= view.end) last++; + const out = points.slice(first, Math.min(points.length, last + 1)); + return out.length === points.length ? points : out; +} + +// ---------------------------------------------------------------- state + +type Gesture = + | { kind: 'pan' | 'select'; x0: number; w0: ZoomWindow; moved: boolean; px: number } + | { kind: 'pinch'; w0: ZoomWindow; start: [number, number] } + | { kind: 'left' | 'right' | 'move' | 'new'; f0: number; w0: ZoomWindow; moved: boolean; px: number }; + +interface ZoomState { + key: string; + window: ZoomWindow; + /** Pointers down on the plot: id → x as a fraction of the plot's width. */ + pointers: Map; + gesture?: Gesture; + /** A drag-to-zoom in progress, as fractions of the plot. */ + select?: [number, number]; + lastTap?: { at: number; x: number }; +} + +const states = new WeakMap(); + +function stateOf(host: HTMLElement, key: string): ZoomState { + let s = states.get(host); + if (!s || s.key !== key) { + // A different span (stepped back, or the period changed): start from all of it. + s = { key, window: FULL, pointers: new Map() }; + states.set(host, s); + } + return s; +} + +/** Forget a card's zoom (it leaves the page). */ +export function stopZoom(host: HTMLElement): void { + states.delete(host); +} + +/** Whether a pan, pinch or drag is under way: the tooltip steps aside meanwhile. */ +export function zoomBusy(host: HTMLElement): boolean { + const g = states.get(host)?.gesture; + return !!g && (g.kind === 'pinch' || ('moved' in g && g.moved)); +} + +/** What a chart shows once zoomed: the window, the times it covers, and how to change it. */ +export interface ZoomView { + options: ZoomOptions; + window: ZoomWindow; + zoomed: boolean; + /** The times on show (all of `frame.range` when not zoomed). */ + range: Range; + /** Smallest window, as a fraction of the span. */ + minSpan: number; + /** The window now, which may have moved since this view was made (events between draws). */ + current(): ZoomWindow; + set(w: ZoomWindow): void; + reset(): void; +} + +/** Shortest span a chart can zoom to, in ms: two groups when grouped, else a few minutes. */ +export function minZoomMs(frame: Pick): number { + const unit = { hour: 3_600_000, day: 86_400_000, week: 604_800_000, month: 2_592_000_000 }; + return frame.group ? 2 * unit[frame.group.unit] : 5 * 60_000; +} + +/** + * The card's zoom over `frame`. It lives on the card, not in its config, and starts again from + * the whole span when the span itself changes (previous / next, or a new period). + */ +export function zoomView(ctx: ChartContext, frame: HistoryFrame): ZoomView { + const options = zoomOptions(ctx.raw); + const total = frame.range.end - frame.range.start; + const minSpan = total > 0 ? Math.min(1, minZoomMs(frame) / total) : 1; + const key = `${JSON.stringify(frame.period)}|${frame.back}|${frame.group?.unit ?? ''}|${frame.source}`; + const state = stateOf(ctx.host, key); + if (!options.zoom && !options.overview) state.window = FULL; + const window = clampWindow(state.window, minSpan); + const set = (w: ZoomWindow) => { + const next = clampWindow(w, minSpan); + if (next.from === state.window.from && next.to === state.window.to) return; + state.window = next; + ctx.requestUpdate(); + }; + return { + options, + window, + zoomed: isZoomed(window), + range: windowRange(frame.range, window), + minSpan, + current: () => clampWindow(state.window, minSpan), + set, + reset: () => set(FULL), + }; +} + +// ---------------------------------------------------------------- the plot's gestures + +const fracOf = (ev: PointerEvent | WheelEvent | MouseEvent) => { + const rect = (ev.currentTarget as HTMLElement).getBoundingClientRect(); + return { + f: rect.width > 0 ? clamp((ev.clientX - rect.left) / rect.width, 0, 1) : 0, + width: rect.width, + }; +}; + +/** Keep getting a pointer's moves once it leaves the element (not for synthetic pointers). */ +function capture(ev: PointerEvent): void { + try { + (ev.currentTarget as HTMLElement).setPointerCapture?.(ev.pointerId); + } catch { + // No such active pointer: nothing to capture. + } +} + +/** A hover `leave` that hides the tooltip at once (a mouse leaving), whatever the pointer was. */ +const hideHover = (hover: Hover | undefined) => hover?.leave({ pointerType: 'mouse' } as PointerEvent); + +/** + * Pointer, wheel and double-click handlers for the plot, wrapping the tooltip's: a mouse drag + * selects a span to zoom into (or pans once zoomed), two fingers pinch and pan, Ctrl + scroll + * (or a trackpad pinch) zooms about the pointer, sideways scroll pans, and a double click or + * double tap goes back to the whole span. One finger still scrubs the tooltip, and up and down + * still scroll the page. + */ +export function zoomGestures(ctx: ChartContext, view: ZoomView, hover?: Hover): Gestures { + const state = states.get(ctx.host)!; + const end = () => { + state.gesture = undefined; + if (state.select) { + state.select = undefined; + ctx.requestUpdate(); + } + }; + return { + down(ev) { + const { f, width } = fracOf(ev); + state.pointers.set(ev.pointerId, f); + if (state.pointers.size === 2) { + const [a, b] = [...state.pointers.values()]; + state.gesture = { kind: 'pinch', w0: view.current(), start: a <= b ? [a, b] : [b, a] }; + state.select = undefined; + hideHover(hover); + return; + } + if (state.pointers.size > 2) return; + if (ev.pointerType === 'mouse' && ev.button === 0) { + const w0 = view.current(); + state.gesture = { kind: isZoomed(w0) ? 'pan' : 'select', x0: f, w0, moved: false, px: width }; + capture(ev); + } + hover?.move(ev); + }, + move(ev) { + if (!state.pointers.has(ev.pointerId)) { + hover?.move(ev); + return; + } + const { f } = fracOf(ev); + state.pointers.set(ev.pointerId, f); + const g = state.gesture; + if (g?.kind === 'pinch') { + if (state.pointers.size < 2) return; + const [a, b] = [...state.pointers.values()]; + view.set(pinchWindow(g.w0, g.start, a <= b ? [a, b] : [b, a], view.minSpan)); + return; + } + if (g && (g.kind === 'pan' || g.kind === 'select')) { + if (!g.moved && Math.abs(f - g.x0) * g.px < DRAG_PX) { + hover?.move(ev); + return; + } + if (!g.moved) hideHover(hover); + g.moved = true; + if (g.kind === 'pan') { + view.set(panBy(g.w0, -(f - g.x0) * (g.w0.to - g.w0.from))); + } else { + state.select = [g.x0, f]; + ctx.requestUpdate(); + } + return; + } + hover?.move(ev); + }, + up(ev) { + const { f, width } = fracOf(ev); + const g = state.gesture; + state.pointers.delete(ev.pointerId); + if (g?.kind === 'pinch') { + // The second finger lifting ends the pinch; the first doesn't then start scrubbing. + if (state.pointers.size === 0) state.gesture = undefined; + return; + } + if (g?.kind === 'select' && g.moved) view.set(selectWindow(g.w0, g.x0, f, view.minSpan)); + const tapped = !g || !('moved' in g) || !g.moved; + end(); + if (ev.pointerType !== 'mouse' && tapped) { + const now = ev.timeStamp || Date.now(); + const last = state.lastTap; + if (last && now - last.at < DOUBLE_TAP_MS && Math.abs(f - last.x) * width < 30) { + state.lastTap = undefined; + view.reset(); + } else { + state.lastTap = { at: now, x: f }; + } + } + }, + cancel(ev) { + state.pointers.delete(ev.pointerId); + // A stray third finger going doesn't end a pinch. + if (state.gesture?.kind === 'pinch' && state.pointers.size >= 2) return; + end(); + hover?.leave(ev); + }, + leave(ev) { + // Mouse capture keeps a drag going outside the plot; it ends on release. + if (state.gesture && ev.pointerType === 'mouse') return; + state.pointers.delete(ev.pointerId); + hover?.leave(ev); + }, + wheel(ev) { + const { f } = fracOf(ev); + if (ev.ctrlKey || ev.metaKey) { + // Ctrl + scroll, and a trackpad pinch (which browsers send as Ctrl + scroll). + ev.preventDefault(); + const step = ev.deltaMode === 1 ? ev.deltaY * 16 : ev.deltaY; + view.set(zoomAt(view.current(), f, Math.exp(-clamp(step, -100, 100) / 200), view.minSpan)); + return; + } + const sideways = ev.shiftKey && ev.deltaX === 0 ? ev.deltaY : ev.deltaX; + const w = view.current(); + if (isZoomed(w) && Math.abs(sideways) > Math.abs(ev.shiftKey ? 0 : ev.deltaY)) { + ev.preventDefault(); + const { width } = fracOf(ev); + view.set(panBy(w, (sideways / Math.max(width, 1)) * (w.to - w.from))); + } + }, + dblclick() { + view.reset(); + }, + }; +} + +/** The drag-to-zoom band and the reset button, over the plot. */ +export function zoomLayer(ctx: ChartContext, view: ZoomView): TemplateResult | typeof nothing { + const state = states.get(ctx.host); + const sel = state?.select; + const band = sel + ? html`
` + : nothing; + const reset = view.zoomed + ? html`` + : nothing; + if (band === nothing && reset === nothing) return nothing; + return html`${band}${reset}`; +} + +const ZOOM_OUT = + 'M15.5,14H14.71L14.43,13.73C15.41,12.59 16,11.11 16,9.5A6.5,6.5 0 0,0 9.5,3A6.5,6.5 0 0,0 3,9.5A6.5,6.5 0 0,0 9.5,16C11.11,16 12.59,15.41 13.73,14.43L14,14.71V15.5L19,20.5L20.5,19L15.5,14M9.5,14C7,14 5,12 5,9.5C5,7 7,5 9.5,5C12,5 14,7 14,9.5C14,12 12,14 9.5,14M7,9H12V10H7V9Z'; + +// ---------------------------------------------------------------- overview strip + +/** Each series as a thin line across the whole span, for the overview strip. */ +export function overviewLines(series: readonly ChartSeries[], range: Range): SVGTemplateResult { + const span = extent(series.map((s) => s.points)); + if (!span) return svg``; + const [lo, hi] = padDomain(span); + const x = linear([range.start, range.end], [0, VIEW_W]); + const y = linear([lo, hi], [OVERVIEW_H - 2, 2]); + return svg`${series.map((s) => { + const d = runs(downsample(s.points, OVERVIEW_POINTS)) + .map((run) => + run.length === 1 + ? `M${x(run[0].t).toFixed(1)},${y(run[0].v as number).toFixed(1)}h0.5` + : `M${run.map((p) => `${x(p.t).toFixed(1)},${y(p.v as number).toFixed(1)}`).join('L')}`, + ) + .join(''); + return svg``; + })}`; +} + +/** + * The overview: the whole span drawn small, with the zoomed window over it. Drag the window to + * move it, its edges to resize it, or across the strip to choose a new one; tap the strip to + * centre the window there; double-tap to show everything again. Arrow keys move the window, + * up and down (or + and −) zoom, and Escape resets. + */ +export function overviewStrip( + ctx: ChartContext, + view: ZoomView, + range: Range, + content: SVGTemplateResult, +): TemplateResult { + const state = states.get(ctx.host)!; + const w = view.window; + const pos = (ev: PointerEvent) => fracOf(ev); + const down = (ev: PointerEvent) => { + if (ev.button > 0) return; + ev.stopPropagation(); + const { f, width } = pos(ev); + const w = view.current(); + const tol = HANDLE_PX / Math.max(width, 1); + let kind: 'left' | 'right' | 'move' | 'new' = 'new'; + if (isZoomed(w)) { + if (Math.abs(f - w.from) <= tol) kind = 'left'; + else if (Math.abs(f - w.to) <= tol) kind = 'right'; + else if (f > w.from && f < w.to) kind = 'move'; + } + state.gesture = { kind, f0: f, w0: w, moved: false, px: width }; + capture(ev); + }; + const move = (ev: PointerEvent) => { + const g = state.gesture; + if (!g || !('f0' in g)) return; + const { f } = pos(ev); + if (!g.moved && Math.abs(f - g.f0) * g.px < DRAG_PX) return; + g.moved = true; + const { w0 } = g; + if (g.kind === 'move') view.set(panBy(w0, f - g.f0)); + else if (g.kind === 'left') view.set({ from: Math.min(f, w0.to - view.minSpan), to: w0.to }); + else if (g.kind === 'right') view.set({ from: w0.from, to: Math.max(f, w0.from + view.minSpan) }); + else view.set(selectWindow(FULL, g.f0, f, view.minSpan)); + }; + const up = () => { + const g = state.gesture; + if (!g || !('f0' in g)) return; + state.gesture = undefined; + if (!g.moved && g.kind === 'new' && isZoomed(g.w0)) { + // A tap beside the window centres it there. + const span = g.w0.to - g.w0.from; + view.set({ from: g.f0 - span / 2, to: g.f0 + span / 2 }); + } + }; + const key = (ev: KeyboardEvent) => { + const w = view.current(); + const span = w.to - w.from; + const centre = 0.5; + const actions: Record ZoomWindow> = { + ArrowLeft: () => panBy(w, -span / 4), + ArrowRight: () => panBy(w, span / 4), + ArrowUp: () => zoomAt(w, centre, 1.5, view.minSpan), + ArrowDown: () => zoomAt(w, centre, 1 / 1.5, view.minSpan), + '+': () => zoomAt(w, centre, 1.5, view.minSpan), + '=': () => zoomAt(w, centre, 1.5, view.minSpan), + '-': () => zoomAt(w, centre, 1 / 1.5, view.minSpan), + Home: () => panBy(w, -1), + End: () => panBy(w, 1), + Escape: () => FULL, + }; + const act = actions[ev.key]; + if (!act) return; + ev.preventDefault(); + ev.stopPropagation(); + view.set(act()); + }; + const shown = windowRange(range, w); + const valuetext = localize(ctx.hass, 'zoom_window', { + from: formatDateTime(shown.start, ctx.hass), + to: formatDateTime(shown.end, ctx.hass), + }); + const pct = (n: number) => `${Math.round(n * 10000) / 100}%`; + return html`
(state.gesture = undefined)} + @dblclick=${(ev: Event) => { + ev.stopPropagation(); + view.reset(); + }} + > + +
+
+
+ +
+
`; +} + +export const zoomStyles = css` + .plot-area.zoomable { + touch-action: pan-y; + } + .plot-area.zoomable.zoomed { + cursor: grab; + } + .plot-area.zoomable.zoomed:active { + cursor: grabbing; + } + .zoom-select { + position: absolute; + top: 0; + bottom: 0; + background: color-mix(in srgb, var(--primary-color) 15%, transparent); + border-left: 1px solid var(--primary-color); + border-right: 1px solid var(--primary-color); + pointer-events: none; + z-index: 1; + } + .zoom-reset { + position: absolute; + top: 2px; + right: 2px; + /* Under the tooltip: the box only covers it while the pointer is away from the corner. */ + z-index: 1; + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px 2px 6px; + font: inherit; + font-size: 11px; + line-height: 18px; + color: var(--primary-color); + background: color-mix(in srgb, var(--card-background-color, #fff) 85%, transparent); + border: 1px solid color-mix(in srgb, var(--primary-color) 40%, transparent); + border-radius: 12px; + cursor: pointer; + } + .zoom-reset svg { + width: 14px; + height: 14px; + fill: currentColor; + } + .zoom-reset:hover { + background: color-mix(in srgb, var(--primary-color) 12%, var(--card-background-color, #fff)); + } + .zoom-reset:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: 1px; + } + .frame.compact .zoom-reset span { + display: none; + } + .frame.compact .zoom-reset { + padding: 2px; + } + .overview { + position: relative; + grid-column: 2; + grid-row: 3; + height: 32px; + margin-top: 6px; + border-radius: 4px; + background: color-mix(in srgb, var(--primary-text-color) 4%, transparent); + touch-action: none; + cursor: crosshair; + user-select: none; + -webkit-user-select: none; + } + .frame.compact .overview { + height: 22px; + margin-top: 4px; + } + .overview-lines { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + } + .overview-lines path { + stroke-width: 1; + vector-effect: non-scaling-stroke; + opacity: 0.8; + } + .overview-shade { + position: absolute; + top: 0; + bottom: 0; + background: color-mix(in srgb, var(--card-background-color, #fff) 60%, transparent); + pointer-events: none; + } + .overview-window { + position: absolute; + top: 0; + bottom: 0; + box-sizing: border-box; + border: 1px solid color-mix(in srgb, var(--primary-color) 70%, transparent); + border-radius: 4px; + background: color-mix(in srgb, var(--primary-color) 8%, transparent); + cursor: grab; + } + .overview:not(.zoomed) .overview-window { + border-color: transparent; + background: none; + cursor: crosshair; + } + .overview-window:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: 1px; + } + .overview-handle { + position: absolute; + top: 50%; + width: 4px; + height: 14px; + border-radius: 2px; + background: var(--primary-color); + transform: translate(-50%, -50%); + cursor: ew-resize; + } + .overview-handle.left { + left: 0; + } + .overview-handle.right { + left: 100%; + } + .overview:not(.zoomed) .overview-handle { + display: none; + } + .plot-wrap.has-overview { + grid-template-rows: minmax(0, 1fr) auto auto; + height: 220px; + } + .frame.compact .plot-wrap.has-overview { + height: 140px; + } +`; diff --git a/src/chartdeck-card.ts b/src/chartdeck-card.ts index aa0128e..46e4fd3 100644 --- a/src/chartdeck-card.ts +++ b/src/chartdeck-card.ts @@ -162,7 +162,8 @@ export class ChartdeckCard extends LitElement { getGridOptions(): { columns: number; rows: 'auto'; min_columns: number; min_rows: number } { const n = this._count(); // Charts need the room: a time axis squeezed into a quarter section is unreadable. - if (isChart(this._style)) return { columns: 12, rows: 'auto', min_columns: 6, min_rows: 2 }; + if (isChart(this._style)) + return { columns: 12, min_columns: 6, min_rows: 2, ...this._style.gridOptions, rows: 'auto' }; const rows = this._style.layout === 'rows'; const columns = rows || n <= 1 ? 6 : 12; // Row styles need half a section: at 3 columns the icon, cells and value can't fit. diff --git a/src/config.ts b/src/config.ts index 6e33dad..5442177 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,4 +1,5 @@ import { normalizeRule } from './fill'; +import { DEFAULT_ANGLES, parseGaugeOptions } from './gauge'; import { STYLE_NAMES } from './types'; import type { ActionConfig, @@ -122,6 +123,9 @@ export function mergeEntity(raw: string | EntityConfig, card: EntityOptions, ind if (target !== undefined) merged.target = target; if (decimals !== undefined) merged.decimals = decimals; if (!own.attribute) delete merged.attribute; + // Gauge options are the card's alone: every gauge on a card is drawn the same way. Only the + // gauge styles read them: other styles (rings, pie…) give center and the angles their own meaning. + if (DEFAULT_ANGLES[(card as CardConfig).style ?? 'bar']) merged.gauge = parseGaugeOptions(card as CardConfig); return merged; } diff --git a/src/editor/annotations-editor.ts b/src/editor/annotations-editor.ts new file mode 100644 index 0000000..a2d4a12 --- /dev/null +++ b/src/editor/annotations-editor.ts @@ -0,0 +1,240 @@ +import { html } from 'lit'; +import type { TemplateResult } from 'lit'; +import { ANNOTATION_TYPES, POINT_AT, timeList } from '../chart/annotations'; +import type { Annotation, AnnotationType } from '../chart/annotations'; +import { languageOf, localize, registerStrings } from '../localize'; +import type { ChartEditorContext } from '../styles/index'; +import type { CardConfig, HomeAssistant } from '../types'; + +registerStrings('en', { + editor_annotations: 'Annotations', + editor_annotation_type: 'Kind', + editor_annotation_type_line: 'Line at a value', + editor_annotation_type_band: 'Shaded band of values', + editor_annotation_type_time: 'Marks at set times', + editor_annotation_type_state: 'Marks when an entity changes', + editor_annotation_type_point: 'Label on a reading', + editor_annotation_add_line: 'Line', + editor_annotation_add_band: 'Band', + editor_annotation_add_time: 'Times', + editor_annotation_add_state: 'State changes', + editor_annotation_add_point: 'Point label', + editor_annotation_add: 'Add an annotation:', + editor_annotation_value: 'Value (a number or an entity)', + editor_annotation_from: 'From (a number or an entity; blank for the bottom)', + editor_annotation_to: 'To (a number or an entity; blank for the top)', + editor_annotation_times: 'Times: 07:00 for every day, or 2026-10-28 07:00 (commas between)', + editor_annotation_entity: 'Entity', + editor_annotation_series: 'Series (blank for the first)', + editor_annotation_state: 'State (blank for every change)', + editor_annotation_shade: 'Shade while in this state', + editor_annotation_at: 'Reading', + editor_annotation_at_max: 'Highest', + editor_annotation_at_min: 'Lowest', + editor_annotation_at_last: 'Latest', + editor_annotation_at_first: 'First', + editor_annotation_label: 'Label', + editor_annotation_color: 'Colour', + editor_annotation_dash: 'Dashed', + editor_annotation_remove: 'Remove annotation', + editor_zones: 'Shade the severity bands behind the chart', +}); + +const DELETE_ICON = 'M19,4H15.5L14.5,3H9.5L8.5,4H5V6H19M6,19A2,2 0 0,0 8,21H16A2,2 0 0,0 18,19V7H6V19Z'; + +/** The fields each kind of annotation has, after its kind; label and colour come last. */ +const FIELDS: Record = { + line: ['value', 'dash'], + band: ['from', 'to'], + time: ['times'], + state: ['entity', 'state', 'shade'], + point: ['at', 'series'], +}; + +/** A field's default: setting it back removes the key. */ +const DEFAULTS: Record = { dash: true, shade: false, at: 'max' }; + +function select(hass: HomeAssistant | undefined, prefix: string, values: readonly string[]) { + return { + select: { + mode: 'dropdown', + options: values.map((value) => ({ value, label: localize(hass, `editor_annotation_${prefix}_${value}`) })), + }, + }; +} + +const schemas = new Map(); + +/** One annotation's form: its kind, its own fields, then label and colour. */ +export function annotationSchema(type: AnnotationType, hass: HomeAssistant | undefined, entities: string[]) { + const k = `${type}|${languageOf(hass)}|${entities.join(',')}`; + let schema = schemas.get(k); + if (schema) return schema; + const selectors: Record = { + value: { text: {} }, + from: { text: {} }, + to: { text: {} }, + times: { text: {} }, + entity: { entity: {} }, + series: { entity: { include_entities: entities } }, + state: { text: {} }, + shade: { boolean: {} }, + dash: { boolean: {} }, + at: select(hass, 'at', POINT_AT), + }; + schema = [ + { name: 'type', selector: select(hass, 'type', ANNOTATION_TYPES) }, + ...FIELDS[type].map((name) => ({ name, selector: selectors[name] })), + { + type: 'grid', + name: '', + schema: [ + { name: 'label', selector: { text: {} } }, + { name: 'color', selector: { text: {} } }, + ], + }, + ]; + schemas.set(k, schema); + return schema; +} + +/** An annotation as form data: values as text, times joined, defaults filled in. */ +export function annotationData(a: Annotation): Record { + const data: Record = { type: a.type }; + for (const name of [...FIELDS[a.type], 'label', 'color']) { + const key = name === 'series' ? 'entity' : name; + let v = (a as unknown as Record)[key]; + if (name === 'times') v = timeList(a.times).join(', '); + else if ((name === 'value' || name === 'from' || name === 'to') && v !== undefined) v = String(v); + data[name] = v ?? DEFAULTS[name]; + } + return data; +} + +/** + * Form data back to an annotation. Blank fields and defaults are left out, numbers typed as text + * become numbers, and times become a list. A new kind keeps only the label and colour. + */ +export function annotationFromData(data: Record, before: Annotation): Annotation { + const type = (data.type as AnnotationType) ?? before.type; + const out: Record = { type }; + const names = type === before.type ? [...FIELDS[type], 'label', 'color'] : ['label', 'color']; + for (const name of names) { + let v = data[name]; + if (typeof v === 'string') v = v.trim(); + if (v === undefined || v === '' || v === DEFAULTS[name]) continue; + if ((name === 'value' || name === 'from' || name === 'to') && Number.isFinite(Number(v))) v = Number(v); + if (name === 'times') v = timeList(v as string); + out[name === 'series' ? 'entity' : name] = v; + } + return out as unknown as Annotation; +} + +/** The card config with its annotation list replaced (and the key gone once it is empty). */ +export function withAnnotations(config: CardConfig, list: Annotation[]): CardConfig { + const next = { ...config }; + if (list.length) next.annotations = list; + else delete next.annotations; + return next; +} + +/** Starting values for a new annotation of each kind. */ +function fresh(type: AnnotationType, config: CardConfig): Annotation { + if (type === 'line') return { type, value: 0 }; + if (type === 'band') return { type, from: 0, to: 1 }; + if (type === 'time') return { type, times: ['07:00'] }; + if (type === 'state') { + const first = (config.entities ?? [])[0]; + return { type, entity: typeof first === 'string' ? first : (first?.entity ?? 'sensor.example'), state: 'on' }; + } + return { type, at: 'max' }; +} + +/** + * The Annotations part of a history chart's editor: one form per annotation, each with a remove + * button, then a row of buttons to add one of each kind. + */ +export function annotationsEditor(ctx: ChartEditorContext): TemplateResult { + const list = Array.isArray(ctx.config.annotations) ? (ctx.config.annotations as Annotation[]) : []; + const entities = (ctx.config.entities ?? []).map((e) => (typeof e === 'string' ? e : e.entity)); + const t = (k: string) => localize(ctx.hass, k); + const replace = (i: number, a?: Annotation) => { + const next = [...list]; + if (a) next[i] = a; + else next.splice(i, 1); + ctx.commit(withAnnotations(ctx.config, next)); + }; + return html` +
+
${t('editor_annotations')}
+ ${list.map( + (a, i) => + html`
+ t(`editor_annotation_${s.name}`)} + @value-changed=${(ev: CustomEvent) => { + ev.stopPropagation(); + replace(i, annotationFromData((ev.detail?.value ?? {}) as Record, a)); + }} + > + replace(i)} + > +
`, + )} +
+ ${t('editor_annotation_add')} + ${ANNOTATION_TYPES.map( + (type) => + html``, + )} +
+
`; +} diff --git a/src/editor/chart-editors.ts b/src/editor/chart-editors.ts index 8d32245..c18a968 100644 --- a/src/editor/chart-editors.ts +++ b/src/editor/chart-editors.ts @@ -1,8 +1,11 @@ import { html } from 'lit'; import type { TemplateResult } from 'lit'; -import { AXIS_LABELS, DEFAULT_AXES } from '../chart/axes'; +import { AXIS_LABELS, DEFAULT_AXES, Y_SCALES } from '../chart/axes'; import { HEADER_PLACES } from '../chart/layout'; import { CURVES } from '../chart/paths'; +import { LABEL_POSITIONS, LABEL_SHOW } from '../chart/data-labels'; +import { EXTREMES_FIELDS, extremesSchema } from '../chart/extremes'; +import { MAX_MULTIPLES_COLUMNS, MULTIPLES_SCALES } from '../chart/multiples'; import { DEFAULT_LINE_WIDTH } from '../chart/series-style'; import { SOURCES } from '../chart/frame'; import { AGGREGATES, GROUP_UNITS } from '../history/group'; @@ -10,6 +13,7 @@ import { CALENDAR_SPANS, PERIOD_UNITS, PERIOD_UNTIL } from '../history/period'; import { languageOf, localize, registerStrings } from '../localize'; import type { ChartEditorContext } from '../styles/index'; import type { CardConfig, HomeAssistant } from '../types'; +import { annotationsEditor } from './annotations-editor'; import { LINE_WIDTH_SELECTOR, applyFormData, formData } from './schemas'; import type { FieldBinding } from './schemas'; @@ -106,6 +110,26 @@ export function historySchema(hass?: HomeAssistant, config?: CardConfig) { ]; } +/** History fields for charts that only read a rolling span (how far back, and from where). */ +export const ROLLING_HISTORY_FIELDS: FieldBinding[] = HISTORY_FIELDS.filter((f) => + ['period_last', 'period_unit', 'source'].includes(f.name), +); + +/** `historySchema` for those charts: no calendar spans, offset, buttons or grouping. */ +export function rollingHistorySchema(hass?: HomeAssistant) { + return [ + { + type: 'grid', + name: '', + schema: [ + { name: 'period_last', selector: { number: { min: 1, mode: 'box' } } }, + { name: 'period_unit', selector: choice(hass, 'period_unit', PERIOD_UNITS) }, + ], + }, + { name: 'source', selector: choice(hass, 'source', SOURCES) }, + ]; +} + registerStrings('en', { editor_x_axis: 'Time labels', editor_y_axis: 'Value labels', @@ -116,6 +140,9 @@ registerStrings('en', { editor_grid_vertical: 'Lines down', editor_y_min: 'Lowest value shown', editor_y_max: 'Highest value shown', + editor_y_scale: 'Value scale', + editor_y_scale_linear: 'Even steps', + editor_y_scale_log: 'Logarithmic (powers of ten)', editor_tooltip: 'Show values on hover or touch', editor_line_width: 'Line width (px)', editor_dots: 'Dot at each reading', @@ -183,6 +210,116 @@ export function axesSchema(hass?: HomeAssistant) { ]; } +/** Zoom gestures and the overview strip, shared by every history chart. */ +export const ZOOM_FIELDS: FieldBinding[] = [ + { name: 'zoom', path: ['zoom'], default: false }, + { name: 'overview', path: ['overview'], default: false }, +]; + +export const ZOOM_SCHEMA = [ + { + type: 'grid', + name: '', + schema: [ + { name: 'zoom', selector: { boolean: {} } }, + { name: 'overview', selector: { boolean: {} } }, + ], + }, +]; +/** Shading the severity bands behind a history chart; the annotation list has its own editor. */ +export const ANNOTATION_FIELDS: FieldBinding[] = [{ name: 'zones', path: ['zones'], default: false }]; +export const ANNOTATION_SCHEMA = [{ name: 'zones', selector: { boolean: {} } }]; +registerStrings('en', { + editor_labels_show: 'Which readings', + editor_labels_show_all: 'All (as many as fit)', + editor_labels_show_last: 'The latest', + editor_labels_show_minmax: 'Highest and lowest', + editor_labels_show_ends: 'First and latest', + editor_labels_position: 'Label position', + editor_labels_position_auto: 'Above peaks, below dips', + editor_labels_position_above: 'Above', + editor_labels_position_below: 'Below', + editor_labels_position_center: 'On the point', + editor_labels_overlap: 'Let labels overlap', +}); + +/** Value labels on a chart's readings: on/off, then which, where, and whether they may overlap. */ +export const LABEL_FIELDS: FieldBinding[] = [ + { name: 'labels', path: ['labels'], default: false }, + { name: 'labels_show', path: ['labels_show'], default: 'all' }, + { name: 'labels_position', path: ['labels_position'], default: 'auto' }, + { name: 'labels_overlap', path: ['labels_overlap'], default: false }, +]; + +/** The label fields; the choices show once labels are on (cache under `labelsVariant`). */ +export function labelSchema(hass: HomeAssistant | undefined, config: CardConfig | undefined) { + return [ + { name: 'labels', selector: { boolean: {} } }, + ...(config?.labels === true + ? [ + { + type: 'grid', + name: '', + schema: [ + { name: 'labels_show', selector: choice(hass, 'labels_show', LABEL_SHOW) }, + { name: 'labels_position', selector: choice(hass, 'labels_position', LABEL_POSITIONS) }, + ], + }, + { name: 'labels_overlap', selector: { boolean: {} } }, + ] + : []), + ]; +} + +export const labelsVariant = (config: CardConfig | undefined) => (config?.labels === true ? 'labels' : ''); +registerStrings('en', { + editor_sync: 'Sync group (charts with the same name share the pointer and period steps)', +}); + +/** A group name shared with other charts on the dashboard. */ +export const SYNC_FIELDS: FieldBinding[] = [{ name: 'sync', path: ['sync'] }]; +export const SYNC_SCHEMA = [{ name: 'sync', selector: { text: {} } }]; + +registerStrings('en', { + editor_multiples: 'A small chart for each entity', + editor_multiples_scale: 'Value scale', + editor_multiples_scale_auto: 'The same when the units match', + editor_multiples_scale_shared: 'The same for every chart', + editor_multiples_scale_independent: 'Each chart its own', + editor_multiples_columns: 'Columns (empty fits the card)', +}); + +/** Small multiples: on/off, then how the charts share a scale and how many go across. */ +export const MULTIPLES_FIELDS: FieldBinding[] = [ + { name: 'multiples', path: ['multiples'], default: false }, + { name: 'multiples_scale', path: ['multiples_scale'], default: 'auto' }, + { name: 'multiples_columns', path: ['multiples_columns'] }, +]; + +export const multiplesVariant = (config: CardConfig | undefined) => (config?.multiples === true ? 'multiples' : ''); + +/** The small-multiples fields; scale and columns show once it is on (cache under `multiplesVariant`). */ +export function multiplesSchema(hass: HomeAssistant | undefined, config: CardConfig | undefined) { + return [ + { name: 'multiples', selector: { boolean: {} } }, + ...(config?.multiples === true + ? [ + { + type: 'grid', + name: '', + schema: [ + { name: 'multiples_scale', selector: choice(hass, 'multiples_scale', MULTIPLES_SCALES) }, + { + name: 'multiples_columns', + selector: { number: { min: 1, max: MAX_MULTIPLES_COLUMNS, mode: 'box' } }, + }, + ], + }, + ] + : []), + ]; +} + const cache = new Map(); /** Build a schema once per language (and per style), so ha-form isn't handed a new one each render. */ @@ -202,13 +339,15 @@ export function chartForm( section: string, schema: unknown[], fields: FieldBinding[], + /** Label keys for fields whose name means something else on another chart (`editor_` otherwise). */ + labels: Record = {}, ): TemplateResult { return html` localize(ctx.hass, `editor_${s.name}`)} + .computeLabel=${(s: { name: string }) => localize(ctx.hass, labels[s.name] ?? `editor_${s.name}`)} @value-changed=${(ev: CustomEvent) => { ev.stopPropagation(); const next = applyFormData(ctx.config, fields, (ev.detail?.value ?? {}) as Record); @@ -221,35 +360,52 @@ const LINE_FIELDS: FieldBinding[] = [ ...HISTORY_FIELDS, { name: 'curve', path: ['curve'], default: 'smooth' }, { name: 'color_by', path: ['color_by'], default: 'series' }, - { name: 'labels', path: ['labels'], default: false }, + ...LABEL_FIELDS, { name: 'line_width', path: ['line_width'], default: DEFAULT_LINE_WIDTH }, { name: 'dots', path: ['dots'], default: false }, ...AXES_FIELDS, + { name: 'y_scale', path: ['y_scale'], default: DEFAULT_AXES.scale }, + ...ZOOM_FIELDS, + ...ANNOTATION_FIELDS, + ...EXTREMES_FIELDS, + ...SYNC_FIELDS, ...LAYOUT_FIELDS, + ...MULTIPLES_FIELDS, ]; export function lineEditor(ctx: ChartEditorContext): TemplateResult { - const schema = cachedSchema(`line|${historyVariant(ctx.config)}`, ctx.hass, () => [ - ...historySchema(ctx.hass, ctx.config), - { - type: 'grid', - name: '', - schema: [ - { name: 'curve', selector: choice(ctx.hass, 'curve', CURVES) }, - { name: 'color_by', selector: choice(ctx.hass, 'color_by', ['series', 'bands']) }, - ], - }, - { - type: 'grid', - name: '', - schema: [ - { name: 'line_width', selector: LINE_WIDTH_SELECTOR }, - { name: 'dots', selector: { boolean: {} } }, - ], - }, - { name: 'labels', selector: { boolean: {} } }, - ...axesSchema(ctx.hass), - ...layoutSchema(ctx.hass), - ]); - return chartForm(ctx, 'chart', schema, LINE_FIELDS); + const schema = cachedSchema( + `line|${historyVariant(ctx.config)}|${labelsVariant(ctx.config)}|${multiplesVariant(ctx.config)}`, + ctx.hass, + () => [ + ...historySchema(ctx.hass, ctx.config), + { + type: 'grid', + name: '', + schema: [ + { name: 'curve', selector: choice(ctx.hass, 'curve', CURVES) }, + { name: 'color_by', selector: choice(ctx.hass, 'color_by', ['series', 'bands']) }, + ], + }, + { + type: 'grid', + name: '', + schema: [ + { name: 'line_width', selector: LINE_WIDTH_SELECTOR }, + { name: 'dots', selector: { boolean: {} } }, + ], + }, + ...labelSchema(ctx.hass, ctx.config), + ...axesSchema(ctx.hass), + // Only the line chart draws on a log scale: columns and stacked areas stand on zero. + { name: 'y_scale', selector: choice(ctx.hass, 'y_scale', Y_SCALES) }, + ...ZOOM_SCHEMA, + ...ANNOTATION_SCHEMA, + ...extremesSchema(ctx.hass), + ...SYNC_SCHEMA, + ...layoutSchema(ctx.hass), + ...multiplesSchema(ctx.hass, ctx.config), + ], + ); + return html`${chartForm(ctx, 'chart', schema, LINE_FIELDS)}${annotationsEditor(ctx)}`; } diff --git a/src/editor/chartdeck-card-editor.ts b/src/editor/chartdeck-card-editor.ts index 102f777..12f1479 100644 --- a/src/editor/chartdeck-card-editor.ts +++ b/src/editor/chartdeck-card-editor.ts @@ -10,23 +10,32 @@ import { COLOUR_TOGGLE_FIELDS, DISPLAY_FIELDS, DISPLAY_TOGGLE_FIELDS, + GAUGE_FIELDS, TITLE_FIELDS, actionsSchema, applyFormData, deepEqual, displaySchema, formData, + gaugeSchema, RULE_FIELDS, ruleSchema, titleSchema, } from './schemas'; import type { FieldBinding } from './schemas'; + import { thumbnail } from './thumbnails'; import { STYLES, STYLE_GROUPS, isChart } from '../styles/index'; import { sensorUnit } from './entities-editor'; import './bands-editor'; import './entities-editor'; import './limit-field'; +import { GROUP_LEVELS } from '../chart/hierarchy'; + +/** Styles whose `group_by` groups entities by place rather than readings by time. */ +const HIERARCHY_STYLES: StyleName[] = ['treemap', 'sunburst', 'icicle', 'radar']; +/** Styles with the gauge options (angles, centre text, tick labels). */ +const GAUGE_STYLES: StyleName[] = ['ring', 'arc', 'needle']; interface CardHelpers { createCardElement(config: Record): Promise; @@ -91,7 +100,8 @@ export class ChartdeckCardEditor extends LitElement { margin: 16px 0 4px; font-weight: 500; } - .rules-help { + .rules-help, + .gauge-help { margin: 0 0 8px; color: var(--secondary-text-color); font-size: 0.875em; @@ -293,7 +303,14 @@ export class ChartdeckCardEditor extends LitElement { private _selectStyle(style: StyleName): void { if (!this._config) return; - this._commit(setOption(this._config, ['style'], style === 'bar' ? undefined : style)); + let next = setOption(this._config, ['style'], style === 'bar' ? undefined : style); + // `group_by` means places (floor, area…) on the hierarchy charts and times (hour, day…) on the + // history charts: one the new style can't read is dropped rather than left to fail. + const levels = ([] as unknown[]).concat(next.group_by ?? []); + const places = levels.length > 0 && levels.every((l) => (GROUP_LEVELS as readonly unknown[]).includes(l)); + if (levels.length > 0 && places !== HIERARCHY_STYLES.includes(style)) + next = setOption(next, ['group_by'], undefined); + this._commit(next); } /** The style picker, one group of thumbnails per kind of style (bars, gauges, charts…). */ @@ -322,7 +339,13 @@ export class ChartdeckCardEditor extends LitElement { `; })}
`; - })}`; + })} + ${ + GAUGE_STYLES.includes(current as StyleName) + ? html`

${this._label('editor_gauge_help')}

+ ${this._form('gauge', gaugeSchema(this.hass, current as StyleName, this._config?.center), GAUGE_FIELDS)}` + : nothing + }`; } /** The current chart style's own options, when it has any. */ diff --git a/src/editor/climate-editor.ts b/src/editor/climate-editor.ts new file mode 100644 index 0000000..99777dd --- /dev/null +++ b/src/editor/climate-editor.ts @@ -0,0 +1,92 @@ +import type { TemplateResult } from 'lit'; +import { CURVES } from '../chart/paths'; +import { DEFAULT_LINE_WIDTH } from '../chart/series-style'; +import { registerStrings } from '../localize'; +import type { ChartEditorContext } from '../styles/index'; +import { + AXES_FIELDS, + HISTORY_FIELDS, + LAYOUT_FIELDS, + axesSchema, + cachedSchema, + chartForm, + choice, + historySchema, + historyVariant, + layoutSchema, +} from './chart-editors'; +import { LINE_WIDTH_SELECTOR } from './schemas'; +import type { FieldBinding } from './schemas'; + +registerStrings('en', { + editor_set_point: 'Show the set point', + editor_shading: 'Shade while heating or cooling', + editor_shade_fan: 'Shade while only the fan runs', + editor_current_color: 'Temperature colour', + editor_set_point_color: 'Set point colour', + editor_heating_color: 'Heating colour', + editor_cooling_color: 'Cooling colour', +}); + +/** Climate charts read attributes, which only history keeps, and draw them ungrouped. */ +const NOT_FOR_CLIMATE = new Set(['source', 'group_by', 'aggregate']); + +export const CLIMATE_FIELDS: FieldBinding[] = [ + ...HISTORY_FIELDS.filter((f) => !NOT_FOR_CLIMATE.has(f.name)), + { name: 'set_point', path: ['set_point'], default: true }, + { name: 'shading', path: ['shading'], default: true }, + { name: 'shade_fan', path: ['shade_fan'], default: false }, + { name: 'curve', path: ['curve'], default: 'smooth' }, + { name: 'line_width', path: ['line_width'], default: DEFAULT_LINE_WIDTH }, + // Empty colours follow the theme: primary, error, and Home Assistant's heat and cool colours. + { name: 'current_color', path: ['current_color'] }, + { name: 'set_point_color', path: ['set_point_color'] }, + { name: 'heating_color', path: ['heating_color'] }, + { name: 'cooling_color', path: ['cooling_color'] }, + ...AXES_FIELDS, + ...LAYOUT_FIELDS, +]; + +/** The history schema without the source and grouping rows. */ +function climateHistorySchema(ctx: ChartEditorContext): unknown[] { + type Row = { name?: string; schema?: Row[] }; + return (historySchema(ctx.hass, ctx.config) as Row[]).filter( + (row) => !NOT_FOR_CLIMATE.has(row.name ?? '') && !(row.schema ?? []).some((s) => NOT_FOR_CLIMATE.has(s.name ?? '')), + ); +} + +export function climateEditor(ctx: ChartEditorContext): TemplateResult { + const schema = cachedSchema(`climate|${historyVariant(ctx.config)}`, ctx.hass, () => [ + ...climateHistorySchema(ctx), + { + type: 'grid', + name: '', + schema: [ + { name: 'set_point', selector: { boolean: {} } }, + { name: 'shading', selector: { boolean: {} } }, + { name: 'shade_fan', selector: { boolean: {} } }, + ], + }, + { + type: 'grid', + name: '', + schema: [ + { name: 'curve', selector: choice(ctx.hass, 'curve', CURVES) }, + { name: 'line_width', selector: LINE_WIDTH_SELECTOR }, + ], + }, + { + type: 'grid', + name: '', + schema: [ + { name: 'current_color', selector: { text: {} } }, + { name: 'set_point_color', selector: { text: {} } }, + { name: 'heating_color', selector: { text: {} } }, + { name: 'cooling_color', selector: { text: {} } }, + ], + }, + ...axesSchema(ctx.hass), + ...layoutSchema(ctx.hass), + ]); + return chartForm(ctx, 'chart', schema, CLIMATE_FIELDS); +} diff --git a/src/editor/day-clock-editor.ts b/src/editor/day-clock-editor.ts new file mode 100644 index 0000000..aa1944d --- /dev/null +++ b/src/editor/day-clock-editor.ts @@ -0,0 +1,102 @@ +import type { TemplateResult } from 'lit'; +import { BUCKETS } from '../chart/day-clock'; +import { AGGREGATES } from '../history/group'; +import { registerStrings } from '../localize'; +import type { ChartEditorContext, EntityFields } from '../styles/index'; +import type { CardConfig, HomeAssistant } from '../types'; +import { cachedSchema, chartForm, choice } from './chart-editors'; +import type { FieldBinding } from './schemas'; + +registerStrings('en', { + editor_days: 'Days (more than 1 shows the average day)', + editor_center: 'In the middle', + editor_day_clock_center_total: 'Time on', + editor_day_clock_center_now: 'The time now', + editor_day_clock_center_none: 'Nothing', + editor_bucket: 'One bar for every', + editor_bucket_15: '15 minutes', + editor_bucket_30: '30 minutes', + editor_bucket_60: 'Hour', + editor_aggregate_auto: 'Automatic (change for meters, else average)', + editor_ring_color: 'Colour', + editor_active: 'Counts as on (empty: on, open, heating, home…)', +}); + +export const DAY_CLOCK_FIELDS: FieldBinding[] = [ + { name: 'days', path: ['days'], default: 1 }, + { name: 'period_offset', path: ['period', 'offset'], default: 0 }, + { name: 'period_navigate', path: ['period', 'navigate'], default: false }, + { name: 'center', path: ['center'], default: 'total' }, + { name: 'bucket', path: ['bucket'], default: '60' }, + { name: 'aggregate', path: ['aggregate'], default: 'auto' }, + { name: 'tooltip', path: ['tooltip'], default: true }, +]; + +function schema(hass?: HomeAssistant) { + return [ + { + type: 'grid', + name: '', + schema: [ + { name: 'days', selector: { number: { min: 1, max: 31, mode: 'box' } } }, + { name: 'period_offset', selector: { number: { min: 0, mode: 'box' } } }, + ], + }, + { name: 'period_navigate', selector: { boolean: {} } }, + { name: 'center', selector: choice(hass, 'day_clock_center', ['total', 'now', 'none']) }, + { + type: 'grid', + name: '', + schema: [ + { name: 'bucket', selector: choice(hass, 'bucket', BUCKETS.map(String)) }, + { name: 'aggregate', selector: choice(hass, 'aggregate', ['auto', ...AGGREGATES]) }, + ], + }, + { name: 'tooltip', selector: { boolean: {} } }, + ]; +} + +/** The dropdown speaks strings ("30"); the config keeps a number (30). */ +function bucketAsText(config: CardConfig): CardConfig { + return typeof config.bucket === 'number' ? { ...config, bucket: String(config.bucket) } : config; +} + +function bucketAsNumber(config: CardConfig): CardConfig { + return typeof config.bucket === 'string' ? { ...config, bucket: Number(config.bucket) } : config; +} + +/** The Chart section: which days, what the middle says, and how numbers become bars. */ +export function dayClockEditor(ctx: ChartEditorContext): TemplateResult { + const view: ChartEditorContext = { + ...ctx, + config: bucketAsText(ctx.config), + commit: (config) => ctx.commit(bucketAsNumber(config)), + }; + return chartForm( + view, + 'chart', + cachedSchema('day_clock', ctx.hass, () => schema(ctx.hass)), + DAY_CLOCK_FIELDS, + ); +} + +const ENTITY_SCHEMA = [ + { + type: 'grid', + name: '', + schema: [ + { name: 'ring_color', selector: { text: {} } }, + { name: 'active', selector: { text: { multiple: true } } }, + ], + }, +]; + +const ENTITY_FIELDS: FieldBinding[] = [ + { name: 'ring_color', path: ['color'] }, + { name: 'active', path: ['active'] }, +]; + +/** Each entity's ring colour, and which of its states count as on. */ +export function dayClockEntityFields(): EntityFields { + return { schema: ENTITY_SCHEMA, fields: ENTITY_FIELDS }; +} diff --git a/src/editor/entity-options.ts b/src/editor/entity-options.ts new file mode 100644 index 0000000..96c25f0 --- /dev/null +++ b/src/editor/entity-options.ts @@ -0,0 +1,64 @@ +import { html } from 'lit'; +import type { TemplateResult } from 'lit'; +import { localize, registerStrings } from '../localize'; +import type { ChartEditorContext } from '../styles/index'; +import { cachedSchema } from './chart-editors'; +import { entityId, setEntityOption } from './edit'; + +registerStrings('en', { + editor_entity_options_none: 'Add an entity above first.', +}); + +/** + * One field per listed entity, for an option a chart reads from each of them (its group, its + * axis, whether it counts down). Entities matched by `include` have no row of their own, so they + * aren't listed: they follow the card-wide setting. + */ +export function entityOptionsForm( + ctx: ChartEditorContext, + section: string, + key: string, + selector: Record, + fallback: unknown = undefined, +): TemplateResult { + const entities = ctx.config.entities ?? []; + const n = entities.length; + const schema = cachedSchema(`${section}-${n}`, ctx.hass, () => entities.map((_, i) => ({ name: `e${i}`, selector }))); + const own = (i: number) => { + const item = entities[i]; + return typeof item === 'object' ? (item as unknown as Record)[key] : undefined; + }; + const data: Record = {}; + for (let i = 0; i < n; i++) data[`e${i}`] = own(i) ?? fallback; + const label = (s: { name: string }) => { + const id = entityId(entities[Number(s.name.slice(1))]); + const name = ctx.hass?.states[id]?.attributes?.friendly_name; + return typeof name === 'string' && name ? name : id; + }; + return html`
+
${localize(ctx.hass, `editor_${section}`)}
+ ${ + n === 0 + ? html`
+ ${localize(ctx.hass, 'editor_entity_options_none')} +
` + : html` { + ev.stopPropagation(); + const value = (ev.detail?.value ?? {}) as Record; + let next = ctx.config; + for (let i = 0; i < n; i++) { + const v = value[`e${i}`]; + const wanted = v === fallback || v === '' ? undefined : v; + if (wanted !== own(i)) next = setEntityOption(next, i, key, wanted); + } + if (next !== ctx.config) ctx.commit(next); + }} + >` + } +
`; +} diff --git a/src/editor/group-fields.ts b/src/editor/group-fields.ts new file mode 100644 index 0000000..48138cb --- /dev/null +++ b/src/editor/group-fields.ts @@ -0,0 +1,52 @@ +import { GROUP_LEVELS, parseGroupBy } from '../chart/hierarchy'; +import { localize, registerStrings } from '../localize'; +import type { ChartEditorContext } from '../styles/index'; +import type { CardConfig, HomeAssistant } from '../types'; +import { entityOptionsForm } from './entity-options'; +import type { FieldBinding } from './schemas'; + +registerStrings('en', { + editor_hierarchy_group_by: 'Group by', + editor_group_by_floor: 'Floor', + editor_group_by_area: 'Area', + editor_group_by_device: 'Device', + editor_group_by_label: 'Label', + editor_entity_groups: 'Own group (wins over "Group by"; "Ground floor / Kitchen" for two levels)', +}); + +export const GROUP_FIELDS: FieldBinding[] = [{ name: 'group_by', path: ['group_by'], default: [] }]; + +/** Pick any of floor, area, device and label; they nest in that order. */ +export function groupBySchema(hass?: HomeAssistant) { + return { + name: 'group_by', + selector: { + select: { + multiple: true, + mode: 'list', + options: GROUP_LEVELS.map((value) => ({ value, label: localize(hass, `editor_group_by_${value}`) })), + }, + }, + }; +} + +/** The editor always works on a list, so `group_by: area` in YAML shows as ticked. */ +export function withGroupList(ctx: ChartEditorContext): ChartEditorContext { + const raw = ctx.config.group_by; + if (raw === undefined || Array.isArray(raw)) return ctx; + let list: string[]; + try { + list = parseGroupBy(raw); + } catch { + return ctx; + } + return { ...ctx, config: { ...ctx.config, group_by: list } as CardConfig }; +} + +/** A text box per listed entity for its own group. */ +export function entityGroupsForm(ctx: ChartEditorContext) { + return entityOptionsForm(ctx, 'entity_groups', 'group', { text: {} }); +} + +/** Label keys for chartForm: here `group_by` groups by place, not by time as on the history charts. */ +export const HIERARCHY_LABELS = { group_by: 'editor_hierarchy_group_by' }; diff --git a/src/editor/schemas.ts b/src/editor/schemas.ts index 46d638f..aface3c 100644 --- a/src/editor/schemas.ts +++ b/src/editor/schemas.ts @@ -42,6 +42,56 @@ export const ACTION_FIELDS: FieldBinding[] = [ { name: 'double_tap_action', path: ['double_tap_action'], default: { action: 'none' } }, ]; +/** Ring, arc and needle options, under the style picker. Empty angles use the style's own. */ +export const GAUGE_FIELDS: FieldBinding[] = [ + { name: 'start_angle', path: ['start_angle'] }, + { name: 'end_angle', path: ['end_angle'] }, + { name: 'center', path: ['center'], default: 'value' }, + { name: 'center_text', path: ['center_text'] }, + { name: 'tick_labels', path: ['tick_labels'], default: false }, +]; + +const gaugeSchemas = new Map(); + +/** + * The gauge options for a style: the text field only while the centre shows custom text, and tick + * labels only on the needle (ring and arc have no scale). + */ +export function gaugeSchema(hass: HomeAssistant | undefined, style: string, center: unknown) { + const text = center === 'text'; + const key = `${languageOf(hass)}|${style}|${text}`; + let schema = gaugeSchemas.get(key); + if (!schema) { + const angle = { number: { min: -360, max: 360, step: 1, mode: 'box', unit_of_measurement: '°' } }; + schema = [ + { + type: 'grid', + name: '', + schema: [ + { name: 'start_angle', selector: angle }, + { name: 'end_angle', selector: angle }, + ], + }, + { + name: 'center', + selector: { + select: { + mode: 'dropdown', + options: ['value', 'band', 'name', 'text', 'none'].map((value) => ({ + value, + label: localize(hass, `editor_center_mode_${value}`), + })), + }, + }, + }, + ...(text ? [{ name: 'center_text', selector: { text: {} } }] : []), + ...(style === 'needle' ? [{ name: 'tick_labels', selector: { boolean: {} } }] : []), + ]; + gaugeSchemas.set(key, schema); + } + return schema; +} + /** Per-entity overrides edited through ha-form; paths are relative to the entity object. */ export const ENTITY_TEXT_FIELDS: FieldBinding[] = [ { name: 'name', path: ['name'] }, diff --git a/src/editor/timeline-editor.ts b/src/editor/timeline-editor.ts new file mode 100644 index 0000000..9d17754 --- /dev/null +++ b/src/editor/timeline-editor.ts @@ -0,0 +1,194 @@ +import { html } from 'lit'; +import type { TemplateResult } from 'lit'; +import { baseColor } from '../bands'; +import { localize, registerStrings } from '../localize'; +import type { ChartEditorContext, EntityFields } from '../styles/index'; +import type { CardConfig, HomeAssistant } from '../types'; +import { HISTORY_FIELDS, cachedSchema, chartForm, choice, historySchema, historyVariant } from './chart-editors'; +import type { FieldBinding } from './schemas'; + +registerStrings('en', { + editor_bar_labels: 'Text on the bars', + editor_bar_labels_state: 'State', + editor_bar_labels_duration: 'How long', + editor_bar_labels_both: 'State and how long', + editor_bar_labels_none: 'None', + editor_timeline_legend: 'Legend of states', + editor_timeline_legend_above: 'Above the chart', + editor_timeline_legend_hidden: 'Hidden', + editor_state_colors: 'State colours', + editor_state_colors_help: 'A palette name (amber, green, blue, red, purple, grey, primary) or any CSS colour.', + editor_state_colors_state: 'State', + editor_state_colors_color: 'Colour', + editor_state_colors_add: 'Add a state colour', + editor_state_colors_remove: 'Remove', + editor_timeline_group: 'Group (rows with the same group share a heading)', +}); + +/** A state timeline reads states, which statistics don't keep, and draws them ungrouped. */ +const NOT_FOR_TIMELINE = new Set(['source', 'group_by', 'aggregate']); + +export const TIMELINE_FIELDS: FieldBinding[] = [ + ...HISTORY_FIELDS.filter((f) => !NOT_FOR_TIMELINE.has(f.name)), + { name: 'bar_labels', path: ['bar_labels'], default: 'state' }, + { name: 'x_axis', path: ['x_axis'], default: true }, + { name: 'grid_vertical', path: ['gridlines', 'vertical'], default: true }, + { name: 'tooltip', path: ['tooltip'], default: true }, + { name: 'timeline_legend', path: ['header'], default: 'above' }, + { name: 'compact', path: ['compact'], default: false }, +]; + +function timelineHistorySchema(hass: HomeAssistant | undefined, config: CardConfig): unknown[] { + type Row = { name?: string; schema?: Row[] }; + return (historySchema(hass, config) as Row[]).filter( + (row) => + !NOT_FOR_TIMELINE.has(row.name ?? '') && !(row.schema ?? []).some((s) => NOT_FOR_TIMELINE.has(s.name ?? '')), + ); +} + +const DELETE_ICON = 'M19,4H15.5L14.5,3H9.5L8.5,4H5V6H19M6,19A2,2 0 0,0 8,21H16A2,2 0 0,0 18,19V7H6V19Z'; +const TEXT = { text: {} }; + +function colorsOf(config: CardConfig): [string, string][] { + const v = config.state_colors; + return v && typeof v === 'object' && !Array.isArray(v) ? Object.entries(v as Record) : []; +} + +/** The config with `state_colors` set to these entries; none removes the key. */ +export function withStateColors(config: CardConfig, entries: [string, string][]): CardConfig { + const next = { ...config }; + const kept = entries.filter(([state]) => state.trim() !== ''); + if (kept.length) next.state_colors = Object.fromEntries(kept.map(([s, c]) => [s.trim(), c])); + else delete next.state_colors; + return next; +} + +/** + * A state for a new row: the first current state of the card's entities that has no colour yet, + * else a common one, so the user mostly just picks a colour. + */ +export function suggestState(config: CardConfig, hass: HomeAssistant | undefined): string { + const taken = new Set(colorsOf(config).map(([s]) => s)); + const ids = (config.entities ?? []).map((e) => (typeof e === 'string' ? e : e.entity)); + const current = ids.map((id) => hass?.states[id]?.state).filter((s): s is string => !!s); + const candidates = [...current, 'on', 'off', 'home', 'not_home', 'heat', 'idle']; + return candidates.find((s) => !taken.has(s) && s !== 'unavailable' && s !== 'unknown') ?? `state_${taken.size + 1}`; +} + +/** One row per coloured state: its name, its colour and a remove button, then an add button. */ +function stateColorsEditor(ctx: ChartEditorContext): TemplateResult { + const entries = colorsOf(ctx.config); + const t = (k: string) => localize(ctx.hass, k); + const set = (next: [string, string][]) => ctx.commit(withStateColors(ctx.config, next)); + const change = (i: number, part: 0 | 1) => (ev: CustomEvent) => { + ev.stopPropagation(); + const value = String(ev.detail?.value ?? ''); + const next = entries.map((e) => [...e] as [string, string]); + if (part === 1 && value.trim() === '') return; + next[i][part] = value; + set(next); + }; + return html`
+ +
${t('editor_state_colors')}
+
${t('editor_state_colors_help')}
+ ${entries.map( + ([state, color], i) => + html`
+ + + + set(entries.filter((_, j) => j !== i))} + > +
`, + )} + +
`; +} + +export function timelineEditor(ctx: ChartEditorContext): TemplateResult { + const schema = cachedSchema(`timeline|${historyVariant(ctx.config)}`, ctx.hass, () => [ + ...timelineHistorySchema(ctx.hass, ctx.config), + { name: 'bar_labels', selector: choice(ctx.hass, 'bar_labels', ['state', 'duration', 'both', 'none']) }, + { + type: 'grid', + name: '', + schema: [ + { name: 'x_axis', selector: { boolean: {} } }, + { name: 'grid_vertical', selector: { boolean: {} } }, + { name: 'tooltip', selector: { boolean: {} } }, + { name: 'compact', selector: { boolean: {} } }, + ], + }, + { name: 'timeline_legend', selector: choice(ctx.hass, 'timeline_legend', ['above', 'hidden']) }, + ]); + return html`${chartForm(ctx, 'chart', schema, TIMELINE_FIELDS)}${stateColorsEditor(ctx)}`; +} + +const ENTITY_FIELDS: FieldBinding[] = [{ name: 'timeline_group', path: ['group'] }]; + +/** Each entity's group: rows that share one are drawn together under its name. */ +export function timelineEntityFields(hass: HomeAssistant | undefined): EntityFields { + return { + schema: cachedSchema('timeline_entity', hass, () => [{ name: 'timeline_group', selector: { text: {} } }]), + fields: ENTITY_FIELDS, + }; +} diff --git a/src/editor/trend-editor.ts b/src/editor/trend-editor.ts new file mode 100644 index 0000000..7b41b6e --- /dev/null +++ b/src/editor/trend-editor.ts @@ -0,0 +1,242 @@ +import type { TemplateResult } from 'lit'; +import { CURVES } from '../chart/paths'; +import { DEFAULT_LINE_WIDTH } from '../chart/series-style'; +import { AGGREGATES } from '../history/group'; +import { registerStrings } from '../localize'; +import type { ChartEditorContext } from '../styles/index'; +import type { CardConfig, HomeAssistant } from '../types'; +import { HISTORY_FIELDS, cachedSchema, chartForm, choice, historySchema, historyVariant } from './chart-editors'; +import { LINE_WIDTH_SELECTOR } from './schemas'; +import type { FieldBinding } from './schemas'; + +/** The trend style's Chart section and per-entity options. Defaults match the style's (see styles/trend.ts). */ + +registerStrings('en', { + editor_points_per_hour: 'Points per hour (0 draws every reading)', + editor_graph: 'Graph', + editor_graph_line: 'Line', + editor_graph_bar: 'Bars', + editor_graph_none: 'None (value only)', + editor_fill: 'Fill under the line', + editor_fill_solid: 'Solid', + editor_fill_fade: 'Fade', + editor_fill_none: 'None', + editor_points: 'Dots on the line', + editor_points_hover: 'On hover', + editor_points_always: 'Always', + editor_points_never: 'Never', + editor_trend_labels: 'Highest and lowest labels', + editor_labels_hover: 'On hover', + editor_labels_always: 'Always', + editor_labels_never: 'Never', + editor_height: 'Graph height (per 500 px of width)', + editor_bar_spacing: 'Space between bars (px)', + editor_color_transition: 'Band colours', + editor_color_transition_smooth: 'Blend', + editor_color_transition_hard: 'Change at each band', + editor_extrema: 'Show lowest and highest', + editor_average: 'Show average', + editor_trend_legend: 'Legend (two or more entities)', + editor_trend_y_min: 'Lowest value (~ for soft)', + editor_trend_y_max: 'Highest value (~ for soft)', + editor_min_range: 'Smallest range shown', + editor_y_min_secondary: 'Right axis lowest (~ for soft)', + editor_y_max_secondary: 'Right axis highest (~ for soft)', + editor_min_range_secondary: 'Right axis smallest range', + editor_value_factor: 'Scale by 10 to the power of', + editor_align_header: 'Name and icon', + editor_align_header_default: 'Spread out', + editor_align_header_left: 'Left', + editor_align_header_right: 'Right', + editor_align_header_center: 'Centre', + editor_align_icon: 'Icon', + editor_align_icon_right: 'Right of the name', + editor_align_icon_left: 'Left of the name', + editor_align_icon_state: 'Beside the value', + editor_align_state: 'Value', + editor_align_state_left: 'Left', + editor_align_state_right: 'Right', + editor_align_state_center: 'Centre', + editor_font_size: 'Value size (%)', + editor_font_size_header: 'Name size (px)', + editor_color_name: 'Colour the name', + editor_color_value: 'Colour the value', + editor_trend_color_icon: 'Colour the icon', + editor_trend_group: 'No background (inside another card)', + editor_icon_image: 'Image instead of the icon (URL)', + editor_trend_tooltip: 'Show the value under the pointer', + editor_y_axis_side: 'Axis', + editor_y_axis_side_primary: 'Left', + editor_y_axis_side_secondary: 'Right (its own scale)', + editor_entity_aggregate: 'Combine with', + editor_fixed_value: 'Draw the current value as a flat line', + editor_show_state: 'Show its value beside the main one', + editor_show_indicator: 'Colour dot by the value', + editor_show_graph: 'On the graph', + editor_show_line: 'Line', + editor_show_fill: 'Fill', + editor_show_points: 'Dots', + editor_show_legend_entity: 'In the legend', + editor_show_legend_state: 'Value in the legend', +}); + +const TREND_FIELDS: FieldBinding[] = [ + ...HISTORY_FIELDS, + { name: 'points_per_hour', path: ['points_per_hour'], default: 0.5 }, + { name: 'graph', path: ['graph'], default: 'line' }, + { name: 'fill', path: ['fill'], default: 'fade' }, + { name: 'curve', path: ['curve'], default: 'smooth' }, + { name: 'line_width', path: ['line_width'], default: DEFAULT_LINE_WIDTH }, + { name: 'points', path: ['points'], default: 'hover' }, + { name: 'labels', path: ['labels'], default: 'hover' }, + { name: 'height', path: ['height'], default: 150 }, + { name: 'bar_spacing', path: ['bar_spacing'], default: 4 }, + { name: 'color_transition', path: ['color_transition'], default: 'smooth' }, + { name: 'extrema', path: ['extrema'], default: false }, + { name: 'average', path: ['average'], default: false }, + { name: 'legend', path: ['legend'], default: true }, + { name: 'trend_y_min', path: ['y_min'] }, + { name: 'trend_y_max', path: ['y_max'] }, + { name: 'min_range', path: ['min_range'] }, + { name: 'y_min_secondary', path: ['y_min_secondary'] }, + { name: 'y_max_secondary', path: ['y_max_secondary'] }, + { name: 'min_range_secondary', path: ['min_range_secondary'] }, + { name: 'value_factor', path: ['value_factor'], default: 0 }, + { name: 'align_header', path: ['align_header'], default: 'default' }, + { name: 'align_icon', path: ['align_icon'], default: 'right' }, + { name: 'align_state', path: ['align_state'], default: 'left' }, + { name: 'font_size', path: ['font_size'], default: 100 }, + { name: 'font_size_header', path: ['font_size_header'] }, + { name: 'color_name', path: ['color_name'], default: false }, + { name: 'color_value', path: ['color_value'], default: false }, + { name: 'trend_color_icon', path: ['color_icon'], default: false }, + { name: 'group', path: ['group'], default: false }, + { name: 'icon_image', path: ['icon_image'] }, + { name: 'trend_tooltip', path: ['tooltip'], default: true }, +]; + +const grid = (...schema: unknown[]) => ({ type: 'grid', name: '', schema }); +const bool = (name: string) => ({ name, selector: { boolean: {} } }); +const box = (name: string, min?: number, max?: number, step: number | 'any' = 'any') => ({ + name, + selector: { + number: { mode: 'box', step, ...(min !== undefined ? { min } : {}), ...(max !== undefined ? { max } : {}) }, + }, +}); +/** A bound takes a number or `~` and a number, so it is a text field; the card turns digits into a number. */ +const bound = (name: string) => ({ name, selector: { text: {} } }); + +function trendSchema(hass: HomeAssistant | undefined, config: CardConfig) { + return [ + ...historySchema(hass, config), + box('points_per_hour', 0, 60), + grid( + { name: 'graph', selector: choice(hass, 'graph', ['line', 'bar', 'none']) }, + { name: 'fill', selector: choice(hass, 'fill', ['fade', 'solid', 'none']) }, + { name: 'curve', selector: choice(hass, 'curve', CURVES) }, + { name: 'line_width', selector: LINE_WIDTH_SELECTOR }, + { name: 'points', selector: choice(hass, 'points', ['hover', 'always', 'never']) }, + { name: 'labels', selector: choice(hass, 'labels', ['hover', 'always', 'never']) }, + box('height', 20, 1000, 1), + box('bar_spacing', 0, 50, 1), + ), + { name: 'color_transition', selector: choice(hass, 'color_transition', ['smooth', 'hard']) }, + grid(bool('extrema'), bool('average'), bool('legend'), bool('trend_tooltip')), + grid(bound('trend_y_min'), bound('trend_y_max'), box('min_range', 0)), + grid(bound('y_min_secondary'), bound('y_max_secondary'), box('min_range_secondary', 0)), + grid( + { name: 'align_header', selector: choice(hass, 'align_header', ['default', 'left', 'right', 'center']) }, + { name: 'align_icon', selector: choice(hass, 'align_icon', ['right', 'left', 'state']) }, + { name: 'align_state', selector: choice(hass, 'align_state', ['left', 'right', 'center']) }, + box('font_size', 25, 400, 1), + box('font_size_header', 6, 60, 1), + box('value_factor', -9, 9, 1), + ), + grid(bool('color_name'), bool('trend_color_icon'), bool('color_value'), bool('group')), + { name: 'icon_image', selector: { text: {} } }, + ]; +} + +/** Bounds typed as digits become numbers, so `y_min: 0` stays a number in YAML. */ +function numericBounds(config: CardConfig): CardConfig { + let next = config; + for (const k of ['y_min', 'y_max', 'y_min_secondary', 'y_max_secondary']) { + const v = next[k]; + if (typeof v === 'string' && v.trim() !== '' && !v.trim().startsWith('~') && Number.isFinite(Number(v))) { + next = { ...next, [k]: Number(v) }; + } else if (v === '') { + next = { ...next }; + delete next[k]; + } + } + return next; +} + +export function trendEditor(ctx: ChartEditorContext): TemplateResult { + const schema = cachedSchema(`trend|${historyVariant(ctx.config)}`, ctx.hass, () => trendSchema(ctx.hass, ctx.config)); + const form = { + ...ctx, + // ha-form's text field hands back strings; keep bounds as numbers when they are. + config: { ...ctx.config, ...stringBounds(ctx.config) }, + commit: (config: CardConfig) => ctx.commit(numericBounds(config)), + }; + return chartForm(form, 'chart', schema, TREND_FIELDS, { + legend: 'editor_trend_legend', + labels: 'editor_trend_labels', + group: 'editor_trend_group', + }); +} + +/** Numbers shown in the bound text fields as text. */ +function stringBounds(config: CardConfig): Partial { + const out: Partial = {}; + for (const k of ['y_min', 'y_max', 'y_min_secondary', 'y_max_secondary']) { + if (typeof config[k] === 'number') out[k] = String(config[k]); + } + return out; +} + +const ENTITY_FIELDS: FieldBinding[] = [ + { name: 'series_color', path: ['color'] }, + { name: 'curve', path: ['curve'] }, + { name: 'line_width', path: ['line_width'] }, + { name: 'entity_aggregate', path: ['aggregate'] }, + { name: 'y_axis_side', path: ['y_axis'], default: 'primary' }, + { name: 'fixed_value', path: ['fixed_value'], default: false }, + { name: 'show_graph', path: ['show_graph'], default: true }, + { name: 'show_line', path: ['show_line'], default: true }, + { name: 'show_fill', path: ['show_fill'], default: true }, + { name: 'show_points', path: ['show_points'] }, + { name: 'show_legend_entity', path: ['show_legend'], default: true }, + { name: 'show_legend_state', path: ['show_legend_state'], default: false }, + { name: 'show_indicator', path: ['show_indicator'], default: false }, + { name: 'show_state', path: ['show_state'], default: false }, +]; + +function entitySchema(hass: HomeAssistant | undefined) { + return [ + grid( + { name: 'series_color', selector: { text: {} } }, + { name: 'curve', selector: choice(hass, 'curve', CURVES) }, + { name: 'line_width', selector: LINE_WIDTH_SELECTOR }, + { name: 'entity_aggregate', selector: choice(hass, 'aggregate', AGGREGATES) }, + { name: 'y_axis_side', selector: choice(hass, 'y_axis_side', ['primary', 'secondary']) }, + ), + grid( + bool('show_state'), + bool('show_indicator'), + bool('show_graph'), + bool('show_line'), + bool('show_fill'), + bool('show_points'), + bool('show_legend_entity'), + bool('show_legend_state'), + bool('fixed_value'), + ), + ]; +} + +/** Colour, shape, axis and what shows for one entity. */ +export function trendEntityFields(hass: HomeAssistant | undefined) { + return { schema: cachedSchema('trend-entity', hass, () => entitySchema(hass)), fields: ENTITY_FIELDS }; +} diff --git a/src/editor/wind-rose-editor.ts b/src/editor/wind-rose-editor.ts new file mode 100644 index 0000000..4b38a43 --- /dev/null +++ b/src/editor/wind-rose-editor.ts @@ -0,0 +1,101 @@ +import { html, nothing } from 'lit'; +import type { TemplateResult } from 'lit'; +import { SECTOR_COUNTS } from '../chart/wind'; +import { localize, registerStrings } from '../localize'; +import type { ChartEditorContext } from '../styles/index'; +import type { CardConfig } from '../types'; +import { HISTORY_FIELDS, cachedSchema, chartForm, choice, historySchema, historyVariant } from './chart-editors'; +import type { FieldBinding } from './schemas'; + +registerStrings('en', { + editor_sectors: 'Directions', + editor_sectors_4: '4 (N, E, S, W)', + editor_sectors_8: '8 (N, NE, E…)', + editor_sectors_16: '16 (N, NNE, NE…)', + editor_calm: 'Calm below (blank: 0.5 m/s, or the same in km/h, mph or knots)', + editor_wind_roles: + 'The first entity is the wind direction, in degrees or compass points such as SW. The second is the wind speed: without one, the petals only show how often the wind came from each way.', + editor_wind_now: 'Direction: {direction}. Speed: {speed}.', + editor_wind_none: 'none', + editor_wind_statistics: + "Long-term statistics give each hour's average direction. That is only right across north when the sensor records direction as an angle (state class measurement_angle); otherwise an hour swinging between NNW and NNE averages to south. History is exact.", +}); + +/** The history fields with the wind rose's month as the default span, and no grouping. */ +const PERIOD_FIELDS: FieldBinding[] = HISTORY_FIELDS.filter((f) => f.name !== 'group_by' && f.name !== 'aggregate').map( + (f) => (f.name === 'period_last' ? { ...f, default: 30 } : f.name === 'period_unit' ? { ...f, default: 'day' } : f), +); + +const WIND_FIELDS: FieldBinding[] = [ + ...PERIOD_FIELDS, + { name: 'sectors', path: ['sectors'], default: '16' }, + { name: 'calm', path: ['calm'] }, + { name: 'tooltip', path: ['tooltip'], default: true }, + { name: 'compact', path: ['compact'], default: false }, +]; + +/** ha-form's dropdown hands back text, so sectors are shown as text and written back as a number. */ +function sectorsAsText(config: CardConfig): CardConfig { + return typeof config.sectors === 'number' ? { ...config, sectors: String(config.sectors) } : config; +} + +function sectorsAsNumber(config: CardConfig): CardConfig { + if (typeof config.sectors !== 'string') return config; + const n = Number(config.sectors); + return { ...config, sectors: Number.isFinite(n) ? n : config.sectors }; +} + +function entityName(ctx: ChartEditorContext, raw: CardConfig['entities'] extends (infer E)[] | undefined ? E : never) { + if (raw === undefined) return localize(ctx.hass, 'editor_wind_none'); + const id = typeof raw === 'string' ? raw : raw.entity; + const own = typeof raw === 'string' ? undefined : raw.name; + return own ?? (ctx.hass?.states[id]?.attributes?.friendly_name as string | undefined) ?? id; +} + +/** The wind rose's Chart section: which entity is which, the span, sectors, calm and layout. */ +export function windRoseEditor(ctx: ChartEditorContext): TemplateResult { + const schema = cachedSchema(`wind_rose|${historyVariant(ctx.config)}`, ctx.hass, () => [ + // Grouping readings means nothing to a rose: leave its row out. + ...historySchema(ctx.hass, ctx.config).filter( + (field) => !(field as { schema?: { name: string }[] }).schema?.some((f) => f.name === 'group_by'), + ), + { + type: 'grid', + name: '', + schema: [ + { name: 'sectors', selector: choice(ctx.hass, 'sectors', SECTOR_COUNTS.map(String)) }, + { name: 'calm', selector: { number: { min: 0, mode: 'box', step: 'any' } } }, + ], + }, + { + type: 'grid', + name: '', + schema: [ + { name: 'tooltip', selector: { boolean: {} } }, + { name: 'compact', selector: { boolean: {} } }, + ], + }, + ]); + const entities = ctx.config.entities ?? []; + const view: ChartEditorContext = { + ...ctx, + config: sectorsAsText(ctx.config), + commit: (config) => ctx.commit(sectorsAsNumber(config)), + }; + const note = 'margin: 0 0 12px; color: var(--secondary-text-color); font-size: 0.875em; line-height: 1.4;'; + return html`

+ ${localize(ctx.hass, 'editor_wind_roles')} +
${localize(ctx.hass, 'editor_wind_now', { + direction: entityName(ctx, entities[0]), + speed: entityName(ctx, entities[1]), + })} +

+ ${ + ctx.config.source === 'statistics' + ? html`

${localize(ctx.hass, 'editor_wind_statistics')}

` + : nothing + } + ${chartForm(view, 'chart', schema, WIND_FIELDS)}`; +} diff --git a/src/gauge.ts b/src/gauge.ts new file mode 100644 index 0000000..6b3dad4 --- /dev/null +++ b/src/gauge.ts @@ -0,0 +1,182 @@ +import { polar } from './geometry'; +import type { CardConfig, StyleName } from './types'; + +/** + * Options the gauge styles (ring, arc, needle) share. Angles are degrees clockwise from the top + * (12 o'clock), as in geometry.ts: -90 is 9 o'clock, 90 is 3 o'clock, ±180 is 6 o'clock. + */ +export const CENTER_MODES = ['value', 'band', 'name', 'text', 'none'] as const; +export type CenterMode = (typeof CENTER_MODES)[number]; + +export interface GaugeOptions { + /** Set only when the card sets it; otherwise the style's own default angle. */ + startAngle?: number; + endAngle?: number; + center: CenterMode; + centerText: string; + tickLabels: boolean; +} + +/** Each gauge style's sweep when no angle is set. */ +export const DEFAULT_ANGLES: Partial> = { + ring: [-180, 180], + arc: [-135, 135], + needle: [-90, 90], +}; + +function angle(raw: unknown, field: string): number | undefined { + if (raw === undefined || raw === null || raw === '') return undefined; + const n = typeof raw === 'string' && raw.trim() !== '' ? Number(raw) : raw; + if (typeof n !== 'number' || !Number.isFinite(n) || n < -360 || n > 360) { + throw new Error(`"${field}" must be a number of degrees from -360 to 360`); + } + return n; +} + +/** Read and check the gauge options of a card config. Throws a readable Error. */ +export function parseGaugeOptions(raw: CardConfig, style: StyleName = raw.style ?? 'bar'): GaugeOptions { + const startAngle = angle(raw.start_angle, 'start_angle'); + const endAngle = angle(raw.end_angle, 'end_angle'); + const defaults = DEFAULT_ANGLES[style]; + if (defaults && (startAngle !== undefined || endAngle !== undefined)) { + const [a0, a1] = [startAngle ?? defaults[0], endAngle ?? defaults[1]]; + if (!(a1 > a0)) throw new Error(`"end_angle" (${a1}) must be greater than "start_angle" (${a0})`); + if (a1 - a0 > 360) throw new Error(`"end_angle" can be at most 360 degrees after "start_angle"`); + } + const center = raw.center ?? 'value'; + if (!CENTER_MODES.includes(center as CenterMode)) { + throw new Error(`"center" must be one of: ${CENTER_MODES.join(', ')}`); + } + if (raw.center_text !== undefined && raw.center_text !== null && typeof raw.center_text !== 'string') { + throw new Error('"center_text" must be text'); + } + if (raw.tick_labels !== undefined && typeof raw.tick_labels !== 'boolean') { + throw new Error('"tick_labels" must be true or false'); + } + const options: GaugeOptions = { + center: center as CenterMode, + centerText: (raw.center_text as string | undefined) ?? '', + tickLabels: raw.tick_labels === true, + }; + if (startAngle !== undefined) options.startAngle = startAngle; + if (endAngle !== undefined) options.endAngle = endAngle; + return options; +} + +/** The angles a gauge draws between: the card's, else the style's default. */ +export function gaugeAngles(defaults: [number, number], options?: GaugeOptions): [number, number] { + return [options?.startAngle ?? defaults[0], options?.endAngle ?? defaults[1]]; +} + +/** Whether the card changed the angles from the style's default. */ +export function customAngles(options?: GaugeOptions): boolean { + return options?.startAngle !== undefined || options?.endAngle !== undefined; +} + +/** The angle for a 0..1 position along a sweep. */ +export function angleAt([a0, a1]: [number, number], t: number): number { + return a0 + (a1 - a0) * t; +} + +export interface Box { + x0: number; + y0: number; + x1: number; + y1: number; +} + +/** The union of boxes. */ +export function union(...boxes: Box[]): Box { + return { + x0: Math.min(...boxes.map((b) => b.x0)), + y0: Math.min(...boxes.map((b) => b.y0)), + x1: Math.max(...boxes.map((b) => b.x1)), + y1: Math.max(...boxes.map((b) => b.y1)), + }; +} + +/** + * The box around an arc of radius `r` drawn with a stroke `pad` wide on each side (round caps + * included): its two ends and every compass point (0, 90, 180, 270 degrees) it passes. + */ +export function arcBox(cx: number, cy: number, r: number, a0: number, a1: number, pad: number): Box { + const angles = [a0, a1]; + for (let k = Math.ceil(a0 / 90); k * 90 <= a1; k++) angles.push(k * 90); + const points = angles.map((a) => polar(cx, cy, r, a)); + const box = { + x0: Math.min(...points.map((p) => p[0])), + y0: Math.min(...points.map((p) => p[1])), + x1: Math.max(...points.map((p) => p[0])), + y1: Math.max(...points.map((p) => p[1])), + }; + return { x0: box.x0 - pad, y0: box.y0 - pad, x1: box.x1 + pad, y1: box.y1 + pad }; +} + +/** A tick step of 1, 2, 2.5 or 5 times a power of ten, near `span / count`. */ +export function tickStep(span: number, count = 5): number { + if (!Number.isFinite(span) || span <= 0) return 1; + const raw = span / count; + const power = 10 ** Math.floor(Math.log10(raw)); + const f = raw / power; + return (f <= 1 ? 1 : f <= 2 ? 2 : f <= 2.5 ? 2.5 : f <= 5 ? 5 : 10) * power; +} + +/** + * Round tick values from `min` to `max`, about `count` steps apart. The scale's own ends are + * always labelled; a round tick too close to an end (under 40 % of a step) is dropped so the + * labels don't collide. + */ +export function scaleTicks(min: number, max: number, count = 5): number[] { + if (!Number.isFinite(min) || !Number.isFinite(max) || !(max > min)) return []; + const step = tickStep(max - min, count); + const inner: number[] = []; + for (let v = Math.ceil(min / step) * step; v < max - step * 0.4; v += step) { + if (v > min + step * 0.4) inner.push(Number(v.toPrecision(12))); + } + return [min, ...inner, max]; +} + +/** Decimals a tick needs: those of the step, or the value itself if it has more (a scale end). */ +export function tickDecimals(value: number, step: number): number { + const places = (n: number) => { + const text = String(Number(n.toPrecision(12))); + return text.includes('e') ? 0 : (text.split('.')[1]?.length ?? 0); + }; + return Math.min(3, Math.max(places(step), places(value))); +} + +/** Text anchor and vertical nudge (em) for a label placed outward at `deg`. */ +export function labelPlacement(deg: number): { anchor: 'start' | 'middle' | 'end'; dy: number } { + const rad = (deg * Math.PI) / 180; + const sin = Math.sin(rad); + const cos = Math.cos(rad); + const anchor = sin > 0.3 ? 'start' : sin < -0.3 ? 'end' : 'middle'; + // Labels above the centre sit on their baseline, those below hang from it, sides are centred. + const dy = cos > 0.3 ? 0 : cos < -0.3 ? 0.75 : 0.35; + return { anchor, dy }; +} + +/** What the gauge shows in place of its value: the value itself, or text. */ +export type CenterContent = { kind: 'value' } | { kind: 'text'; text: string; band: boolean } | { kind: 'none' }; + +/** + * The centre content for an available entity: its band's label in `band` mode (the value when the + * band has none), its name, the set text, or nothing. + */ +export function centerContent( + e: { name: string; band: { label?: string } }, + options: GaugeOptions | undefined, +): CenterContent { + switch (options?.center ?? 'value') { + case 'band': + return e.band.label ? { kind: 'text', text: e.band.label, band: true } : { kind: 'value' }; + case 'name': + return { kind: 'text', text: e.name, band: false }; + case 'text': + return options!.centerText ? { kind: 'text', text: options!.centerText, band: false } : { kind: 'none' }; + case 'none': + return { kind: 'none' }; + default: + return { kind: 'value' }; + } +} diff --git a/src/history/bearing.ts b/src/history/bearing.ts new file mode 100644 index 0000000..24da755 --- /dev/null +++ b/src/history/bearing.ts @@ -0,0 +1,50 @@ +/** The 16 compass points, clockwise from north, each 22.5° on from the last. */ +export const COMPASS_POINTS = [ + 'N', + 'NNE', + 'NE', + 'ENE', + 'E', + 'ESE', + 'SE', + 'SSE', + 'S', + 'SSW', + 'SW', + 'WSW', + 'W', + 'WNW', + 'NW', + 'NNW', +] as const; + +const BY_NAME = new Map(COMPASS_POINTS.map((name, i) => [name, i * 22.5])); +const WORDS: [RegExp, string][] = [ + [/north/g, 'n'], + [/south/g, 's'], + [/east/g, 'e'], + [/west/g, 'w'], +]; + +/** Degrees folded into 0 ≤ d < 360 (−90 is 270, 360 is 0). */ +export function normalizeDegrees(d: number): number { + const n = ((d % 360) + 360) % 360; + return n === 360 ? 0 : n; +} + +/** + * A wind direction reading as degrees clockwise from north (0 ≤ d < 360), or null when it can't + * be read. Takes a number of degrees ("225", "225°", -135) or a compass point in any case ("SW", + * "wsw", "North-East", "south west"). Anything else (unavailable, unknown, "variable") is null. + */ +export function parseBearing(raw: unknown): number | null { + if (typeof raw === 'number') return Number.isFinite(raw) ? normalizeDegrees(raw) : null; + if (typeof raw !== 'string') return null; + const text = raw.trim().toLowerCase(); + if (text === '') return null; + const number = Number(text.replace(/\s*°$/, '')); + if (Number.isFinite(number)) return normalizeDegrees(number); + let name = text.replace(/[\s_-]+/g, ''); + for (const [word, letter] of WORDS) name = name.replace(word, letter); + return BY_NAME.get(name.toUpperCase()) ?? null; +} diff --git a/src/history/fetch.ts b/src/history/fetch.ts index 92c8d0f..5d45e8c 100644 --- a/src/history/fetch.ts +++ b/src/history/fetch.ts @@ -1,9 +1,15 @@ import type { HomeAssistant } from '../types'; +import { parseBearing } from './bearing'; import type { Point, Range, SeriesRequest } from './types'; -/** A state string or attribute value as a number, or null for a gap. */ +/** + * A state string or attribute value as a number, or null for a gap. On and off (binary sensors, + * switches, lights) read as 1 and 0, so their changes are readings too. + */ export function toReading(raw: unknown): number | null { if (typeof raw === 'number') return Number.isFinite(raw) ? raw : null; + if (raw === 'on') return 1; + if (raw === 'off') return 0; if (typeof raw !== 'string' || raw.trim() === '') return null; const n = Number(raw); return Number.isFinite(n) ? n : null; @@ -18,14 +24,44 @@ interface CompressedState { lc?: number; } -/** One entity's history (Home Assistant's compressed format) as points, oldest first. */ -export function parseHistory(rows: CompressedState[] | undefined, attribute?: string): Point[] { +/** How a series' states become numbers: plain readings, or wind bearings that may be compass points. */ +export function readerFor(s: SeriesRequest): (raw: unknown) => number | null { + return s.bearing ? parseBearing : toReading; +} + +/** A state or attribute as a text point's words: numbers as written, anything else (absent, null) as none. */ +export function toText(raw: unknown): string | undefined { + if (typeof raw === 'string') return raw; + return typeof raw === 'number' || typeof raw === 'boolean' ? String(raw) : undefined; +} + +/** One reading as a point: a number (read with `read`), or for a text series its words (with `v` null). */ +export function toPoint( + t: number, + raw: unknown, + text = false, + read: (raw: unknown) => number | null = toReading, +): Point { + return text ? { t, v: null, s: toText(raw) } : { t, v: read(raw) }; +} + +/** + * One entity's history (Home Assistant's compressed format) as points, oldest first. `text` keeps + * the words instead of a number, for states like "heating" or "idle"; `read` turns a state into a + * number otherwise. + */ +export function parseHistory( + rows: CompressedState[] | undefined, + attribute?: string, + text = false, + read: (raw: unknown) => number | null = toReading, +): Point[] { const points: Point[] = []; for (const row of rows ?? []) { const time = row.lu ?? row.lc; if (time === undefined) continue; const raw = attribute ? row.a?.[attribute] : row.s; - points.push({ t: Math.round(time * 1000), v: toReading(raw) }); + points.push(toPoint(Math.round(time * 1000), raw, text, read)); } return points; } @@ -111,7 +147,9 @@ export async function loadHistory( no_attributes: true, }) .then((result) => { - for (const id of plain) out.set(key({ entity: id }), parseHistory(result?.[id])); + for (const s of series.filter((x) => !x.attribute)) { + out.set(key(s), parseHistory(result?.[s.entity], undefined, s.text, readerFor(s))); + } }), ); } @@ -121,7 +159,7 @@ export async function loadHistory( .callWS>({ ...base, entity_ids: withAttributes, no_attributes: false }) .then((result) => { for (const s of series.filter((x) => x.attribute)) { - out.set(key(s), parseHistory(result?.[s.entity], s.attribute)); + out.set(key(s), parseHistory(result?.[s.entity], s.attribute, s.text, readerFor(s))); } }), ); @@ -154,5 +192,7 @@ export async function loadStatistics( /** The map key for a series: its entity, plus the attribute when there is one. */ export function key(s: SeriesRequest): string { - return s.attribute ? `${s.entity}|${s.attribute}` : s.entity; + const base = s.attribute ? `${s.entity}|${s.attribute}` : s.entity; + if (s.text) return `${base}|text`; + return s.bearing ? `${base}#bearing` : base; } diff --git a/src/history/store.ts b/src/history/store.ts index 4e925a4..15ca7c3 100644 --- a/src/history/store.ts +++ b/src/history/store.ts @@ -1,5 +1,5 @@ import type { HomeAssistant } from '../types'; -import { defaultStatisticsQuery, key, loadHistory, loadStatistics, toReading } from './fetch'; +import { defaultStatisticsQuery, key, loadHistory, loadStatistics, readerFor, toPoint } from './fetch'; import type { StatisticsQuery } from './fetch'; import type { Point, Range, SeriesRequest, Source } from './types'; @@ -148,7 +148,7 @@ export class HistoryStore { const points = this.data.get(k) ?? []; const last = points[points.length - 1]; if (last && t <= last.t) continue; - points.push({ t, v: toReading(s.attribute ? state.attributes?.[s.attribute] : state.state) }); + points.push(toPoint(t, s.attribute ? state.attributes?.[s.attribute] : state.state, s.text, readerFor(s))); this.data.set(k, points); } } diff --git a/src/history/types.ts b/src/history/types.ts index 5d81977..451ded8 100644 --- a/src/history/types.ts +++ b/src/history/types.ts @@ -10,6 +10,8 @@ export interface Point { carried?: boolean; /** A grouped value: where its group ends (its `t` is where the group starts). */ end?: number; + /** Text series only (`SeriesRequest.text`): the state or attribute as written; `v` is then null. */ + s?: string; } /** Where a chart's readings come from. */ @@ -26,4 +28,8 @@ export interface SeriesRequest { entity: string; /** Read this attribute instead of the state (history only). */ attribute?: string; + /** Keep the words ("heating", "idle") in `Point.s` instead of reading a number (history only). */ + text?: boolean; + /** Read compass points (N, SSW) as degrees as well as numbers (history only; see `parseBearing`). */ + bearing?: boolean; } diff --git a/src/localize.ts b/src/localize.ts index a47d0f5..ef2c710 100644 --- a/src/localize.ts +++ b/src/localize.ts @@ -47,6 +47,18 @@ const STRINGS: Record> = { editor_style_ring: 'Ring', editor_style_needle: 'Needle', editor_style_arc: 'Arc', + editor_gauge_help: + "Angles are in degrees, clockwise from the top: -90 is the left, 90 the right. Leave them empty for the style's usual shape.", + editor_start_angle: 'Start angle', + editor_end_angle: 'End angle', + editor_center: 'In the middle', + editor_center_mode_value: 'The value', + editor_center_mode_band: "The colour band's label", + editor_center_mode_name: 'The name', + editor_center_mode_text: 'My own text', + editor_center_mode_none: 'Nothing', + editor_center_text: 'Text', + editor_tick_labels: 'Numbers on the scale', editor_show_name: 'Show name', editor_show_value: 'Show value', editor_show_icon: 'Show icon', diff --git a/src/styles/beeswarm.ts b/src/styles/beeswarm.ts new file mode 100644 index 0000000..2ed5ccb --- /dev/null +++ b/src/styles/beeswarm.ts @@ -0,0 +1,355 @@ +import { css, nothing, svg } from 'lit'; +import type { SVGTemplateResult, TemplateResult } from 'lit'; +import { + CATEGORY_FIELDS, + bandTooltip, + categoryGeometry, + categoryOptions, + categoryPlot, + categorySchema, + categoryStyles, + clockScale, + hoveredBand, + labelCount, + numberScale, + stopCategoryHover, + validateCategoryOptions, +} from '../chart/category-plot'; +import type { Category, CategoryGeometry, ValueScale } from '../chart/category-plot'; +import { readingsOf, sampleReadings, summarize } from '../chart/density'; +import type { Reading } from '../chart/density'; +import { + disconnectHistory, + frameMessage, + frameStyles, + historyFrame, + periodNav, + validateHistoryOptions, +} from '../chart/frame'; +import type { ChartSeries } from '../chart/frame'; +import { stopChartWidth } from '../chart/grid'; +import { chartFrame, chartLayout, layoutStyles } from '../chart/layout'; +import { dotsPath } from '../chart/series-style'; +import { fitSwarm, sizeFor } from '../chart/swarm'; +import { tooltipEnabled, tooltipStyles } from '../chart/tooltip'; +import { + HISTORY_FIELDS, + cachedSchema, + chartForm, + choice, + historySchema, + historyVariant, +} from '../editor/chart-editors'; +import type { FieldBinding } from '../editor/schemas'; +import { formatNumber, withUnit } from '../format'; +import { wallTime } from '../history/timezone'; +import type { Point } from '../history/types'; +import { localize, registerStrings } from '../localize'; +import type { CardConfig, HomeAssistant } from '../types'; +import type { ChartContext, ChartEditorContext, ChartStyleModule } from './index'; + +export const PLACE_BY = ['value', 'time_of_day'] as const; +export type PlaceBy = (typeof PLACE_BY)[number]; + +export const DEFAULT_DOT = 6; +export const MIN_DOT = 2; +export const MAX_DOT = 16; +/** At most this many dots per row; more are evenly sampled (the count still says how many). */ +const MAX_DOTS = 400; +/** A row's height across, before compact: swarms need more room than a violin. */ +const ROW_PX = 96; +/** Dot sizes when sized by value are rounded to this many steps, one path each. */ +const SIZE_STEPS = 6; + +registerStrings('en', { + editor_style_beeswarm: 'Beeswarm', + editor_place_by: 'Place each dot by', + editor_place_by_value: 'Its value', + editor_place_by_time_of_day: 'The time of day it happened', + editor_dot_size: 'Dot size (px)', + editor_size_by_value: 'Bigger dots for bigger values', + swarm_readings: '{n} readings', + swarm_events: '{n} times on', + swarm_shown: 'Dots shown', + swarm_none: 'No readings', + swarm_earliest_latest: 'Earliest – latest', + swarm_lowest_highest: 'Lowest – highest', +}); + +export interface SwarmOptions { + placeBy: PlaceBy; + dot: number; + sized: boolean; +} + +export function swarmOptions(raw: CardConfig): SwarmOptions { + return { + placeBy: (raw.place_by as PlaceBy | undefined) ?? 'value', + dot: typeof raw.dot_size === 'number' ? raw.dot_size : DEFAULT_DOT, + sized: raw.size_by_value === true, + }; +} + +/** Domains whose states are on and off: their dots are the times they turned on. */ +const ON_OFF = /^(binary_sensor|switch|input_boolean|light|fan|siren|automation)\./; + +/** + * The times something turned on: readings of 1 (on) after anything but 1. The state carried in + * from before the period is not an event, and nor is coming back on after being unavailable (a + * restart), when it was on before. + */ +export function onEvents(points: Point[]): Point[] { + const out: Point[] = []; + let prev: number | null = null; + for (const p of points) { + if (p.v === null) continue; + if (p.v === 1 && prev !== 1 && !p.carried) out.push(p); + prev = p.v; + } + return out; +} + +/** Hours since midnight (0–24) in the card's time zone. */ +export function hourOfDay(t: number, timeZone: string): number { + const w = wallTime(t, timeZone); + return w.hour + w.minute / 60 + w.second / 3600; +} + +/** One row of dots: the readings (or events), placed and sized. */ +interface Row { + series: ChartSeries; + /** Where along the axis, in axis units (a value, or hours). */ + positions: number[]; + /** The reading behind each dot, for sizing. */ + values: number[]; + total: number; + events: boolean; +} + +function rowsOf(frame: ReturnType, o: SwarmOptions, now: number): Row[] { + const until = Math.min(frame.range.end, now); + return frame.series.map((series) => { + const events = o.placeBy === 'time_of_day' && ON_OFF.test(series.entity.entityId); + let readings: Reading[]; + if (events) { + readings = onEvents(series.points).map((p) => ({ t: p.t, v: 1, w: 1, real: true })); + } else { + // Dots are readings taken in the period: not the value carried in, nor the edge copy. + readings = readingsOf(series.points, frame.source === 'history' && !frame.group, until).filter((r) => r.real); + } + const shown = sampleReadings(readings, MAX_DOTS); + return { + series, + positions: shown.map((r) => (o.placeBy === 'time_of_day' ? hourOfDay(r.t, frame.zone.timeZone) : r.v)), + values: shown.map((r) => r.v), + total: readings.length, + events, + }; + }); +} + +function detail(row: Row, hass: HomeAssistant | undefined): string { + if (row.total === 0) return localize(hass, 'swarm_none'); + const n = formatNumber(row.total, 0, hass); + const count = localize(hass, row.events ? 'swarm_events' : 'swarm_readings', { n }); + return count; +} + +/** One row's swarm: dots packed in px so they stay round and apart, then into plot units. */ +function drawRow(row: Row, i: number, g: CategoryGeometry, scale: ValueScale, o: SwarmOptions): SVGTemplateResult { + if (row.positions.length === 0) return svg``; + const r = o.dot / 2; + const span = scale.domain[1] - scale.domain[0] || 1; + const lo = Math.min(...row.values); + const hi = Math.max(...row.values); + const radii = row.values.map((v) => { + if (!o.sized || row.events) return r; + // Rounded to a few steps, so a row is a handful of paths rather than one per dot. + const exact = sizeFor(v, lo, hi, r); + return Math.max(0.5, Math.round((exact / r) * SIZE_STEPS) / SIZE_STEPS) * r; + }); + const dots = row.positions.map((p, k) => ({ at: ((p - scale.domain[0]) / span) * g.valuePx, r: radii[k] })); + const { offsets, scale: shrink } = fitSwarm(dots, g.bandPx / 2 - 1, 1); + const bySize = new Map(); + dots.forEach((d, k) => { + const size = Math.round(d.r * shrink * 2 * 100) / 100; + const list = bySize.get(size) ?? []; + list.push(g.at(row.positions[k], i, offsets[k] / g.bandPx)); + bySize.set(size, list); + }); + return svg` + ${[...bySize].map(([size, pts]) => svg``)} + `; +} + +function tooltipRows(row: Row, scale: ValueScale, clock: boolean, hass: HomeAssistant | undefined): [string, string][] { + const s = summarize(row.positions.map((v, i) => ({ t: i, v, w: 1, real: true }))); + if (!s) return []; + const f = (v: number) => + clock ? scale.format(v) : withUnit(formatNumber(v, row.series.entity.decimals, hass), row.series.entity.unit); + return [ + [localize(hass, 'dist_count'), formatNumber(row.total, 0, hass)], + ...(row.positions.length < row.total + ? ([[localize(hass, 'swarm_shown'), formatNumber(row.positions.length, 0, hass)]] as [string, string][]) + : []), + [localize(hass, 'dist_median'), f(s.median)], + [localize(hass, 'dist_middle'), `${f(s.q1)} – ${f(s.q3)}`], + // By time of day the ends are the earliest and latest times; by value, the lowest and highest readings. + [localize(hass, clock ? 'swarm_earliest_latest' : 'swarm_lowest_highest'), `${f(s.min)} – ${f(s.max)}`], + ]; +} + +const SWARM_FIELDS: FieldBinding[] = [ + ...HISTORY_FIELDS, + { name: 'place_by', path: ['place_by'], default: 'value' }, + { name: 'dot_size', path: ['dot_size'], default: DEFAULT_DOT }, + { name: 'size_by_value', path: ['size_by_value'], default: false }, + ...CATEGORY_FIELDS, +]; + +/** Fixed value ends only mean something when dots are placed by value. */ +function withoutValueRange(schema: unknown[]): unknown[] { + return schema.map((item) => { + const grid = item as { schema?: { name: string }[] }; + if (!grid.schema) return item; + return { ...grid, schema: grid.schema.filter((s) => s.name !== 'value_min' && s.name !== 'value_max') }; + }); +} + +export function swarmEditor(ctx: ChartEditorContext): TemplateResult { + const o = swarmOptions(ctx.config); + const schema = cachedSchema(`beeswarm|${historyVariant(ctx.config)}|${o.placeBy}`, ctx.hass, () => [ + ...historySchema(ctx.hass, ctx.config), + { name: 'place_by', selector: choice(ctx.hass, 'place_by', PLACE_BY) }, + { + type: 'grid', + name: '', + schema: [ + { + name: 'dot_size', + selector: { number: { min: MIN_DOT, max: MAX_DOT, step: 1, mode: 'box', unit_of_measurement: 'px' } }, + }, + { name: 'size_by_value', selector: { boolean: {} } }, + ], + }, + ...(o.placeBy === 'value' ? categorySchema(ctx.hass) : withoutValueRange(categorySchema(ctx.hass))), + ]); + return chartForm(ctx, 'chart', schema, SWARM_FIELDS); +} + +const ENTITY_FIELDS = { + schema: [{ name: 'fill_color', selector: { text: {} } }], + fields: [{ name: 'fill_color', path: ['color'] }], +}; + +registerStrings('en', { editor_fill_color: 'Colour' }); + +export const beeswarm: ChartStyleModule = { + layout: 'chart', + group: 'distribution', + height: 260, + styles: css` + ${frameStyles} + ${tooltipStyles} + ${layoutStyles} + ${categoryStyles} + .swarm .dots { + fill: none; + stroke-linecap: round; + stroke-opacity: 0.85; + } + `, + thumbnail: () => svg` + + + + + + + + + + `, + validate(raw: CardConfig) { + validateHistoryOptions(raw); + validateCategoryOptions(raw); + if (raw.place_by !== undefined && !PLACE_BY.includes(raw.place_by as PlaceBy)) { + throw new Error(`"place_by" must be one of: ${PLACE_BY.join(', ')}`); + } + const dot = raw.dot_size; + if (dot !== undefined && !(typeof dot === 'number' && dot >= MIN_DOT && dot <= MAX_DOT)) { + throw new Error(`"dot_size" must be a number from ${MIN_DOT} to ${MAX_DOT}`); + } + if (raw.size_by_value !== undefined && typeof raw.size_by_value !== 'boolean') { + throw new Error('"size_by_value" must be true or false'); + } + }, + editor: swarmEditor, + entityFields: () => ENTITY_FIELDS, + disconnect(host) { + disconnectHistory(host); + stopChartWidth(host); + stopCategoryHover(host); + }, + render(ctx: ChartContext) { + const now = Date.now(); + const frame = historyFrame(ctx, now); + const nav = periodNav(frame, ctx); + const layout = { ...chartLayout(ctx.raw), header: 'above' as const }; + const message = frameMessage(frame, ctx); + if (message) return chartFrame(layout, nav, message); + const o = swarmOptions(ctx.raw); + const rows = rowsOf(frame, o, now); + const options = categoryOptions(ctx.raw); + const probe = categoryGeometry(ctx, rows.length, { domain: [0, 1] }, ROW_PX); + const count = labelCount(probe.valuePx, options.orientation); + let scale: ValueScale; + if (o.placeBy === 'time_of_day') scale = clockScale(count, ctx.hass); + else { + const all = rows.flatMap((r) => r.positions); + scale = numberScale(all.length ? [Math.min(...all), Math.max(...all)] : undefined, ctx.raw, count, ctx.hass); + } + const geometry = categoryGeometry(ctx, rows.length, scale, ROW_PX); + const content = svg`${rows.map((row, i) => drawRow(row, i, geometry, scale, o))}`; + const categories: Category[] = rows.map((row) => { + const d = detail(row, ctx.hass); + return { + index: row.series.index, + name: row.series.entity.name, + detail: d, + color: row.series.color, + label: `${row.series.entity.name}, ${d}`, + }; + }); + const tooltip = tooltipEnabled(ctx.raw); + const at = tooltip ? hoveredBand(ctx.host) : undefined; + const hovered = at === undefined ? undefined : rows[at]; + const label = localize(ctx.hass, 'chart_aria', { + style: localize(ctx.hass, 'editor_style_beeswarm'), + names: frame.series.map((s) => s.entity.name).join(', '), + }); + return chartFrame( + layout, + nav, + categoryPlot({ + ctx, + geometry, + categories, + scale, + content, + label, + hover: tooltip, + overlay: + hovered && hovered.total > 0 && at !== undefined + ? bandTooltip( + geometry, + at, + hovered.series.entity.name, + tooltipRows(hovered, scale, o.placeBy === 'time_of_day', ctx.hass), + hovered.series.color, + ) + : nothing, + }), + ); + }, +}; diff --git a/src/styles/box-plot.ts b/src/styles/box-plot.ts index a71975c..d34a518 100644 --- a/src/styles/box-plot.ts +++ b/src/styles/box-plot.ts @@ -72,13 +72,13 @@ registerStrings('en', { box_mean: 'Average', box_count: 'Readings', box_outliers: 'Outliers', - editor_orientation: 'Boxes', + editor_orientation: 'Direction', editor_orientation_vertical: 'Upright', - editor_orientation_horizontal: 'On their side', + editor_orientation_horizontal: 'Across', 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_readings: 'Show each reading as a dot', editor_box_color: 'Box colour', }); diff --git a/src/styles/bullet.ts b/src/styles/bullet.ts new file mode 100644 index 0000000..73c0265 --- /dev/null +++ b/src/styles/bullet.ts @@ -0,0 +1,147 @@ +import { css, html, nothing, svg } from 'lit'; +import { cachedSchema, chartForm, choice } from '../editor/chart-editors'; +import { entityMark, markStyles, rangeFill, rangeSegments, targetText } from '../chart/marks'; +import { localize, registerStrings } from '../localize'; +import type { ResolvedEntity } from '../resolve'; +import type { ChartContext, ChartEditorContext, ChartStyleModule } from './index'; +import type { CardConfig } from '../types'; + +export const RANGE_MODES = ['shades', 'colours'] as const; +export type RangeMode = (typeof RANGE_MODES)[number]; + +registerStrings('en', { + editor_style_bullet: 'Bullet', + editor_ranges: 'Ranges behind the bar', + editor_ranges_shades: 'Shades of grey', + editor_ranges_colours: 'Colour band colours', + compare_target: 'target {target}', + chart_aria: '{style} chart of {names}', +}); + +function bulletRow(e: ResolvedEntity, index: number, ctx: ChartContext, mode: RangeMode) { + const { show } = ctx.config; + const segments = rangeSegments(e); + const sub = show.target ? targetText(e, ctx.hass) : undefined; + const value = show.value || !e.available; + const inner = html` + ${ + show.name + ? html`
+ ${e.name}${sub ? html`${sub}` : nothing} +
` + : nothing + } +
+ ${segments.map( + (s) => + html`
`, + )} + ${e.available ? html`
` : nothing} + ${show.target && e.available && e.targetPosition !== null ? html`
` : nothing} +
+ ${value ? html`${e.display}` : html``} + `; + return entityMark(e, index, inner, 'bullet-row'); +} + +export const bullet: ChartStyleModule = { + layout: 'chart', + group: 'compare', + height: 120, + styles: css` + ${markStyles} + .style-bullet .bullets { + display: grid; + grid-template-columns: minmax(0, auto) minmax(96px, 1fr) fit-content(8em); + column-gap: 12px; + } + .style-bullet .bullets.no-name { + grid-template-columns: minmax(96px, 1fr) fit-content(8em); + } + .style-bullet .bullet-row { + display: grid; + grid-column: 1 / -1; + grid-template-columns: subgrid; + align-items: center; + padding: 6px 0; + } + .style-bullet .label { + display: flex; + flex-direction: column; + min-width: 0; + max-width: 14em; + } + .style-bullet .track { + position: relative; + height: 28px; + border-radius: 6px; + overflow: hidden; + background: var(--cd-track); + } + .style-bullet .range { + position: absolute; + top: 0; + bottom: 0; + } + .style-bullet .fill { + position: absolute; + top: 30%; + bottom: 30%; + left: calc(var(--start) * 100%); + width: calc((var(--end) - var(--start)) * 100%); + border-radius: 4px; + background: var(--c-start); + background-image: linear-gradient(90deg, var(--c-start), color-mix(in srgb, var(--c-start) 80%, #fff)); + transition: + left var(--cd-duration) var(--cd-easing), + width var(--cd-duration) var(--cd-easing); + } + .style-bullet .target { + position: absolute; + top: 3px; + bottom: 3px; + width: 3px; + border-radius: 2px; + left: calc(var(--target) * (100% - 3px)); + background: var(--primary-text-color); + transition: left var(--cd-duration) var(--cd-easing); + } + .style-bullet .value { + text-align: right; + min-width: calc(var(--cd-value-ch, 4) * 0.62em); + } + `, + thumbnail: () => svg` + + + + + + + + `, + validate(raw: CardConfig) { + if (raw.ranges !== undefined && !RANGE_MODES.includes(raw.ranges as RangeMode)) { + throw new Error(`"ranges" must be one of: ${RANGE_MODES.join(', ')}`); + } + }, + editor(ctx: ChartEditorContext) { + const schema = cachedSchema('bullet', ctx.hass, () => [ + { name: 'ranges', selector: choice(ctx.hass, 'ranges', RANGE_MODES) }, + ]); + return chartForm(ctx, 'chart', schema, [{ name: 'ranges', path: ['ranges'], default: 'shades' }]); + }, + render(ctx) { + const mode = (ctx.raw.ranges as RangeMode | undefined) ?? 'shades'; + const label = localize(ctx.hass, 'chart_aria', { + style: localize(ctx.hass, 'editor_style_bullet'), + names: ctx.items.map((e) => e.name).join(', '), + }); + return html`
+ ${ctx.items.map((e, i) => bulletRow(e, i, ctx, mode))} +
`; + }, +}; diff --git a/src/styles/candlestick.ts b/src/styles/candlestick.ts new file mode 100644 index 0000000..a862bca --- /dev/null +++ b/src/styles/candlestick.ts @@ -0,0 +1,439 @@ +import { css, html, nothing, svg } from 'lit'; +import type { SVGTemplateResult, TemplateResult } from 'lit'; +import { baseColor } from '../bands'; +import { formatDateTime, validateAxisOptions } from '../chart/axes'; +import { candleExtent, historyCandles, statisticsCandles, underValues } from '../chart/candles'; +import type { Candle } from '../chart/candles'; +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, gridStyles, plotAxes, plotWidth, stopChartWidth } from '../chart/grid'; +import { chartFrame, chartLayout, headerOver, layoutStyles, validateLayout } from '../chart/layout'; +import { runPath, runs } from '../chart/paths'; +import { linear } from '../chart/scale'; +import { + hoverHandlers, + hoverTime, + nearestIndex, + stopHover, + tooltipEnabled, + tooltipSide, + tooltipStyles, + validateTooltipOptions, +} from '../chart/tooltip'; +import { + AXES_FIELDS, + HISTORY_FIELDS, + LAYOUT_FIELDS, + axesSchema, + cachedSchema, + chartForm, + choice, + historySchema, + layoutSchema, +} from '../editor/chart-editors'; +import type { FieldBinding } from '../editor/schemas'; +import { key } from '../history/fetch'; +import type { StatisticsPeriod, StatisticsQuery } from '../history/fetch'; +import { AGGREGATES, GROUP_UNITS } from '../history/group'; +import type { Aggregate, 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 { Zone } from '../history/timezone'; +import type { Point, Range, Source } from '../history/types'; +import { formatNumber, withUnit } from '../format'; +import { localize, registerStrings } from '../localize'; +import type { CardConfig, HomeAssistant } from '../types'; +import type { ChartContext, ChartEditorContext, ChartStyleModule } from './index'; + +export const UNDERNEATH = ['columns', 'line'] as const; +export type Underneath = (typeof UNDERNEATH)[number]; + +/** A candle's body is this share of its period's width. */ +const BODY = 0.7; +/** With series underneath, the candles take the top of the plot and they take this much of the bottom. */ +const STRIP = 0.22; +/** The gap between the candles and the strip, as a share of the plot's height. */ +const STRIP_GAP = 0.05; +/** The thinnest body (a period that closed where it opened), in plot units. */ +const MIN_BODY = 1.5; + +registerStrings('en', { + editor_style_candlestick: 'Candlestick', + editor_candle_by: 'One candle per', + editor_under_aggregate: 'Entities underneath: combine readings with', + editor_up_color: 'Colour when it rose', + editor_down_color: 'Colour when it fell', + editor_underneath: 'Draw the other entities underneath as', + editor_underneath_columns: 'Columns', + editor_underneath_line: 'A line', + candle_open: 'Open', + candle_high: 'High', + candle_low: 'Low', + candle_close: 'Close', + candle_week_of: 'Week of {date}', +}); + +/** The card's period, with a candlestick's defaults: the last 14 days. */ +export function candlePeriod(raw: CardConfig): CardConfig['period'] { + const own = raw.period as Record | undefined; + return { last: 14, unit: 'day', ...(own ?? {}) } as CardConfig['period']; +} + +function unitOf(raw: CardConfig): GroupUnit { + return (raw.group_by as GroupUnit | undefined) ?? 'day'; +} + +function sourceOf(raw: CardConfig): Source { + return (raw.source as Source | undefined) ?? 'statistics'; +} + +/** + * Statistics rows to build candles from: five-minute rows for hourly candles, hourly rows for + * daily ones (an open and close within the day), and daily rows for weeks and months when Home + * Assistant's days are the card's own (otherwise hourly, cut up here). Mean, min, max and state, + * so a measurement and a meter both have something. + */ +export function candleQuery(unit: GroupUnit, zone: Zone, serverTimeZone: string | undefined): StatisticsQuery { + let period: StatisticsPeriod = 'hour'; + if (unit === 'hour') period = '5minute'; + else if (unit !== 'day' && zone.timeZone === serverTimeZone) period = 'day'; + return { period, types: ['mean', 'min', 'max', 'state'], value: 'mean' }; +} + +interface CandleFrame extends HistoryFrame { + /** The first entity's candles, one per period (null where there is no reading). */ + candles: (Candle | null)[]; + unit: GroupUnit; +} + +/** + * Load the card's readings and turn them into candles (the first entity) and one value per + * period for the rest. Like the line chart's history frame, but the candles need every reading + * in a period, not one combined value, and the range always runs to the end of the last period. + */ +function candleFrame(ctx: ChartContext, now = Date.now()): CandleFrame { + const period = parsePeriod(candlePeriod(ctx.raw)); + const source = sourceOf(ctx.raw); + const unit = unitOf(ctx.raw); + const aggregate = (ctx.raw.aggregate as Aggregate | undefined) ?? 'mean'; + const zone = zoneFor(ctx.hass); + const span = periodRange(period, now, zone, period.navigate ? stepsFor(ctx.host, period) : 0); + const start = startOf(unit, span.start, zone); + const bounds = boundaries(unit, start, span.end, zone); + const range: Range = { start, end: bounds[bounds.length - 1] }; + const until = Math.min(span.end, now); + const requests = ctx.items.map((e) => ({ entity: e.entityId, attribute: e.config.attribute })); + const store = storeFor(ctx.host, ctx.requestUpdate); + const statistics = source === 'statistics' ? candleQuery(unit, zone, ctx.hass.config?.time_zone) : undefined; + const loaded = store.get(ctx.hass, requests, { start, end: span.end }, source, { live: span.live, statistics }); + let candles: (Candle | null)[] = []; + const series = ctx.items.map((entity, index): ChartSeries => { + const own = entity.config as typeof entity.config & { color?: string }; + const points = loaded.data.get(key(requests[index])) ?? []; + let grouped: Point[]; + if (index === 0) { + candles = + source === 'statistics' ? statisticsCandles(points, bounds, until) : historyCandles(points, bounds, until); + grouped = candles.map((c, i) => ({ t: bounds[i], end: bounds[i + 1], v: c ? c.close : null })); + } else { + grouped = underValues(points, bounds, aggregate, source, until); + } + return { + index, + entity, + points: grouped, + color: baseColor(own.color ?? (index === 0 ? SERIES_COLORS[0] : 'grey')), + curve: 'smooth', + dash: false, + }; + }); + return { + status: loaded.status, + error: loaded.error, + range, + period, + source, + series, + live: span.live, + back: span.back, + zone, + group: { unit, aggregate }, + candles, + unit, + }; +} + +/** A period's name in the tooltip: a time for hours, a date for days, "Week of …", a month. */ +export function periodTitle(t: number, unit: GroupUnit, hass: HomeAssistant | undefined, zone: Zone): string { + if (unit === 'hour') return formatDateTime(t, hass); + const options: Intl.DateTimeFormatOptions = + unit === 'month' + ? { month: 'long', year: 'numeric', timeZone: zone.timeZone } + : { weekday: unit === 'day' ? 'short' : undefined, day: 'numeric', month: 'short', timeZone: zone.timeZone }; + let format: Intl.DateTimeFormat; + try { + format = new Intl.DateTimeFormat(hass?.locale?.language ?? hass?.language ?? 'en', options); + } catch { + format = new Intl.DateTimeFormat('en', options); + } + const date = format.format(t); + return unit === 'week' ? localize(hass, 'candle_week_of', { date }) : date; +} + +const pct = (n: number) => `${Math.round(n * 10000) / 100}%`; + +function candleTooltip(ctx: ChartContext, frame: CandleFrame, x: (t: number) => number, t: number | undefined) { + if (t === undefined) return nothing; + const mids = frame.series[0].points.map((p) => ({ t: (p.t + (p.end ?? p.t)) / 2 })); + const i = nearestIndex(mids, t); + if (i < 0) return nothing; + const c = frame.candles[i]; + const at = x(mids[i].t) / VIEW_W; + const side = tooltipSide(at, plotWidth(ctx)); + const first = frame.series[0].entity; + const fmt = (v: number, s = first) => withUnit(formatNumber(v, s.decimals, ctx.hass), s.unit); + const rows: [string, string, string?][] = c + ? [ + [localize(ctx.hass, 'candle_open'), fmt(c.open)], + [localize(ctx.hass, 'candle_high'), fmt(c.high)], + [localize(ctx.hass, 'candle_low'), fmt(c.low)], + [localize(ctx.hass, 'candle_close'), fmt(c.close)], + ] + : [[first.name, '—']]; + for (const s of frame.series.slice(1)) { + const v = s.points[i]?.v; + rows.push([s.entity.name, v === null || v === undefined ? '—' : fmt(v, s.entity), s.color]); + } + return html``; +} + +/** The series under the candles, each scaled to its own largest value, in the strip at the bottom. */ +function underneath(frame: CandleFrame, x: (t: number) => number, kind: Underneath): SVGTemplateResult { + const under = frame.series.slice(1); + const top = VIEW_H * (1 - STRIP); + return svg`${under.map((s, n) => { + const values = s.points.map((p) => p.v).filter((v): v is number => v !== null); + const lo = Math.min(0, ...values); + const hi = Math.max(0, ...values); + const y = linear([lo, hi === lo ? lo + 1 : hi], [VIEW_H, top]); + if (kind === 'line') { + const d = runs(s.points) + .map((run) => + runPath( + run.map((p) => [x((p.t + (p.end ?? p.t)) / 2), y(p.v as number)]), + 'straight', + ), + ) + .join(''); + return svg``; + } + return svg` + ${s.points.map((p) => { + if (p.v === null) return nothing; + const x0 = x(p.t); + const w = x(p.end ?? p.t) - x0; + const each = (w * BODY) / under.length; + const left = x0 + (w * (1 - BODY)) / 2 + n * each; + const y0 = y(Math.max(0, p.v)); + return svg``; + })} + `; + })}`; +} + +const CANDLE_FIELDS: FieldBinding[] = [ + ...HISTORY_FIELDS.map((f) => { + if (f.name === 'period_last') return { ...f, default: 14 }; + if (f.name === 'period_unit') return { ...f, default: 'day' }; + if (f.name === 'source') return { ...f, default: 'statistics' }; + if (f.name === 'group_by') return { name: 'candle_by', path: ['group_by'], default: 'day' }; + if (f.name === 'aggregate') return { name: 'under_aggregate', path: ['aggregate'], default: 'mean' }; + return f; + }), + { name: 'up_color', path: ['up_color'], default: 'green' }, + { name: 'down_color', path: ['down_color'], default: 'red' }, + { name: 'underneath', path: ['underneath'], default: 'columns' }, + ...AXES_FIELDS, + ...LAYOUT_FIELDS, +]; + +/** The history fields, with "one candle per" in place of grouping (candles are always grouped). */ +function candleHistorySchema(hass: HomeAssistant | undefined, config: CardConfig): unknown[] { + return historySchema(hass, { ...config, group_by: unitOf(config) }).map((item) => { + const grid = item as { schema?: { name: string }[] }; + if (!grid.schema?.some((s) => s.name === 'group_by')) return item; + return { + ...grid, + schema: [ + { name: 'candle_by', selector: choice(hass, 'group_by', GROUP_UNITS) }, + { name: 'under_aggregate', selector: choice(hass, 'aggregate', AGGREGATES) }, + ], + }; + }); +} + +export function candleEditor(ctx: ChartEditorContext): TemplateResult { + const calendar = (ctx.config.period as { calendar?: unknown } | undefined)?.calendar ? 'calendar' : 'rolling'; + const schema = cachedSchema(`candlestick|${calendar}`, ctx.hass, () => [ + ...candleHistorySchema(ctx.hass, ctx.config), + { + type: 'grid', + name: '', + schema: [ + { name: 'up_color', selector: { text: {} } }, + { name: 'down_color', selector: { text: {} } }, + ], + }, + { name: 'underneath', selector: choice(ctx.hass, 'underneath', UNDERNEATH) }, + ...axesSchema(ctx.hass), + ...layoutSchema(ctx.hass), + ]); + return chartForm(ctx, 'chart', schema, CANDLE_FIELDS); +} + +const ENTITY_FIELDS = { + schema: [{ name: 'fill_color', selector: { text: {} } }], + fields: [{ name: 'fill_color', path: ['color'] }], +}; + +registerStrings('en', { editor_fill_color: 'Colour' }); + +export const candlestick: ChartStyleModule = { + layout: 'chart', + group: 'history', + height: 240, + styles: css` + ${frameStyles} + ${gridStyles} + ${tooltipStyles} + ${layoutStyles} + .candle { + cursor: pointer; + } + .candle .wick { + stroke-width: 1.5px; + } + .under-columns rect { + opacity: 0.45; + } + .under-line { + stroke-width: 2px; + stroke-linejoin: round; + opacity: 0.7; + } + `, + thumbnail: () => svg` + + + + + + + + + + `, + validate(raw: CardConfig) { + validateHistoryOptions(raw); + validateAxisOptions(raw); + validateTooltipOptions(raw); + validateLayout(raw); + parsePeriod(candlePeriod(raw)); + for (const k of ['up_color', 'down_color']) { + if (raw[k] !== undefined && (typeof raw[k] !== 'string' || raw[k] === '')) { + throw new Error(`"${k}" must be a colour name or CSS colour`); + } + } + if (raw.underneath !== undefined && !UNDERNEATH.includes(raw.underneath as Underneath)) { + throw new Error(`"underneath" must be one of: ${UNDERNEATH.join(', ')}`); + } + }, + editor: candleEditor, + entityFields: () => ENTITY_FIELDS, + disconnect(host) { + disconnectHistory(host); + stopChartWidth(host); + stopHover(host); + }, + render(ctx) { + const frame = candleFrame(ctx); + const nav = periodNav(frame, ctx); + const names = seriesHeader(frame, ctx); + const header = nav === nothing && names === nothing ? nothing : html`${nav}${names}`; + const message = frameMessage(frame, ctx); + const layout = chartLayout(ctx.raw); + if (message) return chartFrame(layout.header === 'over' ? { ...layout, header: 'above' } : layout, header, message); + const axes = plotAxes(ctx, frame.range, candleExtent(frame.candles)); + const strip = frame.series.length > 1; + if (strip) { + // Candles in the top of the plot, the other entities in a strip at the bottom. + axes.scales.y = linear(axes.value.domain, [VIEW_H * (1 - STRIP - STRIP_GAP), 0]); + } + const { x, y } = axes.scales; + const up = baseColor((ctx.raw.up_color as string | undefined) ?? 'green'); + const down = baseColor((ctx.raw.down_color as string | undefined) ?? 'red'); + const first = frame.series[0]; + const candles = frame.candles.map((c) => { + if (!c) return nothing; + const x0 = x(c.t); + const w = x(c.end) - x0; + const mid = x0 + w / 2; + const color = c.close >= c.open ? up : down; + const top = y(Math.max(c.open, c.close)); + const height = Math.max(MIN_BODY, y(Math.min(c.open, c.close)) - top); + return svg` + + + `; + }); + const kind = (ctx.raw.underneath as Underneath | undefined) ?? 'columns'; + const content = svg`${strip ? underneath(frame, x, kind) : nothing}${candles}`; + const tooltip = tooltipEnabled(ctx.raw); + const label = localize(ctx.hass, 'chart_aria', { + style: localize(ctx.hass, 'editor_style_candlestick'), + names: frame.series.map((s) => s.entity.name).join(', '), + }); + const mids = first.points.map((p) => ({ t: (p.t + (p.end ?? p.t)) / 2, v: p.v })); + return chartFrame( + layout, + header, + chartArea({ + axes, + ctx, + content, + label, + overlay: html`${layout.header === 'over' ? headerOver(header) : nothing} + ${tooltip ? candleTooltip(ctx, frame, x, hoverTime(ctx.host)) : nothing}`, + hover: tooltip ? hoverHandlers(ctx, frame.range, [mids]) : undefined, + }), + ); + }, +}; diff --git a/src/styles/climate.ts b/src/styles/climate.ts new file mode 100644 index 0000000..f32c466 --- /dev/null +++ b/src/styles/climate.ts @@ -0,0 +1,458 @@ +import { css, html, nothing, svg } from 'lit'; +import type { TemplateResult } from 'lit'; +import { baseColor } from '../bands'; +import { formatDateTime, validateAxisOptions } from '../chart/axes'; +import { + CLIMATE_ATTRIBUTES, + actionKind, + actionSpans, + climateData, + climateRequests, + holdRuns, + rangeBandPath, + rangePoints, + valueAt, +} from '../chart/climate-data'; +import type { ActionKind, ClimateData } from '../chart/climate-data'; +import { downsample } from '../chart/downsample'; +import { VIEW_H, VIEW_W, disconnectHistory, frameStyles, periodNav, validateHistoryOptions } from '../chart/frame'; +import type { HistoryFrame } from '../chart/frame'; +import { chartArea, gridStyles, plotAxes, plotWidth, stopChartWidth } from '../chart/grid'; +import type { PlotScales } from '../chart/grid'; +import { chartFrame, chartLayout, headerOver, layoutStyles, validateLayout } from '../chart/layout'; +import { runPath, runs } from '../chart/paths'; +import type { Curve } from '../chart/paths'; +import { extent } from '../chart/scale'; +import { DEFAULT_LINE_WIDTH, validateSeriesStyle } from '../chart/series-style'; +import { + hoverHandlers, + hoverTime, + stopHover, + tooltipEnabled, + tooltipSide, + tooltipStyles, + validateTooltipOptions, +} from '../chart/tooltip'; +import { climateEditor } from '../editor/climate-editor'; +import { formatNumber, withUnit } from '../format'; +import { key } from '../history/fetch'; +import { parsePeriod, periodRange, stepsFor } from '../history/period'; +import { storeFor } from '../history/store'; +import { zoneFor } from '../history/timezone'; +import type { Point, Range } from '../history/types'; +import { localize, registerStrings } from '../localize'; +import type { ResolvedEntity } from '../resolve'; +import type { CardConfig, HomeAssistant } from '../types'; +import type { ChartContext, ChartStyleModule } from './index'; + +/** Room-temperature points kept after thinning: about two per pixel on a wide card. */ +const MAX_POINTS = 500; + +registerStrings('en', { + editor_style_climate: 'Climate', + climate_set_point: 'Set point', + climate_range: '{low} – {high}', + climate_not_thermostat: '{name} is not a thermostat. Pick a climate entity.', + climate_action_heating: 'Heating', + climate_action_preheating: 'Preheating', + climate_action_cooling: 'Cooling', + climate_action_defrosting: 'Defrosting', + climate_action_drying: 'Drying', + climate_action_fan: 'Fan', + climate_action_idle: 'Idle', + climate_action_off: 'Off', +}); + +/** The card's climate options with defaults filled in. */ +export interface ClimateOptions { + setPoint: boolean; + shading: boolean; + shadeFan: boolean; + colors: { current: string; setPoint: string } & Record; +} + +/** Colours for each kind of running, from Home Assistant's own climate theme variables. */ +const ACTION_COLORS: Record = { + heat: 'var(--state-climate-heat-color, #ff8100)', + cool: 'var(--state-climate-cool-color, #2b9af9)', + dry: 'var(--state-climate-dry-color, #efbd07)', + fan: 'var(--state-climate-fan_only-color, #8a8a8a)', +}; + +const BOOLEANS = ['set_point', 'shading', 'shade_fan'] as const; +const COLORS = ['current_color', 'set_point_color', 'heating_color', 'cooling_color'] as const; + +/** Throw a readable error for a bad climate option. */ +export function validateClimateOptions(raw: CardConfig): void { + if (raw.source !== undefined && raw.source !== 'history') { + throw new Error('"source" must be history: long-term statistics don\'t keep a thermostat\'s set point or action'); + } + if (raw.group_by !== undefined) throw new Error('"group_by" is not available on a climate chart'); + for (const k of BOOLEANS) { + if (raw[k] !== undefined && typeof raw[k] !== 'boolean') throw new Error(`"${k}" must be true or false`); + } + for (const k of COLORS) { + if (raw[k] !== undefined && (typeof raw[k] !== 'string' || raw[k] === '')) { + throw new Error(`"${k}" must be a colour name or CSS colour`); + } + } +} + +export function climateOptions(raw: CardConfig): ClimateOptions { + const color = (k: (typeof COLORS)[number], fallback: string) => baseColor((raw[k] as string | undefined) ?? fallback); + return { + setPoint: raw.set_point !== false, + shading: raw.shading !== false, + shadeFan: raw.shade_fan === true, + colors: { + current: color('current_color', 'primary'), + setPoint: color('set_point_color', 'red'), + heat: color('heating_color', ACTION_COLORS.heat), + cool: color('cooling_color', ACTION_COLORS.cool), + dry: ACTION_COLORS.dry, + fan: ACTION_COLORS.fan, + }, + }; +} + +/** An `hvac_action` in words: Home Assistant's own translation when it has one, else ours. */ +export function actionLabel(hass: HomeAssistant | undefined, entity: ResolvedEntity, action: string): string { + if (entity.stateObj && hass?.formatEntityAttributeValue) { + const text = hass.formatEntityAttributeValue(entity.stateObj, CLIMATE_ATTRIBUTES.action, action); + if (text) return text; + } + const own = localize(hass, `climate_action_${action}`); + return own === `climate_action_${action}` ? action : own; +} + +interface Temps { + unit: string; + decimals: number; + text(v: unknown): string; +} + +/** How this thermostat's temperatures are written: the card's unit and decimals, else HA's unit and one decimal. */ +function temps(entity: ResolvedEntity, hass: HomeAssistant): Temps { + const system = (hass.config as { unit_system?: { temperature?: string } } | undefined)?.unit_system; + const unit = entity.config.unit ?? system?.temperature ?? '°C'; + const decimals = entity.config.decimals ?? 1; + return { + unit, + decimals, + text: (v) => (typeof v === 'number' && Number.isFinite(v) ? withUnit(formatNumber(v, decimals, hass), unit) : '—'), + }; +} + +/** The set point as words: one temperature, or a heat/cool range, or a dash when there's none. */ +function setPointText(t: Temps, target: unknown, low: unknown, high: unknown, hass: HomeAssistant): string { + if (typeof target === 'number') return t.text(target); + if (typeof low === 'number' && typeof high === 'number') { + return localize(hass, 'climate_range', { low: formatNumber(low, t.decimals, hass), high: t.text(high) }); + } + return '—'; +} + +/** Names and values above the chart: room temperature, set point, and what it's doing now. */ +function header(ctx: ChartContext, entity: ResolvedEntity, options: ClimateOptions, t: Temps) { + const { show } = ctx.config; + if (!show.name && !show.value) return nothing; + const attrs = entity.stateObj?.attributes ?? {}; + // A thermostat that isn't reporting, or an entity that isn't one, shows its state instead. + const live = + entity.entityId.startsWith('climate.') && + entity.stateObj !== undefined && + !['unavailable', 'unknown'].includes(entity.stateObj.state); + const action = + typeof attrs[CLIMATE_ATTRIBUTES.action] === 'string' ? (attrs[CLIMATE_ATTRIBUTES.action] as string) : undefined; + const kind = actionKind(action); + const item = (swatch: TemplateResult, name: string, value: string) => + html`
+ ${swatch} ${show.name ? html`${name}` : nothing} + ${show.value ? html`${value}` : nothing} +
`; + return html`
+ ${item( + html``, + entity.name, + live ? t.text(attrs[CLIMATE_ATTRIBUTES.current]) : entity.display, + )} + ${ + options.setPoint && live + ? item( + html``, + localize(ctx.hass, 'climate_set_point'), + setPointText( + t, + attrs[CLIMATE_ATTRIBUTES.target], + attrs[CLIMATE_ATTRIBUTES.low], + attrs[CLIMATE_ATTRIBUTES.high], + ctx.hass, + ), + ) + : nothing + } + ${ + live && action + ? html`
+ + ${actionLabel(ctx.hass, entity, action)} +
` + : nothing + } +
`; +} + +const pct = (n: number) => `${Math.round(n * 10000) / 100}%`; + +/** The guide, a dot on the room temperature, and a box with the temperature, set point and action then. */ +function tooltip( + ctx: ChartContext, + entity: ResolvedEntity, + data: ClimateData, + options: ClimateOptions, + scales: PlotScales, + t: Temps, + at: number | undefined, +) { + if (at === undefined) return nothing; + const x = scales.x(at) / VIEW_W; + if (!(x >= 0 && x <= 1)) return nothing; + const side = tooltipSide(x, plotWidth(ctx)); + const current = valueAt(data.current, at); + const action = valueAt(data.action, at)?.s; + const kind = actionKind(action); + const v = (points: Point[]) => valueAt(points, at)?.v ?? undefined; + const top = current && current.v !== null ? scales.y(current.v) / VIEW_H : -1; + const row = (swatch: TemplateResult, name: string, value: string) => + html`
+ ${swatch}${name}${value} +
`; + return html``; +} + +const message = (text: string, error = false) => html`
${text}
`; + +/** A stepped, dashed set-point line through held values. */ +function setPointPath(points: Point[], scales: PlotScales): string { + return holdRuns(points) + .map((run) => + runPath( + run.map((p) => [scales.x(p.t), scales.y(p.v)]), + 'step', + ), + ) + .join(''); +} + +export const climate: ChartStyleModule = { + layout: 'chart', + group: 'history', + height: 240, + styles: css` + ${frameStyles} + ${gridStyles} + ${tooltipStyles} + ${layoutStyles} + .style-climate .current { + stroke-linecap: round; + stroke-linejoin: round; + } + .style-climate .set-point { + stroke-dasharray: 6 5; + stroke-width: 2px; + } + .style-climate .range-band { + fill-opacity: 0.08; + } + .style-climate .action-span { + fill-opacity: 0.2; + } + .style-climate .set-point-swatch { + box-sizing: border-box; + height: 0; + border-radius: 0; + border-top: 3px dashed; + } + .style-climate .action-swatch { + border-radius: 2px; + opacity: 0.45; + } + `, + thumbnail: () => svg` + + + + `, + validate(raw: CardConfig) { + validateHistoryOptions(raw); + validateAxisOptions(raw); + validateTooltipOptions(raw); + validateSeriesStyle(raw); + validateLayout(raw); + validateClimateOptions(raw); + }, + editor: climateEditor, + disconnect(host) { + disconnectHistory(host); + stopChartWidth(host); + stopHover(host); + }, + render(ctx) { + const layout = chartLayout(ctx.raw); + const options = climateOptions(ctx.raw); + // One thermostat per chart: the first entity. Several rooms are several cards in a stack. + const entity = ctx.items[0]; + const period = parsePeriod(ctx.raw.period as CardConfig['period']); + const zone = zoneFor(ctx.hass); + const now = Date.now(); + const span = periodRange(period, now, zone, period.navigate ? stepsFor(ctx.host, period) : 0); + const range: Range = { start: span.start, end: span.end }; + const t = temps(entity, ctx.hass); + const thermostat = entity.entityId.startsWith('climate.'); + const head = header(ctx, entity, thermostat ? options : { ...options, setPoint: false }, t); + if (!thermostat) { + return chartFrame( + { ...layout, header: 'above' }, + head, + message(localize(ctx.hass, 'climate_not_thermostat', { name: entity.name })), + ); + } + const requests = climateRequests(entity.entityId); + const loaded = storeFor(ctx.host, ctx.requestUpdate).get(ctx.hass, Object.values(requests), range, 'history', { + live: span.live, + }); + const data = climateData(loaded.data, requests, Math.min(range.end, now), key); + const frame: HistoryFrame = { + status: loaded.status, + error: loaded.error, + range, + period, + source: 'history', + series: [], + live: span.live, + back: span.back, + zone, + }; + const nav = periodNav(frame, ctx); + const top = nav === nothing && head === nothing ? nothing : html`${nav}${head}`; + const kinds: ActionKind[] = options.shadeFan ? ['heat', 'cool', 'dry', 'fan'] : ['heat', 'cool', 'dry']; + const spans = options.shading ? actionSpans(data.action, Math.min(range.end, now), kinds) : []; + const numbers = options.setPoint ? [data.current, data.target, data.low, data.high] : [data.current]; + let body: TemplateResult | undefined; + if (loaded.status === 'loading') body = message(localize(ctx.hass, 'chart_loading')); + else if (loaded.status === 'error') { + body = message(localize(ctx.hass, 'chart_error', { error: loaded.error ?? '' }), true); + } else if (numbers.every((p) => p.every((x) => x.v === null)) && spans.length === 0) { + body = message(localize(ctx.hass, 'chart_no_data')); + } + if (body) return chartFrame(layout.header === 'over' ? { ...layout, header: 'above' } : layout, top, body); + + const axes = plotAxes(ctx, range, extent(numbers)); + const { scales } = axes; + const width = (ctx.raw.line_width as number | undefined) ?? DEFAULT_LINE_WIDTH; + const curve = (ctx.raw.curve as Curve | undefined) ?? 'smooth'; + const shading = svg`${spans.map( + (s) => svg``, + )}`; + const range2 = rangePoints(data.low, data.high); + const setPoint = options.setPoint + ? svg` + + ${[data.target, data.low, data.high].map( + (points) => + svg``, + )}` + : nothing; + const current = runs(downsample(data.current, MAX_POINTS)) + .map((run) => + runPath( + run.map((p) => [scales.x(p.t), scales.y(p.v as number)]), + curve, + ), + ) + .join(''); + const content = svg`${shading}${setPoint}`; + const label = localize(ctx.hass, 'chart_aria', { + style: localize(ctx.hass, 'editor_style_climate'), + names: entity.name, + }); + const hover = tooltipEnabled(ctx.raw); + return chartFrame( + layout, + top, + chartArea({ + axes, + ctx, + content, + label, + overlay: html`${layout.header === 'over' ? headerOver(top) : nothing} + ${hover ? tooltip(ctx, entity, data, options, scales, t, hoverTime(ctx.host)) : nothing}`, + hover: hover + ? hoverHandlers(ctx, range, [data.current, data.target, data.low, data.high, data.action]) + : undefined, + }), + ); + }, +}; diff --git a/src/styles/day-clock.ts b/src/styles/day-clock.ts new file mode 100644 index 0000000..0d23b62 --- /dev/null +++ b/src/styles/day-clock.ts @@ -0,0 +1,956 @@ +import { css, html, nothing, svg } from 'lit'; +import type { SVGTemplateResult, TemplateResult } from 'lit'; +import { baseColor } from '../bands'; +import { formatTime, hour12Of } from '../chart/axes'; +import { + BUCKETS, + DAY_MINUTES, + TAU, + activeSet, + averageDay, + barHeights, + clip, + clockDays, + foldSlots, + formatClockDuration, + isAdditive, + minuteAngle, + polar, + ringLayout, + sectorPath, + stateIntervals, + switchOns, + totalMs, + wallMinute, + wallSpans, +} from '../chart/day-clock'; +import type { AverageRun, Bucket, Interval, Slot } from '../chart/day-clock'; +import { SERIES_COLORS, disconnectHistory, frameStyles, periodNav } from '../chart/frame'; +import type { HistoryFrame } from '../chart/frame'; +import { TOUCH_LINGER_MS, tooltipEnabled, tooltipStyles, validateTooltipOptions } from '../chart/tooltip'; +import { dayClockEditor, dayClockEntityFields } from '../editor/day-clock-editor'; +import { formatNumber, withUnit } from '../format'; +import { key, toReading } from '../history/fetch'; +import { AGGREGATES } from '../history/group'; +import type { Aggregate } from '../history/group'; +import { parsePeriod, stepsFor } from '../history/period'; +import type { Period } from '../history/period'; +import { storeFor } from '../history/store'; +import { zoneFor } from '../history/timezone'; +import type { Point, SeriesRequest } from '../history/types'; +import { localize, registerStrings } from '../localize'; +import type { ResolvedEntity } from '../resolve'; +import type { CardConfig, HomeAssistant } from '../types'; +import type { ChartContext, ChartStyleModule } from './index'; + +registerStrings('en', { + editor_style_day_clock: '24-hour clock', + day_clock_times_one: 'once', + day_clock_times: '{n} times', + day_clock_a_day: '{text} a day', + day_clock_busiest: '{total} · busiest {time}', + day_clock_peak: 'peak {value} at {time}', + day_clock_state: '{name} {state}', + day_clock_per_day: '{label} per day', + day_clock_no_data: 'Nothing recorded', + day_clock_now: 'Now', + day_clock_on: 'on', + day_clock_average: 'Average day · last {n} days', + day_clock_on_days: 'on {on} of {of} days', + day_clock_so_far: '{duration} so far', + day_clock_range: '{from} – {to}', + day_clock_average_value: '{value} on average', +}); + +export const CENTER = ['total', 'now', 'none'] as const; +export type Center = (typeof CENTER)[number]; + +/** Most days an average day may cover: a month of history is already a lot to load. */ +export const MAX_DAYS = 31; + +/** Arcs or bars per ring above which they are no longer each a tab stop. */ +const MAX_FOCUSABLE = 48; + +/** Domains whose states are words (on, heating, home), drawn as arcs rather than bars. */ +const STATE_DOMAINS = new Set([ + 'alarm_control_panel', + 'automation', + 'binary_sensor', + 'calendar', + 'climate', + 'cover', + 'device_tracker', + 'fan', + 'humidifier', + 'input_boolean', + 'lawn_mower', + 'light', + 'lock', + 'media_player', + 'person', + 'remote', + 'schedule', + 'script', + 'siren', + 'switch', + 'timer', + 'vacuum', + 'valve', + 'water_heater', +]); + +type Own = { color?: string; active?: unknown }; + +const domainOf = (id: string) => id.split('.')[0]; + +/** + * Arcs (time in a state) or bars (numbers by the hour). An entity with its own `active` list is + * always a state ring; else its domain decides, then whether it has a unit or reads as a number. + */ +export function ringKind(e: ResolvedEntity): 'state' | 'numeric' { + const active = (e.config as Own).active; + if ((typeof active === 'string' && active.trim() !== '') || (Array.isArray(active) && active.length > 0)) { + return 'state'; + } + if (STATE_DOMAINS.has(domainOf(e.entityId))) return 'state'; + const attrs = e.stateObj?.attributes ?? {}; + if (!e.config.attribute && (attrs.unit_of_measurement !== undefined || attrs.state_class !== undefined)) { + return 'numeric'; + } + // Without a unit, a ring of numbers only when the state reads as one; words get a state ring. + const state = e.stateObj?.state; + const numeric = typeof state === 'string' && state.trim() !== '' && Number.isFinite(Number(state)); + return e.available && numeric ? 'numeric' : 'state'; +} + +/** A climate entity's state is its mode (heat all day); what it is doing is its `hvac_action`. */ +function attributeOf(e: ResolvedEntity, kind: 'state' | 'numeric'): string | undefined { + if (e.config.attribute) return e.config.attribute; + return kind === 'state' && domainOf(e.entityId) === 'climate' ? 'hvac_action' : undefined; +} + +/** The bars' aggregate: the card's, else change for meters, else a time-weighted mean. */ +function aggregateOf(e: ResolvedEntity, raw: CardConfig): Aggregate { + if (raw.aggregate !== undefined) return raw.aggregate as Aggregate; + const sc = e.stateObj?.attributes?.state_class; + return sc === 'total_increasing' || sc === 'total' ? 'change' : 'mean'; +} + +export function validateDayClock(raw: CardConfig): void { + validateTooltipOptions(raw); + parsePeriod(raw.period as CardConfig['period']); + const days = raw.days; + if (days !== undefined && (typeof days !== 'number' || !Number.isInteger(days) || days < 1 || days > MAX_DAYS)) { + throw new Error(`"days" must be a whole number from 1 to ${MAX_DAYS}`); + } + if (raw.center !== undefined && !CENTER.includes(raw.center as Center)) { + throw new Error(`"center" must be one of: ${CENTER.join(', ')}`); + } + if (raw.bucket !== undefined && !BUCKETS.includes(Number(raw.bucket) as Bucket)) { + throw new Error(`"bucket" must be one of: ${BUCKETS.join(', ')}`); + } + if (raw.aggregate !== undefined && !AGGREGATES.includes(raw.aggregate as Aggregate)) { + throw new Error(`"aggregate" must be one of: ${AGGREGATES.join(', ')}`); + } + for (const e of raw.entities ?? []) { + if (typeof e !== 'object' || e === null) continue; + const active = (e as Own).active; + if (active === undefined) continue; + const ok = + (typeof active === 'string' && active.trim() !== '') || + (Array.isArray(active) && active.every((s) => typeof s === 'string')); + if (!ok) throw new Error('"active" must be a state or a list of states'); + } +} + +/** One ring, worked out: what it draws and what the legend says about it. */ +export interface Ring { + index: number; + entity: ResolvedEntity; + kind: 'state' | 'numeric'; + color: string; + missing: boolean; + hasData: boolean; + /** State rings on a single day: the active stretches. */ + intervals: Interval[]; + /** State rings over several days: the average day. */ + runs: AverageRun[]; + /** Numeric rings: one bar per bucket, with heights 0–1. */ + slots: Slot[]; + heights: number[]; + aggregate: Aggregate; + /** Active time (a day, on average), and switch-ons (a day). */ + totalMs: number; + count: number; + /** The last active state seen, for "heating on". */ + word?: string; + /** The legend's line under the name. */ + stat: string; +} + +export interface Clock { + status: 'loading' | 'ready' | 'error'; + error?: string; + rings: Ring[]; + /** Each day's start, then the end of the last one. */ + bounds: number[]; + days: number; + bucket: number; + live: boolean; + now: number; + period: Period; + back: number; + zone: ReturnType; +} + +/** The period the buttons step: one day, or `days` days at a time. */ +function clockPeriod(raw: CardConfig, days: number): Period { + const own = parsePeriod(raw.period as CardConfig['period']); + return days === 1 + ? { last: 1, unit: 'day', calendar: 'day', offset: own.offset, navigate: own.navigate, until: 'end' } + : { last: days, unit: 'day', offset: own.offset, navigate: own.navigate, until: 'end' }; +} + +/** A state word as the user reads it ("on", "heating"), lower case to follow a name. */ +function stateWord(hass: HomeAssistant, e: ResolvedEntity, word: string, attribute: string | undefined): string { + const s = e.stateObj; + let text = word.replace(/_/g, ' '); + if (s) { + const formatted = attribute + ? hass.formatEntityAttributeValue?.(s, attribute, word) + : hass.formatEntityState?.(s, word); + if (formatted) text = formatted; + } + return text.toLocaleLowerCase(); +} + +/** A wall-clock minute of the day as the user's clock shows it: "07:00", "7:00 AM". */ +export function minuteLabel(minutes: number, hass?: HomeAssistant): string { + const m = Math.round(minutes) % DAY_MINUTES; + const t = Date.UTC(2000, 0, 1, Math.floor(m / 60), m % 60); + return new Intl.DateTimeFormat(hass?.locale?.language ?? 'en', { + hour: 'numeric', + minute: '2-digit', + hour12: hour12Of(hass), + timeZone: 'UTC', + }).format(t); +} + +/** The dial's four labels: "00 06 12 18", or "12 AM 6 AM…" on a 12-hour clock. */ +export function dialLabels(hass?: HomeAssistant): string[] { + const language = hass?.locale?.language ?? 'en'; + let twelve = hour12Of(hass); + if (twelve === undefined) { + try { + twelve = new Intl.DateTimeFormat(language, { hour: 'numeric' }).resolvedOptions().hour12 === true; + } catch { + twelve = false; + } + } + if (!twelve) return ['00', '06', '12', '18']; + return [0, 6, 12, 18].map((h) => + new Intl.DateTimeFormat(language, { hour: 'numeric', hour12: true, timeZone: 'UTC' }).format( + Date.UTC(2000, 0, 1, h), + ), + ); +} + +function valueText(v: number, e: ResolvedEntity, hass: HomeAssistant): string { + return withUnit(formatNumber(v, e.decimals, hass), e.unit); +} + +function timesText(hass: HomeAssistant, n: number): string { + const rounded = Math.round(n * 10) / 10; + return rounded === 1 ? localize(hass, 'day_clock_times_one') : localize(hass, 'day_clock_times', { n: rounded }); +} + +/** Load the card's history and work out every ring. */ +export function dayClock(ctx: ChartContext, now = Date.now()): Clock { + const { raw, hass } = ctx; + const days = (raw.days as number | undefined) ?? 1; + const bucket = Number(raw.bucket ?? 60); + const period = clockPeriod(raw, days); + const back = Math.max(0, period.offset + (period.navigate ? stepsFor(ctx.host, period) : 0)); + const zone = zoneFor(hass); + const bounds = clockDays(now, zone, days, back); + const live = back === 0; + const end = bounds[bounds.length - 1]; + const until = Math.min(end, now); + const kinds = ctx.items.map(ringKind); + const requests: SeriesRequest[] = ctx.items.map((e, i) => ({ + entity: e.entityId, + attribute: attributeOf(e, kinds[i]), + text: true, + })); + const store = storeFor(ctx.host, ctx.requestUpdate); + const loaded = store.get(hass, requests, { start: bounds[0], end: live ? now : end }, 'history', { live }); + const range = { start: bounds[0], end: until }; + // Days that have begun: the average is per day of those, today counting as one once it starts. + const daysSoFar = Math.max(1, bounds.slice(0, -1).filter((b) => b < until).length); + const rings = ctx.items.map((entity, index): Ring => { + const kind = kinds[index]; + const own = entity.config as typeof entity.config & Own; + // A missing entity has no history worth drawing: an empty ring and "Entity not found". + const points = entity.stateObj ? (loaded.data.get(key(requests[index])) ?? []) : []; + const ring: Ring = { + index, + entity, + kind, + color: baseColor(own.color ?? SERIES_COLORS[index % SERIES_COLORS.length]), + missing: !entity.stateObj, + hasData: false, + intervals: [], + runs: [], + slots: [], + heights: [], + aggregate: aggregateOf(entity, raw), + totalMs: 0, + count: 0, + stat: '', + }; + if (kind === 'state') { + const active = activeSet(own.active); + const spans = stateIntervals(points, range, active); + ring.hasData = spans.known.length > 0; + ring.word = spans.word; + const on = spans.active; + if (days === 1) ring.intervals = on; + else ring.runs = averageDay(on, spans.known, bounds, zone.timeZone); + ring.totalMs = totalMs(on) / daysSoFar; + let count = 0; + for (let d = 0; d + 1 < bounds.length; d++) { + count += switchOns(clip(on, { start: bounds[d], end: bounds[d + 1] })); + } + ring.count = count / daysSoFar; + // Over several days, each figure is a day's average: "30 m a day · 0.7 times a day". + const daily = (text: string) => (days === 1 ? text : localize(hass, 'day_clock_a_day', { text })); + const parts = [daily(formatClockDuration(ring.totalMs))]; + if (ring.count > 0) parts.push(daily(timesText(hass, ring.count))); + ring.stat = parts.join(' · '); + } else { + const numbers: Point[] = points.map((p) => ({ ...p, v: toReading(p.s), s: undefined })); + ring.slots = foldSlots(numbers, bounds, bucket, ring.aggregate, until, zone.timeZone); + const additive = isAdditive(ring.aggregate); + ring.heights = barHeights(ring.slots, additive); + ring.hasData = ring.slots.length > 0; + if (ring.hasData) { + const top = ring.slots.reduce((a, b) => (b.v > a.v ? b : a)); + const time = minuteLabel(top.index * bucket, hass); + if (additive) { + const total = valueText( + ring.slots.reduce((a, s) => a + s.v, 0), + entity, + hass, + ); + ring.stat = localize(hass, 'day_clock_busiest', { + total: days === 1 ? total : localize(hass, 'day_clock_a_day', { text: total }), + time, + }); + } else { + ring.stat = localize(hass, 'day_clock_peak', { value: valueText(top.v, entity, hass), time }); + } + } + } + if (ring.missing) ring.stat = localize(hass, 'entity_not_found'); + else if (!ring.hasData && loaded.status === 'ready') ring.stat = localize(hass, 'day_clock_no_data'); + return ring; + }); + return { + status: loaded.status, + error: loaded.error, + rings, + bounds, + days, + bucket, + live, + now, + period, + back, + zone, + }; +} + +/* ------------------------------------------------------------------ hover */ + +/** Which arc or bar the pointer (or focus) is on. */ +interface HoverState { + ring?: number; + item?: number; + timer?: ReturnType; +} +const hovers = new WeakMap(); + +function hoverOf(host: HTMLElement): HoverState { + let h = hovers.get(host); + if (!h) { + h = {}; + hovers.set(host, h); + } + return h; +} + +function setHover(ctx: ChartContext, ring: number | undefined, item: number | undefined): void { + const h = hoverOf(ctx.host); + if (h.timer !== undefined) clearTimeout(h.timer); + h.timer = undefined; + if (h.ring === ring && h.item === item) return; + h.ring = ring; + h.item = item; + ctx.requestUpdate(); +} + +function leave(ctx: ChartContext, ev: Event): void { + const touch = (ev as PointerEvent).pointerType === 'touch' || (ev as PointerEvent).pointerType === 'pen'; + if (!touch) { + setHover(ctx, undefined, undefined); + return; + } + const h = hoverOf(ctx.host); + if (h.timer !== undefined) clearTimeout(h.timer); + h.timer = setTimeout(() => { + h.timer = undefined; + setHover(ctx, undefined, undefined); + }, TOUCH_LINGER_MS); +} + +/* ------------------------------------------------------------------ live hand */ + +const ticks = new WeakMap>(); + +/** Redraw at the start of the next minute, so the hand and the dimmed future keep up. */ +function tickNextMinute(ctx: ChartContext, now: number): void { + if (ticks.has(ctx.host)) return; + const wait = 60_000 - (now % 60_000) + 50; + ticks.set( + ctx.host, + setTimeout(() => { + ticks.delete(ctx.host); + // Hidden tabs skip the redraw; the next visible minute catches up. + if (typeof document !== 'undefined' && document.visibilityState === 'hidden') tickNextMinute(ctx, Date.now()); + else ctx.requestUpdate(); + }, wait), + ); +} + +function stopTick(host: HTMLElement): void { + const t = ticks.get(host); + if (t !== undefined) clearTimeout(t); + ticks.delete(host); +} + +/* ------------------------------------------------------------------ drawing */ + +/** The dial's drawing box: centre at 0, 0. Wider than tall so side labels fit. */ +const VIEW = { x: -132, y: -120, w: 264, h: 240 }; +const OUTER = 92; + +interface Mark { + d: string; + /** Mid-angle and radius, for placing the tooltip. */ + angle: number; + r: number; + opacity?: number; + /** The interval, run or slot it draws (its index in the ring's list). */ + item: number; +} + +function stateMarks(ring: Ring, clock: Clock, r0: number, r1: number): Mark[] { + const tz = clock.zone.timeZone; + if (clock.days === 1) { + return ring.intervals.map((iv, item) => { + const spans = wallSpans(iv, clock.bounds[0], tz); + const d = spans.map(([a, b]) => sectorPath(r0, r1, minuteAngle(a), minuteAngle(b))).join(''); + const mid = spans.length ? (spans[0][0] + spans[spans.length - 1][1]) / 2 : 0; + return { d, angle: minuteAngle(mid), r: r1, item }; + }); + } + return ring.runs.map((run, item) => ({ + d: sectorPath(r0, r1, minuteAngle(run.from), minuteAngle(run.to)), + angle: minuteAngle((run.from + run.to) / 2), + r: r1, + opacity: Math.max(0.12, run.on / Math.max(1, run.of)), + item, + })); +} + +function barMarks(ring: Ring, clock: Clock, r0: number, r1: number): Mark[] { + const per = DAY_MINUTES / clock.bucket; + // A small gap either side of each bar, less when the bars are thin. + const pad = Math.min(0.03, TAU / per / 8); + const additive = isAdditive(ring.aggregate); + const marks: Mark[] = []; + ring.slots.forEach((slot, i) => { + // An hour with nothing added (no motion, no energy) has no bar, as on a bar chart. + if (additive && slot.v === 0) return; + const a0 = (slot.index / per) * TAU + pad; + const a1 = ((slot.index + 1) / per) * TAU - pad; + const r = r0 + Math.max(0.04, ring.heights[i]) * (r1 - r0); + marks.push({ d: sectorPath(r0, r, a0, a1), angle: (a0 + a1) / 2, r, item: i }); + }); + return marks; +} + +/** The tooltip's lines for the hovered arc or bar. */ +function tipText(ring: Ring, item: number, clock: Clock, hass: HomeAssistant): { time: string; value: string } { + const range = (from: string, to: string) => localize(hass, 'day_clock_range', { from, to }); + if (ring.kind === 'numeric') { + const slot = ring.slots[item]; + const time = range( + minuteLabel(slot.index * clock.bucket, hass), + minuteLabel((slot.index + 1) * clock.bucket, hass), + ); + const value = valueText(slot.v, ring.entity, hass); + return { time, value: clock.days === 1 ? value : localize(hass, 'day_clock_average_value', { value }) }; + } + if (clock.days > 1) { + const run = ring.runs[item]; + return { + time: range(minuteLabel(run.from, hass), minuteLabel(run.to, hass)), + value: localize(hass, 'day_clock_on_days', { on: run.on, of: run.of }), + }; + } + const iv = ring.intervals[item]; + const running = clock.live && iv.end >= clock.now - 1000; + const to = running ? localize(hass, 'day_clock_now').toLocaleLowerCase() : formatTime(iv.end, hass); + const duration = formatClockDuration(iv.end - iv.start); + return { + time: range(formatTime(iv.start, hass), to), + value: running ? localize(hass, 'day_clock_so_far', { duration }) : duration, + }; +} + +function tooltip(ctx: ChartContext, clock: Clock, marks: Mark[][]): TemplateResult | typeof nothing { + const h = hovers.get(ctx.host); + if (h?.ring === undefined || h.item === undefined) return nothing; + const ring = clock.rings[h.ring]; + const mark = marks[h.ring]?.[h.item]; + if (!ring || !mark) return nothing; + const { time, value } = tipText(ring, mark.item, clock, ctx.hass); + const [x, y] = polar(mark.r + 4, mark.angle); + const left = ((x - VIEW.x) / VIEW.w) * 100; + const top = ((y - VIEW.y) / VIEW.h) * 100; + // Hang the box inwards, over the dial, so it stays inside the card even when the dial fills it. + const side = `${x < 0 ? 'tip-right' : 'tip-left'} ${y < 0 ? 'tip-down' : 'tip-up'}`; + return html``; +} + +/** + * "Heating on", "Alex home": a name and a state word. A name that already says the state + * ("Heating" heating) reads "Heating on" instead. + */ +function stateLabel(hass: HomeAssistant, name: string, word: string): string { + const state = name.toLocaleLowerCase().includes(word.toLocaleLowerCase()) ? localize(hass, 'day_clock_on') : word; + return localize(hass, 'day_clock_state', { name, state }); +} + +/** The current state of a ring's entity, as "Heating on" or "Power 1.2 kW". */ +function nowText(ring: Ring, hass: HomeAssistant): string { + const e = ring.entity; + if (!e.stateObj) return e.display; + if (ring.kind === 'numeric') return localize(hass, 'day_clock_state', { name: e.name, state: e.display }); + const attribute = attributeOf(e, 'state'); + const raw = attribute ? e.stateObj.attributes?.[attribute] : e.stateObj.state; + if (typeof raw !== 'string') return localize(hass, 'day_clock_state', { name: e.name, state: e.display }); + return stateLabel(hass, e.name, stateWord(hass, e, raw, attribute)); +} + +/** The ring the "now" line describes: the first entity that exists. */ +function firstFound(clock: Clock): Ring | undefined { + return clock.rings.find((r) => !r.missing); +} + +/** What the middle of the dial says: a big line and a small one, or nothing. */ +function centreText(ctx: ChartContext, clock: Clock): [string, string] | undefined { + const center = (ctx.raw.center as Center | undefined) ?? 'total'; + if (center === 'none' || clock.rings.length === 0) return undefined; + const { hass } = ctx; + const found = firstFound(clock); + if (center === 'now' && clock.live && clock.days === 1 && found) { + return [formatTime(clock.now, hass), nowText(found, hass)]; + } + const first = clock.rings.find((r) => r.kind === 'state' && r.hasData); + if (first) { + const word = first.word + ? stateWord(hass, first.entity, first.word, attributeOf(first.entity, 'state')) + : localize(hass, 'day_clock_on'); + const label = stateLabel(hass, first.entity.name, word); + return [ + formatClockDuration(first.totalMs), + clock.days === 1 ? label : localize(hass, 'day_clock_per_day', { label }), + ]; + } + const bars = clock.rings.find((r) => r.kind === 'numeric' && r.hasData); + if (!bars) return undefined; + const total = isAdditive(bars.aggregate) + ? valueText( + bars.slots.reduce((a, s) => a + s.v, 0), + bars.entity, + hass, + ) + : bars.entity.display; + return [total, bars.entity.name]; +} + +/** Fit text in the hole: a font size (in dial units) and the text, cut short with an ellipsis if need be. */ +export function fitText(text: string, width: number, max: number): { size: number; text: string } { + // About 0.56 em per character in the card's sans-serif. + const size = Math.min(max, width / Math.max(1, text.length * 0.56)); + const floor = max * 0.62; + if (size >= floor) return { size, text }; + const chars = Math.max(3, Math.floor(width / (floor * 0.56)) - 1); + return { size: floor, text: `${text.slice(0, chars).trimEnd()}…` }; +} + +function centre(ctx: ChartContext, clock: Clock, hole: number): SVGTemplateResult | typeof nothing { + const lines = centreText(ctx, clock); + if (!lines) return nothing; + const maxBig = Math.min(18, hole * 0.4); + const maxSmall = Math.min(11, hole * 0.25); + // The two lines are a block (value, then label) centred in the hole. Each may be as wide as the + // hole is at the block's top and bottom edge, less a margin, so neither reaches the rings or the + // hand (which starts at the hole's edge). + const half = (1.05 * maxBig + 1.3 * maxSmall) / 2; + const width = 2 * Math.sqrt(Math.max(0, hole * hole - half * half)) - 8; + const big = fitText(lines[0], width, maxBig); + const small = fitText(lines[1], width, maxSmall); + // Value baseline at 0 (its glyphs reach up about 0.75 em), label baseline below it. + const labelY = 0.3 * big.size + small.size; + const shift = (-0.75 * big.size + labelY + 0.3 * small.size) / 2; + return svg``; +} + +function dial(ctx: ChartContext, clock: Clock): TemplateResult { + const layout = ringLayout( + clock.rings.map((r) => r.kind), + OUTER, + ); + const tip = tooltipEnabled(ctx.raw); + const marks = clock.rings.map((ring, i) => { + const { r0, r1 } = layout.rings[i]; + return ring.kind === 'state' ? stateMarks(ring, clock, r0, r1) : barMarks(ring, clock, r0, r1); + }); + const h = hovers.get(ctx.host); + const rings = clock.rings.map((ring, i) => { + const { r0, r1 } = layout.rings[i]; + const focusable = clock.days === 1 && marks[i].length <= MAX_FOCUSABLE; + const track = + ring.kind === 'state' + ? svg`` + : svg``; + return svg` + ${track} + ${marks[i].map( + (m, j) => + svg` setHover(ctx, i, j) : nothing} + @pointerdown=${tip ? () => setHover(ctx, i, j) : nothing} + @pointerleave=${tip ? (ev: Event) => leave(ctx, ev) : nothing} + @focus=${tip ? () => setHover(ctx, i, j) : nothing} + @blur=${tip ? () => setHover(ctx, undefined, undefined) : nothing} + >`, + )} + `; + }); + const labels = dialLabels(ctx.hass); + const LABEL_R = OUTER + 15; + const tickMarks = Array.from({ length: 24 }, (_, hour) => { + const a = (hour / 24) * TAU; + const major = hour % 6 === 0; + const [x0, y0] = polar(OUTER + 3, a); + const [x1, y1] = polar(OUTER + (major ? 9 : 6), a); + return svg``; + }); + const dialText = svg` + ${labels[0]} + ${labels[1]} + ${labels[2]} + ${labels[3]}`; + let hand: SVGTemplateResult | typeof nothing = nothing; + if (clock.live && clock.days === 1) { + const a = minuteAngle(wallMinute(clock.now, clock.bounds[0], clock.zone.timeZone)); + const [x0, y0] = polar(layout.hole, a); + const [x1, y1] = polar(OUTER + 9, a); + hand = svg` + + + `; + } + const label = localize(ctx.hass, 'chart_aria', { + style: localize(ctx.hass, 'editor_style_day_clock'), + names: clock.rings.map((r) => r.entity.name).join(', '), + }); + return html`
+ + ${tickMarks}${dialText} + ${rings} + ${hand} ${centre(ctx, clock, layout.hole)} + + ${tip ? tooltip(ctx, clock, marks) : nothing} +
`; +} + +function legend(ctx: ChartContext, clock: Clock): TemplateResult { + const { hass } = ctx; + let footer: TemplateResult | typeof nothing = nothing; + if (clock.days > 1) { + footer = html`
+
${localize(hass, 'day_clock_average', { n: clock.days })}
+
`; + } else if (clock.live && firstFound(clock)) { + footer = html`
+
${localize(hass, 'day_clock_now')}
+
${formatTime(clock.now, hass)} · ${nowText(firstFound(clock) as Ring, hass)}
+
`; + } + return html`
+ ${clock.rings.map( + (ring) => + html`
+ +
+
${ring.entity.name}
+
${ring.stat}
+
+
`, + )} + ${footer} +
`; +} + +function message(nav: TemplateResult | typeof nothing, text: string, error = false): TemplateResult { + return html`
+ ${nav} +
${text}
+
`; +} + +export const day_clock: ChartStyleModule = { + layout: 'chart', + group: 'history', + height: 280, + styles: css` + ${frameStyles} + ${tooltipStyles} + .dc-body { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: 8px 20px; + padding-bottom: 4px; + } + .dc-dial { + position: relative; + flex: 0 1 280px; + min-width: 200px; + aspect-ratio: 264 / 240; + } + .dc-svg { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + overflow: visible; + } + .dc-track { + fill: var(--cd-track); + } + .dc-base { + fill: none; + stroke: var(--divider-color, rgba(127, 127, 127, 0.3)); + stroke-width: 1; + } + .dc-mark { + outline: none; + transition: fill-opacity 0.15s ease; + } + .dc-ring:hover .dc-mark:not(.hover), + .dc-ring:focus-within .dc-mark:not(.hover) { + fill-opacity: 0.75; + } + .dc-mark.hover { + stroke: var(--primary-text-color); + stroke-width: 1.2; + paint-order: stroke; + } + .dc-ring { + cursor: pointer; + outline: none; + } + .dc-ring:focus-visible .dc-track, + .dc-ring:focus-visible .dc-base { + stroke: var(--primary-color); + stroke-width: 1.5; + } + .dc-tick { + stroke: var(--secondary-text-color); + stroke-opacity: 0.5; + stroke-width: 1; + } + .dc-tick.major { + stroke-opacity: 1; + stroke-width: 1.5; + } + .dc-label { + fill: var(--secondary-text-color); + font-size: 10px; + } + .dc-future { + fill: var(--card-background-color, #fff); + fill-opacity: 0.55; + pointer-events: none; + } + .dc-hand { + stroke: var(--primary-text-color); + stroke-width: 2; + stroke-linecap: round; + pointer-events: none; + } + .dc-hand-dot { + fill: var(--primary-text-color); + } + .dc-centre text { + text-anchor: middle; + pointer-events: none; + } + .dc-centre-value { + fill: var(--primary-text-color); + font-weight: 500; + } + .dc-centre-label { + fill: var(--secondary-text-color); + } + .dc-marks { + opacity: 0; + transition: opacity var(--cd-duration) var(--cd-easing); + } + :host([ready]) .dc-marks { + opacity: 1; + } + :host([static]) .dc-marks, + :host([static]) .dc-mark { + transition: none; + } + .dc-legend { + flex: 1 1 160px; + min-width: 0; + display: flex; + flex-direction: column; + gap: 10px; + } + .dc-item { + display: flex; + gap: 8px; + align-items: baseline; + min-width: 0; + } + .dc-swatch { + flex: none; + width: 10px; + height: 10px; + border-radius: 2px; + } + .dc-text { + min-width: 0; + } + .dc-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .dc-stat { + color: var(--secondary-text-color); + font-size: 0.875em; + } + .dc-item.missing .dc-name { + color: var(--secondary-text-color); + } + .dc-foot { + margin-top: 4px; + } + .dc-now { + font-weight: 500; + font-size: 1.1em; + } + .dc-tip { + position: absolute; + pointer-events: none; + z-index: 1; + white-space: nowrap; + } + .dc-tip.tip-right { + transform: translate(6px, 0); + } + .dc-tip.tip-left { + transform: translate(calc(-100% - 6px), 0); + } + .dc-tip.tip-right.tip-up { + transform: translate(6px, -100%); + } + .dc-tip.tip-left.tip-up { + transform: translate(calc(-100% - 6px), -100%); + } + `, + thumbnail: () => svg` + + + + + + `, + validate: validateDayClock, + editor: dayClockEditor, + entityFields: dayClockEntityFields, + disconnect(host) { + disconnectHistory(host); + stopTick(host); + const h = hovers.get(host); + if (h?.timer !== undefined) clearTimeout(h.timer); + hovers.delete(host); + }, + render(ctx) { + const clock = dayClock(ctx); + if (clock.live) tickNextMinute(ctx, clock.now); + const nav = periodNav( + { + period: clock.period, + range: { start: clock.bounds[0], end: clock.bounds[clock.bounds.length - 1] }, + back: clock.back, + zone: clock.zone, + } as HistoryFrame, + ctx, + clock.now, + ); + if (clock.status === 'loading') return message(nav, localize(ctx.hass, 'chart_loading')); + if (clock.status === 'error') { + return message(nav, localize(ctx.hass, 'chart_error', { error: clock.error ?? '' }), true); + } + if (clock.rings.every((r) => !r.hasData)) return message(nav, localize(ctx.hass, 'chart_no_data')); + return html`
+ ${nav} +
${dial(ctx, clock)} ${legend(ctx, clock)}
+
`; + }, +}; diff --git a/src/styles/dumbbell.ts b/src/styles/dumbbell.ts new file mode 100644 index 0000000..ec86ad0 --- /dev/null +++ b/src/styles/dumbbell.ts @@ -0,0 +1,616 @@ +import { css, html, nothing, svg } from 'lit'; +import type { TemplateResult } from 'lit'; +import { baseColor } from '../bands'; +import { extentOf, lowHigh, niceTicks, readingAt } from '../chart/compare'; +import type { TimedValue } from '../chart/compare'; +import { disconnectHistory, frameMessage, frameStyles, historyFrame, validateHistoryOptions } from '../chart/frame'; +import { ROLLING_HISTORY_FIELDS, cachedSchema, chartForm, choice, rollingHistorySchema } from '../editor/chart-editors'; +import type { FieldBinding } from '../editor/schemas'; +import { formatNumber, withUnit } from '../format'; +import { parsePeriod } from '../history/period'; +import type { Period } from '../history/period'; +import { localize, registerStrings } from '../localize'; +import type { ResolvedEntity } from '../resolve'; +import type { ChartContext, ChartEditorContext, ChartStyleModule } from './index'; +import type { CardConfig, HomeAssistant } from '../types'; + +export const ENDS = ['range', 'then', 'target'] as const; +export type Ends = (typeof ENDS)[number]; +export const ORIENTATIONS = ['horizontal', 'vertical'] as const; +export type Orientation = (typeof ORIENTATIONS)[number]; + +registerStrings('en', { + editor_style_dumbbell: 'Dumbbell', + editor_ends: 'Compare', + editor_ends_range: 'Lowest and highest in the period', + editor_ends_then: 'Start of the period and now', + editor_ends_target: 'Target and now', + editor_show_now: 'Mark the current value', + editor_orientation: 'Direction', + editor_orientation_horizontal: 'Across', + editor_orientation_vertical: 'Upright', + dumbbell_low: 'Low', + dumbbell_high: 'High', + dumbbell_now: 'Now', + dumbbell_target: 'Target', + dumbbell_ago_hour_one: 'An hour ago', + dumbbell_ago_hour: '{n} hours ago', + dumbbell_ago_day_one: 'A day ago', + dumbbell_ago_day: '{n} days ago', + dumbbell_ago_week_one: 'A week ago', + dumbbell_ago_week: '{n} weeks ago', + dumbbell_ago_month_one: 'A month ago', + dumbbell_ago_month: '{n} months ago', + dumbbell_no_value: 'No value', +}); + +/** What a mark on a row stands for. */ +export type MarkKind = 'low' | 'high' | 'now' | 'then' | 'target'; + +export interface Mark { + kind: MarkKind; + value: number; +} + +/** One entity's row: its marks, lowest first, and which of them get a value label. */ +export interface DumbbellRow { + index: number; + entity: ResolvedEntity; + marks: Mark[]; + /** The marks labelled at the low and high ends (the same mark when there is only one). */ + labelled: Mark[]; +} + +/** The earliest reading in a period when none was in force at its start (statistics start late). */ +function firstReading(points: TimedValue[]): number | null { + for (const p of points) if (p.v !== null) return p.v; + return null; +} + +/** + * The marks for one entity. `range`: lowest and highest over the period (stretched to take in + * the current value, which may be newer than the last stored reading), plus the current value + * when `showNow`. `then`: the reading at the start of the period and now. `target`: the entity's + * target and now. Anything missing is left out, so a row may have one mark or none. + */ +export function rowMarks(e: ResolvedEntity, ends: Ends, points: TimedValue[], start: number, showNow: boolean): Mark[] { + const now = e.available ? e.value : null; + const marks: Mark[] = []; + if (ends === 'range') { + const lh = lowHigh(points); + const span = lh ? extentOf([lh[0], lh[1], now]) : undefined; + if (span) marks.push({ kind: 'low', value: span[0] }, { kind: 'high', value: span[1] }); + if (now !== null && (showNow || !span)) marks.push({ kind: 'now', value: now }); + } else { + const other = ends === 'then' ? (readingAt(points, start) ?? firstReading(points)) : e.target; + if (other !== null) marks.push({ kind: ends, value: other }); + if (now !== null) marks.push({ kind: 'now', value: now }); + } + // Stable sort keeps low before high when they are equal. + return marks.sort((a, b) => a.value - b.value); +} + +/** + * The marks that carry a value label: the lowest and highest. In range mode with low and high, + * the current value sits between them unlabelled (it is in the row's description). + */ +export function labelledMarks(marks: Mark[]): Mark[] { + if (marks.length === 0) return []; + const ends = marks.some((m) => m.kind === 'low') ? marks.filter((m) => m.kind !== 'now') : marks; + const lo = ends[0]; + const hi = ends[ends.length - 1]; + return lo === hi ? [hi] : [lo, hi]; +} + +export function dumbbellRows( + items: ResolvedEntity[], + ends: Ends, + pointsFor: (index: number) => TimedValue[], + start: number, + showNow: boolean, +): DumbbellRow[] { + return items.map((entity, index) => { + const marks = rowMarks(entity, ends, pointsFor(index), start, showNow); + return { index, entity, marks, labelled: labelledMarks(marks) }; + }); +} + +/** Decimals a tick step needs: 0 for whole steps, 1 for 0.5, 2 for 0.25. */ +export function stepDecimals(ticks: number[]): number { + if (ticks.length < 2) return 0; + const step = Math.abs(ticks[1] - ticks[0]); + for (let d = 0; d < 6; d++) if (Math.abs(Math.round(step * 10 ** d) - step * 10 ** d) < 1e-6) return d; + return 6; +} + +/** "A week ago", "3 days ago": the label for the start of the period. */ +export function agoText(hass: HomeAssistant | undefined, period: Period): string { + return period.last === 1 + ? localize(hass, `dumbbell_ago_${period.unit}_one`) + : localize(hass, `dumbbell_ago_${period.unit}`, { n: period.last }); +} + +const MARK_COLORS: Record, string> = { + low: baseColor('blue'), + high: baseColor('red'), + then: baseColor('grey'), + target: 'var(--primary-text-color, #212121)', +}; + +/** Hollow rings: range's current value, and a target. */ +function hollow(kind: MarkKind, ends: Ends): boolean { + return kind === 'target' || (kind === 'now' && ends === 'range'); +} + +function markColor(kind: MarkKind, ends: Ends, e: ResolvedEntity): string { + if (kind !== 'now') return MARK_COLORS[kind]; + return ends === 'range' ? 'var(--primary-text-color, #212121)' : baseColor(e.band.color); +} + +const DEFAULTS = { ends: 'range' as Ends, orientation: 'horizontal' as Orientation, showNow: true }; + +function optionsOf(raw: CardConfig) { + return { + ends: (raw.ends as Ends | undefined) ?? DEFAULTS.ends, + orientation: (raw.orientation as Orientation | undefined) ?? DEFAULTS.orientation, + showNow: raw.show_now === undefined ? DEFAULTS.showNow : raw.show_now === true, + }; +} + +/** One unit for the axis when every entity shares it, else none. */ +function sharedUnit(items: ResolvedEntity[]): string { + const units = new Set(items.map((e) => e.unit).filter((u) => u !== '')); + return units.size === 1 ? [...units][0] : ''; +} + +function legend(ctx: ChartContext, ends: Ends, showNow: boolean, rows: DumbbellRow[], period: Period, unit: string) { + const t = (k: string) => localize(ctx.hass, k); + const bands = new Set(rows.map((r) => baseColor(r.entity.band.color))); + const nowFill = bands.size === 1 ? [...bands][0] : baseColor('primary'); + const items: [string, string, boolean][] = + ends === 'range' + ? [ + [t('dumbbell_low'), MARK_COLORS.low, false], + ...(showNow + ? [[t('dumbbell_now'), markColor('now', ends, rows[0].entity), true] as [string, string, boolean]] + : []), + [t('dumbbell_high'), MARK_COLORS.high, false], + ] + : [ + ends === 'then' + ? [agoText(ctx.hass, period), MARK_COLORS.then, false] + : [t('dumbbell_target'), MARK_COLORS.target, true], + [t('dumbbell_now'), nowFill, false], + ]; + return html`
+ ${unit ? html`${unit}` : nothing} + ${items.map( + ([text, color, ring]) => + html`${text}`, + )} +
`; +} + +function describe(row: DumbbellRow, ctx: ChartContext, period: Period): string { + const e = row.entity; + if (row.marks.length === 0) return `${e.name}, ${e.available ? localize(ctx.hass, 'dumbbell_no_value') : e.display}`; + const name = (k: MarkKind) => (k === 'then' ? agoText(ctx.hass, period) : localize(ctx.hass, `dumbbell_${k}`)); + const parts = row.marks.map( + (m) => `${name(m.kind)} ${withUnit(formatNumber(m.value, e.decimals, ctx.hass), e.unit)}`, + ); + return `${e.name}: ${parts.join(', ')}`; +} + +function renderRow( + row: DumbbellRow, + ctx: ChartContext, + ends: Ends, + at: (v: number) => number, + ticks: number[], + period: Period, +): TemplateResult { + const e = row.entity; + const { show } = ctx.config; + // The labelled ends: low and high in range mode, so a current value equal to one of them + // doesn't take over the bar's colour. + const lo = row.labelled[0]; + const hi = row.labelled[row.labelled.length - 1]; + const fill = (m: Mark) => + hollow(m.kind, ends) + ? 'color-mix(in srgb, var(--primary-text-color, #212121) 30%, transparent)' + : markColor(m.kind, ends, e); + return html``; +} + +const FIELDS: FieldBinding[] = [ + ...ROLLING_HISTORY_FIELDS, + { name: 'ends', path: ['ends'], default: DEFAULTS.ends }, + { name: 'show_now', path: ['show_now'], default: DEFAULTS.showNow }, + { name: 'orientation', path: ['orientation'], default: DEFAULTS.orientation }, +]; + +export function dumbbellEditor(ctx: ChartEditorContext): TemplateResult { + const { ends } = optionsOf(ctx.config); + const schema = cachedSchema(`dumbbell-${ends}`, ctx.hass, () => [ + { + type: 'grid', + name: '', + schema: [ + { name: 'ends', selector: choice(ctx.hass, 'ends', ENDS) }, + { name: 'orientation', selector: choice(ctx.hass, 'orientation', ORIENTATIONS) }, + ], + }, + // A target needs no history. + ...(ends === 'target' ? [] : rollingHistorySchema(ctx.hass)), + ...(ends === 'range' ? [{ name: 'show_now', selector: { boolean: {} } }] : []), + ]); + return chartForm(ctx, 'chart', schema, FIELDS); +} + +export const dumbbell: ChartStyleModule = { + layout: 'chart', + group: 'compare', + height: 220, + styles: css` + ${frameStyles} + .db { + --pad: 40px; + --span: calc(100% - 2 * var(--pad)); + --dot: 14px; + display: flex; + flex-direction: column; + } + .db.no-values { + --pad: 12px; + } + .db-legend { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + align-items: center; + gap: 4px 14px; + padding: 2px 0 6px; + font-size: 0.8125em; + color: var(--secondary-text-color); + } + .db-unit { + margin-right: auto; + } + /* Narrow cards: every other tick label, so they don't run together. */ + @container (max-width: 400px) { + .db.horizontal .db-tick:nth-child(even) { + visibility: hidden; + } + } + .db-key { + display: inline-flex; + align-items: center; + gap: 6px; + } + .db-swatch { + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--mark); + box-sizing: border-box; + } + .db-swatch.ring, + .db-dot.ring { + background: var(--card-background-color, #fff); + border: 2.5px solid var(--mark); + } + .db-row { + position: relative; + cursor: pointer; + border-radius: 6px; + outline: none; + } + .db-row:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: -2px; + } + .db-name { + color: var(--primary-text-color); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .db-plot { + position: relative; + } + .db-grid, + .db-bar, + .db-dot, + .db-label, + .db-tick { + position: absolute; + } + .db-dot { + width: var(--dot); + height: var(--dot); + border-radius: 50%; + background: var(--mark); + box-sizing: border-box; + z-index: 1; + } + .db-dot.now.ring { + z-index: 2; + } + .db-label, + .db-tick, + .db-status { + font-size: 0.8125em; + color: var(--secondary-text-color); + white-space: nowrap; + line-height: 1; + } + .db-status { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + } + .db-bar, + .db-dot, + .db-label { + transition: + left var(--cd-duration) var(--cd-easing), + width var(--cd-duration) var(--cd-easing), + bottom var(--cd-duration) var(--cd-easing), + height var(--cd-duration) var(--cd-easing), + opacity var(--cd-duration) var(--cd-easing); + } + :host(:not([ready])) .db-bar, + :host(:not([ready])) .db-dot, + :host(:not([ready])) .db-label { + opacity: 0; + } + + /* Rows: value across, one entity per row. */ + .db.horizontal .db-row { + display: grid; + grid-template-columns: var(--name-w) minmax(0, 1fr); + align-items: center; + column-gap: 8px; + height: 36px; + } + .db.horizontal.no-names .db-row, + .db.horizontal.no-names .db-axis { + grid-template-columns: minmax(0, 1fr); + } + .db.horizontal .db-plot { + height: 100%; + } + .db.horizontal .db-grid { + top: 0; + bottom: 0; + left: calc(var(--pad) + var(--span) * var(--f)); + border-left: 1px dashed var(--divider-color, rgba(127, 127, 127, 0.3)); + } + .db.horizontal .db-bar { + top: 50%; + height: 6px; + transform: translateY(-50%); + border-radius: 3px; + left: calc(var(--pad) + var(--span) * var(--a)); + width: calc(var(--span) * (var(--b) - var(--a))); + background: linear-gradient(to right, var(--from), var(--to)); + opacity: 0.45; + } + .db.horizontal .db-dot { + top: 50%; + left: calc(var(--pad) + var(--span) * var(--f)); + transform: translate(-50%, -50%); + } + .db.horizontal .db-label { + top: 50%; + left: calc(var(--pad) + var(--span) * var(--f)); + } + .db.horizontal .db-label.lo { + transform: translate(calc(-100% - 11px), -50%); + } + .db.horizontal .db-label.hi { + transform: translate(11px, -50%); + } + .db.horizontal .db-axis { + display: grid; + grid-template-columns: var(--name-w) minmax(0, 1fr); + column-gap: 8px; + height: 22px; + } + .db.horizontal .db-axis .db-plot { + grid-column: -2; + } + .db.horizontal .db-tick { + top: 6px; + left: calc(var(--pad) + var(--span) * var(--f)); + transform: translateX(-50%); + } + + /* Columns: value up, one entity per column. */ + .db.vertical .db-body { + display: flex; + } + .db.vertical .db-row { + flex: 1 1 0; + min-width: 0; + display: flex; + flex-direction: column-reverse; + align-items: stretch; + } + .db.vertical .db-name { + text-align: center; + font-size: 0.8125em; + padding-top: 4px; + height: 1.4em; + } + .db.vertical .db-plot { + height: 180px; + } + /* Columns are narrow and tall: status text runs up the column. */ + .db.vertical .db-status { + writing-mode: vertical-rl; + transform: rotate(180deg); + overflow: hidden; + } + .db.vertical { + --pad: 22px; + } + .db.vertical .db-grid { + left: 0; + right: 0; + bottom: calc(var(--pad) + var(--span) * var(--f)); + border-top: 1px dashed var(--divider-color, rgba(127, 127, 127, 0.3)); + } + .db.vertical .db-bar { + left: 50%; + width: 6px; + transform: translateX(-50%); + border-radius: 3px; + bottom: calc(var(--pad) + var(--span) * var(--a)); + height: calc(var(--span) * (var(--b) - var(--a))); + background: linear-gradient(to top, var(--from), var(--to)); + opacity: 0.45; + } + .db.vertical .db-dot { + left: 50%; + bottom: calc(var(--pad) + var(--span) * var(--f)); + transform: translate(-50%, 50%); + } + .db.vertical .db-label { + left: 50%; + bottom: calc(var(--pad) + var(--span) * var(--f)); + } + .db.vertical .db-label.lo { + transform: translate(-50%, calc(100% + 11px)); + } + .db.vertical .db-label.hi { + transform: translate(-50%, -11px); + } + .db.vertical .db-axis { + flex: none; + position: relative; + width: 3.5em; + height: 180px; + } + .db.vertical .db-tick { + right: 6px; + bottom: calc(var(--pad) + var(--span) * var(--f)); + transform: translateY(50%); + } + `, + thumbnail: () => svg` + + + + + + + + + `, + validate(raw: CardConfig) { + validateHistoryOptions(raw); + if (raw.ends !== undefined && !ENDS.includes(raw.ends as Ends)) { + throw new Error(`"ends" must be one of: ${ENDS.join(', ')}`); + } + if (raw.orientation !== undefined && !ORIENTATIONS.includes(raw.orientation as Orientation)) { + throw new Error(`"orientation" must be one of: ${ORIENTATIONS.join(', ')}`); + } + if (raw.show_now !== undefined && typeof raw.show_now !== 'boolean') { + throw new Error('"show_now" must be true or false'); + } + }, + editor: dumbbellEditor, + disconnect: disconnectHistory, + render(ctx) { + const { ends, orientation, showNow } = optionsOf(ctx.raw); + const period = parsePeriod(ctx.raw.period as CardConfig['period']); + let rows: DumbbellRow[]; + if (ends === 'target') { + // Current values only: no history to load. + rows = dumbbellRows(ctx.items, ends, () => [], 0, showNow); + } else { + const frame = historyFrame(ctx); + if (frame.status !== 'ready') return frameMessage(frame, ctx) ?? html``; + rows = dumbbellRows(ctx.items, ends, (i) => frame.series[i]?.points ?? [], frame.range.start, showNow); + } + const span = extentOf(rows.flatMap((r) => r.marks.map((m) => m.value))); + const ticks = span ? niceTicks(span[0], span[1], 4) : []; + const [d0, d1] = ticks.length ? [ticks[0], ticks[ticks.length - 1]] : [0, 1]; + const at = (v: number) => (d1 === d0 ? 0.5 : (v - d0) / (d1 - d0)); + const unit = sharedUnit(ctx.items); + const decimals = stepDecimals(ticks); + const { show } = ctx.config; + const longest = Math.max(...ctx.items.map((e) => e.name.length)); + const nameW = `min(${Math.min(longest, 16) + 1}ch, 38%)`; + const label = localize(ctx.hass, 'chart_aria', { + style: localize(ctx.hass, 'editor_style_dumbbell'), + names: ctx.items.map((e) => e.name).join(', '), + }); + const tickLabels = ticks.map( + (v) => html`${formatNumber(v, decimals, ctx.hass)}`, + ); + const body = rows.map((row) => renderRow(row, ctx, ends, at, ticks, period)); + const classes = `db ${orientation} ${show.name ? '' : 'no-names'} ${show.value ? '' : 'no-values'}`; + return html`
+ ${span ? legend(ctx, ends, showNow, rows, period, unit) : nothing} + ${ + orientation === 'horizontal' + ? html`${body} + ${ticks.length ? html`` : nothing}` + : html`
+ ${ticks.length ? html`` : nothing}${body} +
` + } +
`; + }, +}; diff --git a/src/styles/funnel.ts b/src/styles/funnel.ts new file mode 100644 index 0000000..1f9aaaa --- /dev/null +++ b/src/styles/funnel.ts @@ -0,0 +1,274 @@ +import { css, html, nothing, svg } from 'lit'; +import { baseColor, onFillText } from '../bands'; +import { SERIES_COLORS } from '../chart/colors'; +import { cachedSchema, chartForm, choice } from '../editor/chart-editors'; +import type { FieldBinding } from '../editor/schemas'; +import { isInteractive } from '../actions'; +import { formatNumber } from '../format'; +import { localize, registerStrings } from '../localize'; +import type { ResolvedEntity } from '../resolve'; +import type { ChartEditorContext, ChartStyleModule } from './index'; +import type { CardConfig } from '../types'; + +export const FUNNEL_SHAPES = ['funnel', 'pyramid'] as const; +export const PERCENT_OF = ['first', 'previous', 'none'] as const; +export const FUNNEL_COLOR_BY = ['series', 'bands'] as const; +export type FunnelShape = (typeof FUNNEL_SHAPES)[number]; +export type PercentOf = (typeof PERCENT_OF)[number]; + +/** A stage below this width (percent of the widest) is too narrow for its percentage inside. */ +const NARROW = 22; +/** How much the last funnel stage narrows towards its bottom edge. */ +const TAPER = 0.8; + +registerStrings('en', { + editor_style_funnel: 'Funnel', + editor_funnel_shape: 'Shape', + editor_funnel_shape_funnel: 'Funnel (first stage at the top)', + editor_funnel_shape_pyramid: 'Pyramid (first stage at the bottom)', + editor_funnel_percent_of: 'Percentages', + editor_funnel_percent_of_first: 'Share of the first stage', + editor_funnel_percent_of_previous: 'Share of the stage before', + editor_funnel_percent_of_none: "Don't show", + editor_funnel_color_by: 'Colour', + editor_funnel_color_by_series: 'One colour per entity', + editor_funnel_color_by_bands: 'By colour band', + funnel_aria: '{style} of {names}', +}); + +/** Each stage's width as a percentage of the largest; no number (or none above 0) is 0. */ +export function stageWidths(values: (number | null)[]): number[] { + const max = Math.max(0, ...values.map((v) => (v !== null && v > 0 ? v : 0))); + return values.map((v) => (max > 0 && v !== null && v > 0 ? (v / max) * 100 : 0)); +} + +/** + * Each stage as a percentage of the first, or of the stage before (the first is then 100). + * Null where there is nothing to divide by or no value. + */ +export function stagePercents(values: (number | null)[], of: PercentOf): (number | null)[] { + if (of === 'none') return values.map(() => null); + return values.map((v, i) => { + if (v === null) return null; + if (of === 'previous' && i === 0) return 100; + const base = of === 'first' ? values[0] : values[i - 1]; + return base !== null && base !== undefined && base > 0 ? (v / base) * 100 : null; + }); +} + +/** + * The top and bottom widths of each stage's trapezoid, in the order the stages are listed. + * Funnel: a stage narrows to the width of the one below it; the last narrows a little. Pyramid + * (first stage at the bottom): a stage narrows to the width of the one above it; the top one + * comes to a point. A stage next to one without a value narrows a little instead. + */ +export function stageEdges(widths: number[], shape: FunnelShape): { top: number; bottom: number }[] { + return widths.map((w, i) => { + if (w <= 0) return { top: 0, bottom: 0 }; + const next = widths[i + 1]; + const last = i === widths.length - 1; + if (shape === 'pyramid') { + const top = last ? 0 : next > 0 ? next : w * TAPER; + return { top, bottom: w }; + } + return { top: w, bottom: !last && next > 0 ? next : w * TAPER }; + }); +} + +/** The trapezoid's corners, centred in a 100 × 10 box, as an SVG `points` attribute. */ +export function trapezoid(top: number, bottom: number): string { + const r = (n: number) => Math.round(n * 100) / 100; + const t = r((100 - top) / 2); + const b = r((100 - bottom) / 2); + return `${t},0 ${r(100 - t)},0 ${r(100 - b)},10 ${b},10`; +} + +const FIELDS: FieldBinding[] = [ + { name: 'funnel_shape', path: ['shape'], default: 'funnel' }, + { name: 'funnel_percent_of', path: ['percent_of'], default: 'first' }, + { name: 'funnel_color_by', path: ['color_by'], default: 'series' }, +]; + +function funnelEditor(ctx: ChartEditorContext) { + const schema = cachedSchema('funnel', ctx.hass, () => [ + { name: 'funnel_shape', selector: choice(ctx.hass, 'funnel_shape', FUNNEL_SHAPES) }, + { + type: 'grid', + name: '', + schema: [ + { name: 'funnel_percent_of', selector: choice(ctx.hass, 'funnel_percent_of', PERCENT_OF) }, + { name: 'funnel_color_by', selector: choice(ctx.hass, 'funnel_color_by', FUNNEL_COLOR_BY) }, + ], + }, + ]); + return chartForm(ctx, 'chart', schema, FIELDS); +} + +function check(raw: CardConfig, key: string, allowed: readonly string[]): void { + const v = raw[key]; + if (v !== undefined && !allowed.includes(v as string)) { + throw new Error(`"${key}" must be one of: ${allowed.join(', ')}`); + } +} + +/** The colour name (palette or CSS) a stage is filled with. */ +function stageColor(e: ResolvedEntity, index: number, byBands: boolean): string { + if (byBands) return e.band.color; + const own = (e.config as { color?: unknown }).color; + return typeof own === 'string' && own ? own : SERIES_COLORS[index % SERIES_COLORS.length]; +} + +export const funnel: ChartStyleModule = { + layout: 'chart', + group: 'totals', + height: 240, + styles: css` + .style-funnel .funnel { + display: flex; + flex-direction: column; + gap: 4px; + padding: 4px 0; + } + .style-funnel .stage { + display: grid; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + align-items: center; + gap: 12px; + } + .style-funnel .funnel.no-labels .stage { + grid-template-columns: minmax(0, 1fr); + } + .style-funnel .shape { + position: relative; + height: 44px; + } + .style-funnel .shape svg { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + overflow: visible; + transform: scaleX(0); + transform-origin: 50% 50%; + transition: transform var(--cd-duration) var(--cd-easing); + } + :host([ready]) .style-funnel .shape svg { + transform: scaleX(1); + } + .style-funnel polygon.empty { + fill: none; + stroke: var(--secondary-text-color); + stroke-width: 1; + stroke-dasharray: 4 3; + vector-effect: non-scaling-stroke; + } + .style-funnel .percent { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + font-weight: 500; + color: #fff; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.35); + pointer-events: none; + } + .style-funnel .percent.dark { + color: #1d1f24; + text-shadow: none; + } + .style-funnel .percent.theme { + color: var(--primary-text-color); + text-shadow: none; + } + .style-funnel .label { + display: flex; + flex-direction: column; + min-width: 0; + line-height: 1.25; + } + .style-funnel .name { + color: var(--secondary-text-color); + font-size: 0.875em; + overflow: hidden; + overflow-wrap: anywhere; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + } + .style-funnel .value { + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .style-funnel .unavailable .value { + color: var(--secondary-text-color); + font-weight: 400; + } + `, + thumbnail: () => svg` + + + `, + validate(raw: CardConfig) { + check(raw, 'shape', FUNNEL_SHAPES); + check(raw, 'percent_of', PERCENT_OF); + check(raw, 'color_by', FUNNEL_COLOR_BY); + }, + editor: funnelEditor, + render(ctx) { + const { items, raw, hass, config } = ctx; + const shape = (raw.shape as FunnelShape | undefined) ?? 'funnel'; + const byBands = raw.color_by === 'bands'; + const values = items.map((e) => (e.available ? e.value : null)); + const widths = stageWidths(values); + const edges = stageEdges(widths, shape); + const percents = stagePercents(values, (raw.percent_of as PercentOf | undefined) ?? 'first'); + const { show } = config; + const labels = show.name || show.value; + const stages = items.map((e, i) => { + const color = stageColor(e, i, byBands); + const percent = percents[i]; + const percentText = percent === null ? '' : `${formatNumber(percent, 0, hass)}%`; + const textTone = widths[i] < NARROW ? 'theme' : onFillText(color); + const interactive = isInteractive(e.config); + const { top, bottom } = edges[i]; + const drawn = widths[i] > 0; + return html``; + }); + // A pyramid builds up from its first stage, so the first is drawn at the bottom. + if (shape === 'pyramid') stages.reverse(); + const label = localize(hass, 'funnel_aria', { + style: localize(hass, `editor_funnel_shape_${shape}`).replace(/ \(.*\)$/, ''), + names: items.map((e) => e.name).join(', '), + }); + return html`
${stages}
`; + }, +}; diff --git a/src/styles/grouped-bars.ts b/src/styles/grouped-bars.ts new file mode 100644 index 0000000..9c41524 --- /dev/null +++ b/src/styles/grouped-bars.ts @@ -0,0 +1,510 @@ +import { css, html, nothing, svg } from 'lit'; +import { repeat } from 'lit/directives/repeat.js'; +import { baseColor, onFillText } from '../bands'; +import { extentOf, withZero } from '../chart/compare'; +import { cachedSchema, chartForm, choice } from '../editor/chart-editors'; +import type { FieldBinding } from '../editor/schemas'; +import { formatNumber, withUnit } from '../format'; +import { localize, registerStrings } from '../localize'; +import type { ResolvedEntity } from '../resolve'; +import type { ChartContext, ChartEditorContext, ChartStyleModule } from './index'; +import type { CardConfig, HomeAssistant, SortMode } from '../types'; +import { SERIES_COLORS } from '../chart/frame'; + +export const MODES = ['grouped', 'stacked', 'percent'] as const; +export type Mode = (typeof MODES)[number]; +const COLOR_BY = ['series', 'bands'] as const; + +/** Series colours in order, for entities without their own `color` (the line chart's order). */ +/** Bar colours in order: the series colours every chart uses. */ +export const BAR_COLORS = SERIES_COLORS; + +/** Heights in px: one bar in grouped mode, the gap between a row's bars, a stacked bar, row padding. */ +const BAR_H = 12; +const BAR_GAP = 3; +const STACK_H = 20; +const ROW_PAD = 8; +/** Percent mode labels a segment only when it is at least this share of the width. */ +const MIN_LABEL_SHARE = 0.12; + +registerStrings('en', { + editor_style_grouped_bars: 'Grouped bars', + editor_mode: 'Bars', + editor_mode_grouped: 'Side by side', + editor_mode_stacked: 'Stacked', + editor_mode_percent: 'Stacked to 100 %', + editor_bars_color_by: 'Colour', + editor_bars_color_by_series: 'One colour per series', + editor_bars_color_by_bands: 'By colour band', + editor_bar_group: 'Row', + editor_bar_series: 'Series', + editor_bar_color: 'Bar colour', + grouped_bars_aria: 'Bar chart of {names}', +}); + +/** One entity as the chart sees it, in config order. */ +export interface BarInput { + /** Index into the card's items, for actions. */ + index: number; + group: string; + series: string; + value: number | null; +} + +/** One drawn bar or stack segment, in value units (percent mode: share of the row, -1..1). */ +export interface Segment { + index: number; + series: string; + value: number | null; + from: number; + to: number; + /** Grouped mode: which bar line in the row (0 = top). */ + slot: number; +} + +export interface BarRow { + key: string; + label: string; + segments: Segment[]; + /** Sum of the row's values, ignoring missing ones; null when none has a value. */ + total: number | null; +} + +export interface BarLayout { + rows: BarRow[]; + domain: [number, number]; + /** Series names in order of first appearance. */ + series: string[]; +} + +/** + * Rows from entities: entities sharing a group share a row, in the order they first appear. Bars + * run from zero; stacks put positives right of zero and negatives left, each in config order; + * percent mode divides by the row's absolute total. Rows are then sorted by total, if asked. + */ +export function layoutBars(inputs: BarInput[], mode: Mode, sort: SortMode = 'none'): BarLayout { + const rows = new Map(); + const series: string[] = []; + for (const input of inputs) { + let row = rows.get(input.group); + if (!row) { + row = { key: input.group, label: input.group, segments: [], total: null }; + rows.set(input.group, row); + } + if (!series.includes(input.series)) series.push(input.series); + const slot = row.segments.length; + row.segments.push({ index: input.index, series: input.series, value: input.value, from: 0, to: 0, slot }); + if (input.value !== null) row.total = (row.total ?? 0) + input.value; + } + const list = [...rows.values()]; + for (const row of list) { + const absolute = row.segments.reduce((sum, s) => sum + Math.abs(s.value ?? 0), 0); + let up = 0; + let down = 0; + for (const s of row.segments) { + if (s.value === null) continue; + const v = mode === 'percent' ? (absolute === 0 ? 0 : s.value / absolute) : s.value; + if (mode === 'grouped') { + s.to = v; + } else if (v >= 0) { + s.from = up; + s.to = up += v; + } else { + s.from = down; + s.to = down += v; + } + } + } + const ends = list.flatMap((r) => r.segments.flatMap((s) => (s.value === null ? [] : [s.from, s.to]))); + const domain = withZero(extentOf(ends) ?? [0, 1]); + if (domain[0] === domain[1]) domain[1] = 1; + if (sort !== 'none') { + const dir = sort === 'ascending' ? 1 : -1; + list.sort((a, b) => { + if ((a.total === null) !== (b.total === null)) return a.total === null ? 1 : -1; + return ((a.total ?? 0) - (b.total ?? 0)) * dir; + }); + } + return { rows: list, domain, series }; +} + +/** Height of one row in px: grouped rows grow with their bars. */ +export function rowHeight(row: BarRow, mode: Mode): number { + if (mode !== 'grouped') return STACK_H + 2 * ROW_PAD; + const n = Math.max(1, row.segments.length); + return n * BAR_H + (n - 1) * BAR_GAP + 2 * ROW_PAD; +} + +/** The text an option holds, or undefined when blank or not text. */ +function text(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() !== '' + ? value.trim() + : typeof value === 'number' + ? String(value) + : undefined; +} + +/** Items back in the order the card lists them, so rows and series don't move when one sorts. */ +function configOrder(ctx: ChartContext): { e: ResolvedEntity; index: number }[] { + const listed = ctx.config.entities; + return ctx.items + .map((e, index) => ({ e, index, at: listed.indexOf(e.config) })) + .sort((a, b) => (a.at === -1 ? Infinity : a.at) - (b.at === -1 ? Infinity : b.at) || a.index - b.index); +} + +function inputsFor(ctx: ChartContext): BarInput[] { + return configOrder(ctx).map(({ e, index }) => { + const own = e.config as typeof e.config & { group?: unknown; series?: unknown }; + return { + index, + // An entity without a row gets its own, keyed by position so a repeated id stays apart. + group: text(own.group) ?? `\u0000${index}`, + series: text(own.series) ?? '', + value: e.available ? e.value : null, + }; + }); +} + +function percent(share: number, hass: HomeAssistant): string { + return `${formatNumber(Math.abs(share) * 100, 0, hass)} %`; +} + +const pct = (v: number) => `${(v * 100).toFixed(3)}%`; + +export function validateBars(raw: CardConfig): void { + if (raw.mode !== undefined && !MODES.includes(raw.mode as Mode)) { + throw new Error(`"mode" must be one of: ${MODES.join(', ')}`); + } + if (raw.color_by !== undefined && !COLOR_BY.includes(raw.color_by as (typeof COLOR_BY)[number])) { + throw new Error(`"color_by" must be one of: ${COLOR_BY.join(', ')}`); + } + (raw.entities ?? []).forEach((item, i) => { + if (typeof item !== 'object' || item === null) return; + for (const key of ['group', 'series'] as const) { + const v = (item as unknown as Record)[key]; + if (v !== undefined && v !== null && typeof v !== 'string' && typeof v !== 'number') { + throw new Error(`Entity ${i + 1}: "${key}" must be text`); + } + } + }); +} + +const BAR_FIELDS: FieldBinding[] = [ + { name: 'mode', path: ['mode'], default: 'grouped' }, + { name: 'bars_color_by', path: ['color_by'], default: 'series' }, +]; + +export function barsEditor(ctx: ChartEditorContext) { + const schema = cachedSchema('grouped_bars', ctx.hass, () => [ + { + type: 'grid', + name: '', + schema: [ + { name: 'mode', selector: choice(ctx.hass, 'mode', MODES) }, + { name: 'bars_color_by', selector: choice(ctx.hass, 'bars_color_by', COLOR_BY) }, + ], + }, + ]); + return chartForm(ctx, 'chart', schema, BAR_FIELDS); +} + +const ENTITY_FIELDS: FieldBinding[] = [ + { name: 'bar_group', path: ['group'] }, + { name: 'bar_series', path: ['series'] }, + { name: 'bar_color', path: ['color'] }, +]; + +function renderBars(ctx: ChartContext) { + const { hass } = ctx; + const mode: Mode = (ctx.raw.mode as Mode | undefined) ?? 'grouped'; + const byBands = ctx.raw.color_by === 'bands'; + const layout = layoutBars(inputsFor(ctx), mode, ctx.config.sort); + const [lo, hi] = layout.domain; + const x = (v: number) => (v - lo) / (hi - lo); + const zero = x(0); + const colorOf = (s: Segment) => { + const e = ctx.items[s.index]; + const own = (e.config as { color?: unknown }).color; + if (byBands) return e.band.color; + if (typeof own === 'string' && own) return own; + return BAR_COLORS[layout.series.indexOf(s.series) % BAR_COLORS.length]; + }; + const { show } = ctx.config; + const showLegend = !byBands && layout.series.some((s) => s !== '') && ctx.raw.show?.legend !== false; + // Room either side of the bars for value labels, as wide as the longest one. + const valueTexts = ctx.items.map((e) => e.display.length); + const valueCh = show.value && mode !== 'percent' ? Math.min(12, Math.max(4, ...valueTexts)) : 0; + // Value labels are 11 px text: about 6.5 px a character, plus their padding. + const valuePx = valueCh ? Math.round(valueCh * 6.5 + 8) : 4; + const nameCh = Math.max( + 0, + ...layout.rows.map((r) => (r.label.startsWith('\u0000') ? ctx.items[r.segments[0].index].name : r.label).length), + ); + let offset = 0; + const placed = layout.rows.map((row) => { + const top = offset; + offset += rowHeight(row, mode); + return { row, top }; + }); + const label = localize(hass, 'grouped_bars_aria', { names: ctx.items.map((e) => e.name).join(', ') }); + + const segment = (s: Segment) => { + const e = ctx.items[s.index]; + const color = colorOf(s); + const left = x(Math.min(s.from, s.to)); + const width = Math.abs(x(s.to) - x(s.from)); + const top = mode === 'grouped' ? ROW_PAD + s.slot * (BAR_H + BAR_GAP) : ROW_PAD; + const height = mode === 'grouped' ? BAR_H : STACK_H; + const negative = s.to < s.from || (mode === 'grouped' && s.to < 0); + const share = mode === 'percent' && e.available ? s.to - s.from : 0; + const inside = mode === 'percent' && show.value && Math.abs(share) >= MIN_LABEL_SHARE; + return html`
+ ${ + inside + ? html`` + : nothing + } +
+ ${ + mode === 'grouped' && show.value + ? html`` + : nothing + }`; + }; + + const rowTotal = (row: BarRow) => { + if (mode !== 'stacked' || !show.value) return nothing; + const first = ctx.items[row.segments[0].index]; + const ends = row.segments.filter((s) => s.value !== null).map((s) => s.to); + const right = Math.max(0, ...ends); + const left = Math.min(0, ...ends); + const onLeft = right === 0 && left < 0; + const textOut = + row.total === null ? first.display : withUnit(formatNumber(row.total, first.decimals, hass), first.unit); + return html``; + }; + + const negatives = lo < 0; + return html``; +} + +/** The first segment of a series, whose colour the legend shows. */ +function placedSegment(layout: BarLayout, series: string): Segment | undefined { + for (const row of layout.rows) for (const s of row.segments) if (s.series === series) return s; + return undefined; +} + +export const groupedBars: ChartStyleModule = { + layout: 'chart', + group: 'compare', + height: 200, + styles: css` + .bars { + padding: 4px 0; + } + .bar-rows { + position: relative; + transition: height var(--cd-duration) var(--cd-easing); + } + .bar-row { + position: absolute; + left: 0; + right: 0; + top: 0; + display: flex; + align-items: center; + transition: transform var(--cd-duration) var(--cd-easing); + } + .row-name { + flex: none; + width: var(--name-w); + padding-right: 8px; + box-sizing: border-box; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 0.875em; + } + .row-plot { + position: relative; + flex: 1; + min-width: 0; + align-self: stretch; + margin: 0 var(--value-w) 0 var(--neg-w); + } + .bars .zero { + position: absolute; + top: 0; + bottom: 0; + width: 1px; + background: var(--divider-color, rgba(127, 127, 127, 0.3)); + } + .bar-seg { + position: absolute; + box-sizing: border-box; + border-radius: 4px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + transform-origin: left center; + transition: + left var(--cd-duration) var(--cd-easing), + width var(--cd-duration) var(--cd-easing), + transform var(--cd-duration) var(--cd-easing); + } + .bar-seg.negative { + transform-origin: right center; + } + .mode-stacked .bar-seg, + .mode-percent .bar-seg { + border-radius: 2px; + border-right: 1px solid var(--card-background-color, #fff); + } + :host(:not([ready])) .bar-seg { + transform: scaleX(0); + } + .bar-seg:focus-visible { + outline: 2px solid var(--primary-text-color); + outline-offset: 1px; + } + .seg-text { + font-size: 11px; + line-height: 1; + white-space: nowrap; + color: #fff; + pointer-events: none; + } + .seg-text.dark { + color: rgba(0, 0, 0, 0.8); + } + .seg-text.theme { + color: var(--primary-text-color); + } + .bar-value { + position: absolute; + display: flex; + align-items: center; + padding: 0 4px; + font-size: 11px; + line-height: 1; + white-space: nowrap; + color: var(--primary-text-color); + transition: + left var(--cd-duration) var(--cd-easing), + right var(--cd-duration) var(--cd-easing); + } + .bar-value.unavailable { + color: var(--secondary-text-color); + } + .bar-value.total { + font-size: 12px; + font-weight: 500; + } + :host([static]) .bar-row, + :host([static]) .bar-rows, + :host([static]) .bar-value { + transition: none; + } + .bar-legend { + display: flex; + flex-wrap: wrap; + gap: 4px 16px; + padding-top: 8px; + font-size: 0.875em; + color: var(--secondary-text-color); + } + .bar-legend-item { + display: inline-flex; + align-items: center; + gap: 6px; + } + .bar-legend .swatch { + width: 10px; + height: 10px; + border-radius: 50%; + } + `, + thumbnail: () => svg` + + + + + `, + validate: validateBars, + editor: barsEditor, + entityFields: (hass) => ({ + schema: cachedSchema('grouped_bars_entity', hass, () => [ + { + type: 'grid', + name: '', + schema: [ + { name: 'bar_group', selector: { text: {} } }, + { name: 'bar_series', selector: { text: {} } }, + ], + }, + { name: 'bar_color', selector: { text: {} } }, + ]), + fields: ENTITY_FIELDS, + }), + render: renderBars, +}; diff --git a/src/styles/index.ts b/src/styles/index.ts index 6e0645d..eb7ee5f 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -21,6 +21,29 @@ import { segmented } from './segmented'; import { tank } from './tank'; import { thin } from './thin'; import { waffle } from './waffle'; +import { funnel } from './funnel'; +import { radar } from './radar'; +import { polarArea } from './polar-area'; +import { treemap } from './treemap'; +import { icicle, sunburst } from './sunburst'; +import { groupedBars } from './grouped-bars'; +import { bullet } from './bullet'; +import { lollipop } from './lollipop'; +import { waterfall } from './waterfall'; +import { slope } from './slope'; +import { dumbbell } from './dumbbell'; +import { rangeArea } from './range-area'; +import { sparklineStyle } from './sparkline'; +import { streamgraph } from './streamgraph'; +import { candlestick } from './candlestick'; +import { violin } from './violin'; +import { beeswarm } from './beeswarm'; +import { sankey } from './sankey'; +import { timeline } from './timeline'; +import { climate } from './climate'; +import { windRoseStyle } from './wind-rose'; +import { day_clock } from './day-clock'; +import { trend } from './trend'; export interface RenderContext { show: ShowOptions; @@ -80,6 +103,8 @@ export interface ChartStyleModule { render(ctx: ChartContext): TemplateResult; /** Estimated height in px, for masonry sizing. */ height: number; + /** Sections grid size, when not the full-width default (a small tile-like chart). */ + gridOptions?: { columns: number; min_columns: number; min_rows: number }; /** A 64 × 40 picture for the style picker, in theme colours. */ thumbnail(): SVGTemplateResult; /** Throw a readable Error for an invalid option this style reads. */ @@ -130,4 +155,28 @@ export const STYLES: Record = { bubble, heatmap, box, + funnel, + radar, + polar_area: polarArea, + treemap, + sunburst, + icicle, + grouped_bars: groupedBars, + bullet, + lollipop, + waterfall, + slope, + dumbbell, + range_area: rangeArea, + sparkline: sparklineStyle, + streamgraph, + candlestick, + violin, + beeswarm, + sankey, + timeline, + climate, + wind_rose: windRoseStyle, + day_clock, + trend, }; diff --git a/src/styles/line.ts b/src/styles/line.ts index 33cfcbe..7febd6b 100644 --- a/src/styles/line.ts +++ b/src/styles/line.ts @@ -1,5 +1,5 @@ import { css, html, nothing, svg } from 'lit'; -import type { SVGTemplateResult } from 'lit'; +import type { SVGTemplateResult, TemplateResult } from 'lit'; import { baseColor } from '../bands'; import { downsample } from '../chart/downsample'; import { validateAxisOptions } from '../chart/axes'; @@ -16,7 +16,7 @@ import { } from '../chart/frame'; import type { ChartSeries, HistoryFrame } from '../chart/frame'; import { chartArea, gridStyles, plotAxes, plotWidth, stopChartWidth } from '../chart/grid'; -import type { PlotScales } from '../chart/grid'; +import type { PlotAxes, PlotScales } from '../chart/grid'; import { chartFrame, chartLayout, headerOver, layoutStyles, validateLayout } from '../chart/layout'; import { runPath, runs } from '../chart/paths'; import { MAX_DOTS, dotSize, dotsPath, drawOrder, seriesStyle, validateSeriesStyle } from '../chart/series-style'; @@ -30,18 +30,48 @@ import { validateTooltipOptions, } from '../chart/tooltip'; import { extent } from '../chart/scale'; +import { + overviewLines, + overviewStrip, + stopZoom, + validateZoomOptions, + visiblePoints, + zoomBusy, + zoomGestures, + zoomLayer, + zoomStyles, + zoomView, +} from '../chart/zoom'; +import { + annotationEntities, + annotationLayers, + annotationStyles, + annotationValues, + stopAnnotations, + validateAnnotations, +} from '../chart/annotations'; +import { + extremesLayer, + extremesOn, + extremesOptions, + extremesStyles, + validateExtremes, + withEntityExtremes, + withEntityExtremesFields, +} from '../chart/extremes'; +import { dataLabelStyles, dataLabels, labelOptions, validateLabelOptions } from '../chart/data-labels'; +import { stopSync, syncChart, validateSyncOptions } from '../chart/sync'; +import { multiplesGrid, multiplesOptions, multiplesStyles, validateMultiples } from '../chart/multiples'; +import { positivePoints } from '../chart/log-scale'; import { lineEditor } from '../editor/chart-editors'; import { entitySeriesFields, entitySeriesSchema } from '../editor/schemas'; -import { formatNumber } from '../format'; import { localize, registerStrings } from '../localize'; -import type { ChartStyleModule } from './index'; +import type { ChartContext, ChartStyleModule } from './index'; import type { Range } from '../history/types'; import type { CardConfig, ResolvedBand } from '../types'; /** Points kept per series after thinning: about two per pixel on a wide card. */ const MAX_POINTS = 500; -/** Above this many points a series gets no value labels: they would overlap. */ -const MAX_LABELS = 40; const COLOR_BY = ['series', 'bands'] as const; @@ -51,6 +81,7 @@ registerStrings('en', { chart_error: "Couldn't load history: {error}", chart_no_data: 'No readings in this period.', chart_aria: '{style} chart of {names}', + chart_log_nothing: 'Nothing above zero to draw on a log scale.', }); /** @@ -58,8 +89,11 @@ registerStrings('en', { * changes colour where it crosses a threshold. */ function bandGradient(id: string, bands: ResolvedBand[], scales: PlotScales): SVGTemplateResult { - const [lo, hi] = scales.domain; - const stop = (v: number) => Math.min(1, Math.max(0, (v - lo) / (hi - lo))); + // Through the scale, so a log axis puts each stop where the value is drawn (zero and below: the bottom). + const stop = (v: number) => { + const at = (VIEW_H - scales.y(v)) / VIEW_H; + return Number.isFinite(at) ? Math.min(1, Math.max(0, at)) : 0; + }; const stops: SVGTemplateResult[] = []; bands.forEach((band, i) => { const from = Number.isFinite(band.from) ? stop(band.from) : 0; @@ -108,24 +142,94 @@ export function seriesPath(s: ChartSeries, scales: PlotScales, stroke: string, r `; } -function valueLabels(frame: HistoryFrame, scales: PlotScales, ctx: Parameters[0]) { - return html`
- ${frame.series.flatMap((s) => { - // The points at either edge are carried readings, not new ones: no label (groups are all real). - const readings = s.points.filter( - (p) => p.v !== null && (frame.group !== undefined || (p.t !== frame.range.start && p.t !== frame.range.end)), - ); - if (readings.length > MAX_LABELS) return []; - return readings.map( - (p) => - html`${formatNumber(p.v as number, s.entity.decimals, ctx.hass)}`, - ); - })} -
`; +/** Value labels on the line's readings, kept clear of each other. */ +function pointLabels( + frame: HistoryFrame, + series: ChartSeries[], + view: Range, + scales: PlotScales, + options: NonNullable>, + compact: boolean, + ctx: Parameters[0], +) { + const { range } = frame; + return dataLabels( + series.map((s) => ({ + points: s.points.filter((p) => p.t >= view.start && p.t <= view.end), + decimals: s.entity.decimals, + color: s.color, + })), + options, + { + at: (p) => [scales.x(p.t) / VIEW_W, scales.y(p.v as number) / VIEW_H], + width: plotWidth(ctx), + // The plot's height less its time labels, roughly. + height: compact ? 96 : 162, + // The points at either edge are carried readings, not new ones (groups are all real). + skip: (p) => frame.group === undefined && (p.t === range.start || p.t === range.end), + }, + ctx.hass, + ); +} + +/** + * One plot of `series`: the lines, value labels, tooltip and gridlines. `id` keeps band gradient + * ids apart when a card draws several plots (small multiples). + */ +/** Rough height of the plot itself in px: the frame less the time labels and top padding. */ +function plotHeight(compact: boolean, axes: { x: boolean; y: boolean; labels: string }): number { + const outside = axes.labels === 'outside'; + return (compact ? 110 : 180) - (outside && axes.x ? (compact ? 14 : 18) : 0) - (outside && axes.y ? 6 : 0); +} + +/** Roughly where a header laid over the plot sits, in px, so marks' labels keep clear of it. */ +function overHeaderBox(frame: HistoryFrame, compact: boolean, width: number) { + const chars = frame.series.reduce((n, s) => n + s.entity.name.length + s.entity.display.length + 6, 0); + return { l: 0, t: 0, r: Math.min(width, chars * 8), b: compact ? 24 : 34 }; +} + +function linePlot( + ctx: ChartContext, + frame: HistoryFrame, + series: ChartSeries[], + axes: PlotAxes, + width: number, + id: string, + over: TemplateResult | typeof nothing = nothing, + time = hoverTime(ctx.host), +) { + const { scales } = axes; + const labels = labelOptions(ctx.raw); + const tooltip = tooltipEnabled(ctx.raw); + const byBands = ctx.raw.color_by === 'bands'; + const defs = byBands + ? svg`${series.map((s) => bandGradient(`cd-bands-${s.index}${id}`, s.entity.bands, scales))}` + : nothing; + // Listed order, last on top, with any series marked "on top" drawn after the rest. + const ordered = drawOrder(series, (s) => seriesStyle(s.entity.config, ctx.raw).onTop); + const lines = svg`${ordered.map((s) => + seriesPath(s, scales, byBands ? `url(#cd-bands-${s.index}${id})` : s.color, frame.range, ctx.raw), + )}`; + const label = localize(ctx.hass, 'chart_aria', { + style: localize(ctx.hass, 'editor_style_line'), + names: series.map((s) => s.entity.name).join(', '), + }); + return chartArea({ + axes, + ctx, + content: lines, + label, + defs, + overlay: html`${over} ${labels ? pointLabels(frame, series, frame.range, scales, labels, true, ctx) : nothing} + ${tooltip ? tooltipLayer(ctx, series, scales, time, width) : nothing}`, + hover: tooltip + ? hoverHandlers( + ctx, + frame.range, + series.map((s) => s.points), + ) + : undefined, + }); } export const line: ChartStyleModule = { @@ -137,6 +241,11 @@ export const line: ChartStyleModule = { ${gridStyles} ${tooltipStyles} ${layoutStyles} + ${zoomStyles} + ${annotationStyles} + ${dataLabelStyles} + ${extremesStyles} + ${multiplesStyles} .style-line .line { stroke-linecap: round; stroke-linejoin: round; @@ -159,40 +268,113 @@ export const line: ChartStyleModule = { validateTooltipOptions(raw); validateSeriesStyle(raw); validateLayout(raw); + validateZoomOptions(raw); + validateAnnotations(raw); + validateLabelOptions(raw); + validateExtremes(raw); + validateSyncOptions(raw); + validateMultiples(raw); if (raw.color_by !== undefined && !COLOR_BY.includes(raw.color_by as (typeof COLOR_BY)[number])) { throw new Error(`"color_by" must be one of: ${COLOR_BY.join(', ')}`); } }, editor: lineEditor, - entityFields: (hass, config) => ({ - schema: entitySeriesSchema(hass), - fields: entitySeriesFields(config.dots === true), - }), + entityFields: (hass, config) => { + const schema = entitySeriesSchema(hass); + const fields = entitySeriesFields(config.dots === true); + // While the card marks highs, lows or averages, each series can opt out. + return extremesOn(extremesOptions(config)) + ? { schema: withEntityExtremes(schema, hass), fields: withEntityExtremesFields(fields) } + : { schema, fields }; + }, + watch: annotationEntities, disconnect(host) { disconnectHistory(host); stopChartWidth(host); stopHover(host); + stopZoom(host); + stopAnnotations(host); + stopSync(host); }, render(ctx) { const frame = historyFrame(ctx); + // Charts in a sync group share the pointer's time: another chart's shows here too. + const own = hoverTime(ctx.host); + const shared = syncChart(ctx, frame, own); const nav = periodNav(frame, ctx); const names = seriesHeader(frame, ctx); const header = nav === nothing && names === nothing ? nothing : html`${nav}${names}`; const message = frameMessage(frame, ctx); const layout = chartLayout(ctx.raw); if (message) return chartFrame(layout.header === 'over' ? { ...layout, header: 'above' } : layout, header, message); - const axes = plotAxes(ctx, frame.range, extent(frame.series.map((s) => s.points))); + const multiples = multiplesOptions(ctx.raw); + if (multiples) { + // A log axis has no place for zero or below: each small chart draws only the readings above it. + const panels = + ctx.raw.y_scale === 'log' + ? { ...frame, series: frame.series.map((s) => ({ ...s, points: positivePoints(s.points) })) } + : frame; + // One small chart per series: the period buttons stay above, each chart has its own title. + return chartFrame( + { ...layout, header: 'above' }, + nav, + multiplesGrid(ctx, panels, multiples, own ?? shared, (s, axes, width) => + linePlot(ctx, panels, [s], axes, width, `-${s.index}`, nothing, own ?? shared), + ), + ); + } + // Zoomed, the chart draws the readings in its window (and one either side), fitted to them. + const zoom = zoomView(ctx, frame); + const view = zoom.range; + const shown = zoom.zoomed + ? frame.series.map((s) => ({ ...s, points: visiblePoints(s.points, view) })) + : frame.series; + // A log axis has no place for zero or below: those readings are gaps in the drawing (the tooltip still reads them). + const log = ctx.raw.y_scale === 'log'; + const drawn = log ? shown.map((s) => ({ ...s, points: positivePoints(s.points) })) : shown; + // Threshold lines widen the value axis, so a target off the readings' range still shows. + const thresholds = annotationValues(ctx.raw, ctx.hass) + .filter((v) => !log || v > 0) + .map((v) => ({ t: view.start, v })); + const data = extent([...drawn.map((s) => s.points), thresholds]); + if (log && !data) { + const empty = html`
${localize(ctx.hass, 'chart_log_nothing')}
`; + return chartFrame(layout.header === 'over' ? { ...layout, header: 'above' } : layout, header, empty); + } + const axes = plotAxes(ctx, view, data); const { scales } = axes; - const tooltip = tooltipEnabled(ctx.raw); + const tooltip = tooltipEnabled(ctx.raw) && !zoomBusy(ctx.host); const byBands = ctx.raw.color_by === 'bands'; const defs = byBands - ? svg`${frame.series.map((s) => bandGradient(`cd-bands-${s.index}`, s.entity.bands, scales))}` + ? svg`${shown.map((s) => bandGradient(`cd-bands-${s.index}`, s.entity.bands, scales))}` : nothing; // Listed order, last on top, with any series marked "on top" drawn after the rest. - const ordered = drawOrder(frame.series, (s) => seriesStyle(s.entity.config, ctx.raw).onTop); + const ordered = drawOrder(drawn, (s) => seriesStyle(s.entity.config, ctx.raw).onTop); const lines = svg`${ordered.map((s) => seriesPath(s, scales, byBands ? `url(#cd-bands-${s.index})` : s.color, frame.range, ctx.raw), )}`; + const hover = tooltipEnabled(ctx.raw) + ? hoverHandlers( + ctx, + view, + shown.map((s) => s.points), + ) + : undefined; + const notes = annotationLayers(ctx, frame, scales, plotWidth(ctx)); + const labels = labelOptions(ctx.raw); + const width = plotWidth(ctx); + const marks = extremesLayer({ + ctx, + series: shown, + scales, + range: view, + group: frame.group, + timeWeighted: frame.source === 'history' && frame.group === undefined, + width, + height: plotHeight(layout.compact, axes.options), + until: Math.min(view.end, Date.now()), + obstacles: layout.header === 'over' ? [overHeaderBox(frame, layout.compact, width)] : [], + }); const label = localize(ctx.hass, 'chart_aria', { style: localize(ctx.hass, 'editor_style_line'), names: frame.series.map((s) => s.entity.name).join(', '), @@ -203,19 +385,19 @@ export const line: ChartStyleModule = { chartArea({ axes, ctx, - content: lines, + content: svg`${notes.behind}${marks.lines}${lines}${notes.front}`, label, defs, overlay: html`${layout.header === 'over' ? headerOver(header) : nothing} - ${ctx.raw.labels === true ? valueLabels(frame, scales, ctx) : nothing} - ${tooltip ? tooltipLayer(ctx, frame.series, scales, hoverTime(ctx.host), plotWidth(ctx)) : nothing}`, - hover: tooltip - ? hoverHandlers( - ctx, - frame.range, - frame.series.map((s) => s.points), - ) - : undefined, + ${labels ? pointLabels(frame, drawn, view, scales, labels, layout.compact, ctx) : nothing} + ${zoomLayer(ctx, zoom)} ${notes.overlay} ${marks.overlay} + ${tooltip ? tooltipLayer(ctx, shown, scales, own ?? shared, plotWidth(ctx)) : nothing}`, + hover, + gestures: zoom.options.zoom ? zoomGestures(ctx, zoom, hover) : undefined, + classes: zoom.options.zoom ? `zoomable ${zoom.zoomed ? 'zoomed' : ''}` : '', + below: zoom.options.overview + ? overviewStrip(ctx, zoom, frame.range, overviewLines(frame.series, frame.range)) + : nothing, }), ); }, diff --git a/src/styles/lollipop.ts b/src/styles/lollipop.ts new file mode 100644 index 0000000..3d140cd --- /dev/null +++ b/src/styles/lollipop.ts @@ -0,0 +1,241 @@ +import { css, html, nothing, svg } from 'lit'; +import { cachedSchema, chartForm, choice } from '../editor/chart-editors'; +import { entityMark, markStyles } from '../chart/marks'; +import { localize, registerStrings } from '../localize'; +import type { ResolvedEntity } from '../resolve'; +import type { ChartContext, ChartEditorContext, ChartStyleModule } from './index'; +import type { CardConfig } from '../types'; + +export const ORIENTATIONS = ['vertical', 'horizontal'] as const; +export type Orientation = (typeof ORIENTATIONS)[number]; + +registerStrings('en', { + editor_style_lollipop: 'Lollipop', + editor_orientation: 'Direction', + editor_orientation_vertical: 'Upright', + editor_orientation_horizontal: 'Across', + chart_aria: '{style} chart of {names}', +}); + +/** The stem, dot and target ring, placed by the entity's drawing variables. */ +function stick(e: ResolvedEntity, ctx: ChartContext, withValue: boolean) { + const { show } = ctx.config; + return html`
+
+ ${ + e.available + ? html`
+ ${show.target && e.targetPosition !== null ? html`
` : nothing} +
+ ${withValue && show.value ? html`${e.display}` : nothing}` + : withValue + ? html`${e.display}` + : nothing + } +
+
`; +} + +function verticalItem(e: ResolvedEntity, index: number, ctx: ChartContext) { + const { show } = ctx.config; + const inner = html`${stick(e, ctx, true)} ${show.name ? html`${e.name}` : nothing}`; + return entityMark(e, index, inner, 'pop'); +} + +function horizontalItem(e: ResolvedEntity, index: number, ctx: ChartContext) { + const { show } = ctx.config; + const value = show.value || !e.available; + const inner = html`${show.name ? html`${e.name}` : nothing} ${stick(e, ctx, false)} + ${value ? html`${e.display}` : html``}`; + return entityMark(e, index, inner, 'pop'); +} + +export const lollipop: ChartStyleModule = { + layout: 'chart', + group: 'compare', + height: 200, + styles: css` + ${markStyles} + .style-lollipop .lane { + position: relative; + } + .style-lollipop .scale { + position: absolute; + } + .style-lollipop .stem { + position: absolute; + background: var(--c-start); + border-radius: 2px; + } + .style-lollipop .dot { + position: absolute; + width: 14px; + height: 14px; + border-radius: 50%; + background: var(--c-start); + background-image: radial-gradient( + circle at 35% 30%, + color-mix(in srgb, var(--c-start) 70%, #fff), + var(--c-start) + ); + } + .style-lollipop .target { + position: absolute; + width: 8px; + height: 8px; + border-radius: 50%; + border: 2px solid var(--primary-text-color); + background: var(--card-background-color, #fff); + box-sizing: content-box; + } + + /* Upright: one column per entity over a shared baseline. */ + .style-lollipop .vertical { + display: flex; + align-items: stretch; + } + .style-lollipop .vertical .pop { + flex: 1 1 0; + min-width: 0; + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + } + .style-lollipop .vertical .lane { + align-self: stretch; + height: 150px; + border-bottom: 1px solid var(--divider-color, rgba(127, 127, 127, 0.3)); + } + .style-lollipop .vertical .scale { + left: 50%; + top: 26px; + bottom: 0; + } + .style-lollipop .vertical .stem { + left: -1.5px; + width: 3px; + bottom: calc(var(--start) * 100%); + height: calc((var(--end) - var(--start)) * 100%); + transition: + bottom var(--cd-duration) var(--cd-easing), + height var(--cd-duration) var(--cd-easing); + } + .style-lollipop .vertical .dot { + left: -7px; + bottom: calc(var(--pos) * 100% - 7px); + transition: bottom var(--cd-duration) var(--cd-easing); + } + .style-lollipop .vertical .target { + left: -6px; + bottom: calc(var(--target) * 100% - 6px); + } + .style-lollipop .vertical .tip { + position: absolute; + left: 0; + bottom: calc(var(--pos) * 100% + 10px); + transform: translateX(-50%); + font-size: 0.875em; + line-height: 1.2; + padding: 0 3px; + border-radius: 3px; + background: var(--card-background-color, #fff); + z-index: 1; + transition: bottom var(--cd-duration) var(--cd-easing); + } + .style-lollipop .vertical .name { + max-width: 100%; + color: var(--secondary-text-color); + font-size: 0.875em; + padding: 0 2px; + } + .style-lollipop .vertical .entity.unavailable .value { + font-size: 0.75em; + white-space: nowrap; + } + + /* Across: one row per entity, stems growing to the right. */ + .style-lollipop .horizontal { + display: grid; + grid-template-columns: minmax(0, auto) minmax(96px, 1fr) fit-content(8em); + column-gap: 12px; + } + .style-lollipop .horizontal.no-name { + grid-template-columns: minmax(96px, 1fr) fit-content(8em); + } + .style-lollipop .horizontal .pop { + display: grid; + grid-column: 1 / -1; + grid-template-columns: subgrid; + align-items: center; + padding: 6px 0; + } + .style-lollipop .horizontal .name { + max-width: 14em; + } + .style-lollipop .horizontal .lane { + height: 20px; + border-left: 1px solid var(--divider-color, rgba(127, 127, 127, 0.3)); + } + .style-lollipop .horizontal .scale { + top: 50%; + left: 0; + right: 7px; + } + .style-lollipop .horizontal .stem { + top: -1.5px; + height: 3px; + left: calc(var(--start) * 100%); + width: calc((var(--end) - var(--start)) * 100%); + transition: + left var(--cd-duration) var(--cd-easing), + width var(--cd-duration) var(--cd-easing); + } + .style-lollipop .horizontal .dot { + top: -7px; + left: calc(var(--pos) * 100% - 7px); + transition: left var(--cd-duration) var(--cd-easing); + } + .style-lollipop .horizontal .target { + top: -6px; + left: calc(var(--target) * 100% - 6px); + } + .style-lollipop .horizontal .value { + text-align: right; + } + `, + thumbnail: () => svg` + + + + + + + `, + validate(raw: CardConfig) { + if (raw.orientation !== undefined && !ORIENTATIONS.includes(raw.orientation as Orientation)) { + throw new Error(`"orientation" must be one of: ${ORIENTATIONS.join(', ')}`); + } + }, + editor(ctx: ChartEditorContext) { + const schema = cachedSchema('lollipop', ctx.hass, () => [ + { name: 'orientation', selector: choice(ctx.hass, 'orientation', ORIENTATIONS) }, + ]); + return chartForm(ctx, 'chart', schema, [{ name: 'orientation', path: ['orientation'], default: 'vertical' }]); + }, + render(ctx) { + const orientation = (ctx.raw.orientation as Orientation | undefined) ?? 'vertical'; + const label = localize(ctx.hass, 'chart_aria', { + style: localize(ctx.hass, 'editor_style_lollipop'), + names: ctx.items.map((e) => e.name).join(', '), + }); + const item = orientation === 'vertical' ? verticalItem : horizontalItem; + return html`
+ ${ctx.items.map((e, i) => item(e, i, ctx))} +
`; + }, +}; diff --git a/src/styles/needle.ts b/src/styles/needle.ts index 65a9015..5702767 100644 --- a/src/styles/needle.ts +++ b/src/styles/needle.ts @@ -1,5 +1,19 @@ import { css, html, nothing, svg } from 'lit'; import { baseColor } from '../bands'; +import { formatNumber } from '../format'; +import { + angleAt, + arcBox, + centerContent, + customAngles, + gaugeAngles, + labelPlacement, + scaleTicks, + tickDecimals, + tickStep, + union, +} from '../gauge'; +import type { Box } from '../gauge'; import { arcPath, needlePath, polar } from '../geometry'; import { fitStatusText } from '../status-text'; import type { ResolvedBand } from '../types'; @@ -10,6 +24,13 @@ const CX = 65; const CY = 66; const R = 50; const GAP = 1; +const DEFAULT_ANGLES: [number, number] = [-90, 90]; +/** Tick labels sit just outside the band arc (outer edge R + 6), or past the target marker (R + 14). */ +const LABEL_R = R + 10; +const LABEL_R_TARGET = R + 17; +const LABEL_SIZE = 9; +/** The value line's baseline under the hub in the fixed layout. */ +const VALUE_Y = 94; /** A short plain number for inline style (no float noise, no -0). */ const num = (x: number) => String(Math.round(x * 1000) / 1000 + 0); @@ -44,9 +65,9 @@ function statusInDial(text: string) { } /** Status text in the value's place under a full-height gauge, fitted to the tile's width. */ -function statusBelow(text: string) { +function statusBelow(text: string, baseline = VALUE_Y) { // One line whenever it fits at full size (126 / (0.58 × 12) ≈ 18 characters). - return statusLines(text, 126, 12, 8, 94, 18); + return statusLines(text, 126, 12, 8, baseline, 18); } function statusLines(text: string, width: number, max: number, min: number, lastBaseline: number, splitAbove = 11) { @@ -104,6 +125,9 @@ export const needle: ValueStyleModule = { :host(:not([ready])) .needle-gauge .needle { transform: rotate(-90deg) !important; } + :host(:not([ready])) .needle-gauge.fitted .needle { + transform: rotate(var(--needle-start)) !important; + } .needle-gauge .hub { fill: var(--primary-text-color); } @@ -118,6 +142,15 @@ export const needle: ValueStyleModule = { font-weight: 500; fill: var(--primary-text-color); } + .needle-gauge .tick-label { + font-size: 9px; + fill: var(--secondary-text-color); + } + .needle-gauge .center-text { + font-size: 15px; + font-weight: 500; + fill: var(--primary-text-color); + } .needle-gauge .na { font-size: 12px; fill: var(--secondary-text-color); @@ -141,21 +174,42 @@ export const needle: ValueStyleModule = { } `, render(e, { show, hass }) { + const options = e.config.gauge; + const angles = gaugeAngles(DEFAULT_ANGLES, options); + const at = (t: number) => angleAt(angles, t); const segs = bandSegments(e.bands, e.min, e.max); const arcs = segs.map((s, i) => { - const a0 = -90 + 180 * s.from + (i > 0 ? GAP : 0); - const a1 = -90 + 180 * s.to - (i < segs.length - 1 ? GAP : 0); + const a0 = at(s.from) + (i > 0 ? GAP : 0); + const a1 = at(s.to) - (i < segs.length - 1 ? GAP : 0); if (!(a1 > a0)) return nothing; return svg``; }); - const ticks = Array.from({ length: 11 }, (_, i) => { - const [x1, y1] = polar(CX, CY, 36, -90 + 18 * i); - const [x2, y2] = polar(CX, CY, 40, -90 + 18 * i); + const tickmark = (deg: number) => { + const [x1, y1] = polar(CX, CY, 36, deg); + const [x2, y2] = polar(CX, CY, 40, deg); return svg``; + }; + // With tick labels, a mark at each labelled value; otherwise ten even steps. + const values = options?.tickLabels ? scaleTicks(e.min, e.max) : []; + const range = e.max - e.min; + const ticks = values.length + ? values.map((v) => tickmark(at((v - e.min) / range))) + : Array.from({ length: 11 }, (_, i) => tickmark(at(i / 10))); + const step = tickStep(range); + const hasTarget = show.target && e.targetPosition !== null; + const labelR = hasTarget ? LABEL_R_TARGET : LABEL_R; + const fitted = customAngles(options) || values.length > 0; + // A sweep that comes down past the hub would run through the value line: put it under the arc. + const valueY = fitted ? Math.max(VALUE_Y, Math.ceil(arcBox(CX, CY, R, angles[0], angles[1], 6).y1) + 14) : VALUE_Y; + const labels = values.map((v) => { + const deg = at((v - e.min) / range); + const [x, y] = polar(CX, CY, labelR, deg); + const { anchor, dy } = labelPlacement(deg); + return svg`${formatNumber(v, tickDecimals(v, step), hass)}`; }); let mark: unknown = nothing; if (show.target && e.targetPosition !== null && e.available) { - const deg = -90 + 180 * e.targetPosition; + const deg = at(e.targetPosition); const [tx, ty] = polar(CX, CY, R + 7, deg); const [lx, ly] = polar(CX, CY, R + 14, deg - 3); const [rx, ry] = polar(CX, CY, R + 14, deg + 3); @@ -163,12 +217,30 @@ export const needle: ValueStyleModule = { } // Without a value line under the hub, the drawing ends at the hub and the name moves up. const compact = !show.value; - return html`
-
+ ${ - show.name || show.icon + (show.name && options?.center !== 'name') || show.icon ? html`
${ show.icon @@ -196,10 +278,52 @@ export const needle: ValueStyleModule = { >` : nothing } - ${show.name ? html`${e.name}` : nothing} + ${show.name && options?.center !== 'name' ? html`${e.name}` : nothing}
` : nothing }
`; }, }; + +/** + * The box around a needle gauge as drawn: its band arc, hub and needle, the value line (or status + * text) under the hub, the target marker and the tick labels, with a little margin. + */ +function drawingBox( + e: { min: number; max: number }, + angles: [number, number], + ticks: number[], + step: number, + layout: { hasTarget: boolean; labelR: number; valueY: number; compact: boolean }, + hass: Parameters[2], +): Box { + const boxes: Box[] = [ + arcBox(CX, CY, R, angles[0], angles[1], layout.hasTarget ? 15 : 7), + // The needle reaches 42 from the hub, all the way round. + arcBox(CX, CY, 43, angles[0], angles[1], 0), + { x0: CX - 8, y0: CY - 8, x1: CX + 8, y1: CY + 8 }, + // The value (or status) line under the hub, as in the fixed layout. + layout.compact + ? { x0: 2, y0: 40, x1: 128, y1: 82 } + : { x0: 2, y0: layout.valueY - 16, x1: 128, y1: layout.valueY + 6 }, + ]; + const range = e.max - e.min; + for (const v of ticks) { + const deg = angleAt(angles, (v - e.min) / range); + const [x, y] = polar(CX, CY, layout.labelR, deg); + const { anchor, dy } = labelPlacement(deg); + // About 0.6 em per character for these digits. + const width = formatNumber(v, tickDecimals(v, step), hass).length * LABEL_SIZE * 0.6; + const left = anchor === 'start' ? x : anchor === 'end' ? x - width : x - width / 2; + const base = y + dy * LABEL_SIZE; + boxes.push({ x0: left, y0: base - LABEL_SIZE, x1: left + width, y1: base + 2 }); + } + const box = union(...boxes); + return { + x0: Math.floor(box.x0 - 2), + y0: Math.floor(box.y0 - 2), + x1: Math.ceil(box.x1 + 2), + y1: Math.ceil(box.y1 + 2), + }; +} diff --git a/src/styles/polar-area.ts b/src/styles/polar-area.ts new file mode 100644 index 0000000..2917ef5 --- /dev/null +++ b/src/styles/polar-area.ts @@ -0,0 +1,244 @@ +import { css, html, nothing, svg } from 'lit'; +import type { SVGTemplateResult, TemplateResult } from 'lit'; +import { PALETTE, baseColor } from '../bands'; +import { seriesColor, shade } from '../chart/colors'; +import { axisAngle, labelAnchor, polar, sectorPath } from '../chart/polar'; +import { cachedSchema, chartForm, choice } from '../editor/chart-editors'; +import type { FieldBinding } from '../editor/schemas'; +import { formatNumber, withUnit } from '../format'; +import { localize, registerStrings } from '../localize'; +import type { ResolvedEntity } from '../resolve'; +import type { CardConfig } from '../types'; +import type { ChartContext, ChartEditorContext, ChartStyleModule } from './index'; + +registerStrings('en', { + editor_style_polar_area: 'Polar area', + editor_polar_scale: 'Scale', + editor_polar_scale_shared: 'One scale for all, from 0 to the largest value', + editor_polar_scale_limits: "Each slice from its entity's minimum to maximum", + editor_polar_color_by: 'Colour', + editor_polar_color_by_series: 'One colour per entity', + editor_polar_color_by_shades: 'Shades of one colour, darker for more', + editor_polar_color_by_bands: 'By colour band', + editor_polar_color: 'Colour for shades', + polar_area_aria: 'Polar area chart of {names}', +}); + +export const POLAR_SCALES = ['shared', 'limits'] as const; +export type PolarScale = (typeof POLAR_SCALES)[number]; +export const POLAR_COLOR_BY = ['series', 'shades', 'bands'] as const; +export type PolarColorBy = (typeof POLAR_COLOR_BY)[number]; + +const HALF_W = 170; +const HALF_H = 120; +const RADIUS = 92; +/** A small hole in the middle, so thin slices don't meet in a point. */ +const HOLE = 6; +/** Angle left empty either side of each slice (radians). */ +const GAP = 0.02; +const RINGS = 3; + +/** How far out a slice reaches, 0–1: its share of the largest value, or its place between its limits. */ +export function sliceLength(e: ResolvedEntity, scale: PolarScale, largest: number): number | null { + if (!e.available || e.value === null) return null; + if (scale === 'limits') return e.position; + return largest > 0 ? Math.min(1, Math.max(0, e.value / largest)) : 0; +} + +/** Slice `i` of `n`, centred on its spoke, with a thin gap either side; one slice is a whole disc. */ +export function sliceAngles(i: number, n: number): [number, number] { + if (n <= 1) return [0, Math.PI * 2]; + const half = Math.PI / n; + const mid = axisAngle(i, n); + return [mid - half + GAP, mid + half - GAP]; +} + +/** The slice's fill: its series colour, a shade of one colour by length, or its band colour. */ +export function sliceColor(e: ResolvedEntity, i: number, length: number, colorBy: PolarColorBy, base: string): string { + if (colorBy === 'bands') return baseColor(e.band.color); + if (colorBy === 'shades') return shade(baseColor(base), 30 + 70 * length); + return seriesColor(i); +} + +/** Dashed rings at even steps, and (separately, drawn over the slices) their values. */ +function rings(): SVGTemplateResult { + return svg`${Array.from({ length: RINGS }, (_, k) => svg``)}`; +} + +function ringLabels(ctx: ChartContext, scale: PolarScale, largest: number): SVGTemplateResult { + const first = ctx.items.find((e) => e.available); + // Values only make sense on a shared scale; with per-entity limits each slice has its own. + if (scale !== 'shared' || !first || largest <= 0) return svg``; + // Along the gap between the first two slices, where no slice covers it. + const angle = ctx.items.length > 1 ? Math.PI / ctx.items.length : 0; + return svg`${Array.from({ length: RINGS }, (_, k) => { + const r = (RADIUS * (k + 1)) / RINGS; + const [x, y] = polar(r, angle); + const text = withUnit(formatNumber((largest * (k + 1)) / RINGS, first.decimals, ctx.hass), first.unit); + return svg`${text}`; + })}`; +} + +function labels(ctx: ChartContext): SVGTemplateResult { + const n = ctx.items.length; + const { show } = ctx.config; + if (!show.name && !show.value) return svg``; + return svg`${ctx.items.map((e, i) => { + const angle = axisAngle(i, n); + const [x, y] = polar(RADIUS + 12, angle); + const below = Math.cos(angle) < -0.2; + const lines = show.name && show.value ? 2 : 1; + const dy = below ? 10 : Math.abs(Math.cos(angle)) <= 0.2 ? 4 - (lines - 1) * 6 : -(lines - 1) * 13; + return svg` + ${show.name ? svg`${e.name}` : nothing} + ${show.value ? svg`${e.display}` : nothing} + `; + })}`; +} + +const FIELDS: FieldBinding[] = [ + { name: 'polar_scale', path: ['scale'], default: 'shared' }, + { name: 'polar_color_by', path: ['color_by'], default: 'series' }, + { name: 'polar_color', path: ['color'], default: 'primary' }, +]; + +const PALETTE_NAMES = Object.keys(PALETTE); + +export function polarAreaEditor(ctx: ChartEditorContext): TemplateResult { + const schema = cachedSchema('polar_area', ctx.hass, () => [ + { name: 'polar_scale', selector: choice(ctx.hass, 'polar_scale', POLAR_SCALES) }, + { + type: 'grid', + name: '', + schema: [ + { name: 'polar_color_by', selector: choice(ctx.hass, 'polar_color_by', POLAR_COLOR_BY) }, + { + name: 'polar_color', + selector: { + select: { + mode: 'dropdown', + custom_value: true, + options: PALETTE_NAMES.map((value) => ({ value, label: localize(ctx.hass, `editor_color_${value}`) })), + }, + }, + }, + ], + }, + ]); + return chartForm(ctx, 'chart', schema, FIELDS); +} + +export const polarArea: ChartStyleModule = { + layout: 'chart', + group: 'compare', + height: 260, + styles: css` + .polar-plot { + display: block; + width: 100%; + height: 240px; + overflow: visible; + } + .polar-plot .ring { + fill: none; + stroke: var(--divider-color, rgba(127, 127, 127, 0.25)); + stroke-dasharray: 3 3; + } + .polar-plot .ring:last-of-type { + stroke-dasharray: none; + } + .polar-plot .ring-label { + fill: var(--secondary-text-color); + font-size: 10px; + paint-order: stroke; + stroke: var(--card-background-color, #fff); + stroke-width: 3px; + stroke-linejoin: round; + } + .polar-plot .slice-label { + fill: var(--secondary-text-color); + font-size: 12px; + } + .polar-plot .slice-value { + fill: var(--primary-text-color); + font-weight: 500; + } + .polar-plot .slice-label.muted .slice-value { + fill: var(--secondary-text-color); + font-weight: 400; + } + .polar-plot .slice { + cursor: pointer; + outline: none; + stroke: var(--card-background-color, #fff); + stroke-width: 0.5; + } + .polar-plot .slice:focus-visible { + stroke: var(--primary-text-color); + stroke-width: 2; + } + .polar-grow { + transform-box: fill-box; + transform-origin: center; + transform: scale(0); + transition: transform var(--cd-duration) var(--cd-easing); + } + :host([ready]) .polar-grow { + transform: scale(1); + } + :host([static]) .polar-grow { + transition: none; + } + `, + thumbnail: () => svg` + + + + + + + `, + validate(raw: CardConfig) { + if (raw.scale !== undefined && !POLAR_SCALES.includes(raw.scale as PolarScale)) { + throw new Error(`"scale" must be one of: ${POLAR_SCALES.join(', ')}`); + } + if (raw.color_by !== undefined && !POLAR_COLOR_BY.includes(raw.color_by as PolarColorBy)) { + throw new Error(`"color_by" must be one of: ${POLAR_COLOR_BY.join(', ')}`); + } + if (raw.color !== undefined && (typeof raw.color !== 'string' || !raw.color)) { + throw new Error('"color" must be a colour name, such as "primary", or any CSS colour'); + } + }, + editor: polarAreaEditor, + render(ctx) { + const scale: PolarScale = (ctx.raw.scale as PolarScale | undefined) ?? 'shared'; + const colorBy: PolarColorBy = (ctx.raw.color_by as PolarColorBy | undefined) ?? 'series'; + const base = (ctx.raw.color as string | undefined) ?? 'primary'; + const n = ctx.items.length; + const values = ctx.items.filter((e) => e.available && e.value !== null).map((e) => e.value as number); + const largest = values.length ? Math.max(0, ...values) : 0; + const slices = ctx.items.map((e, i) => { + const length = sliceLength(e, scale, largest); + if (length === null || length <= 0) return nothing; + const [a0, a1] = sliceAngles(i, n); + const r = HOLE + (RADIUS - HOLE) * length; + return svg``; + }); + const label = localize(ctx.hass, 'polar_area_aria', { names: ctx.items.map((e) => e.name).join(', ') }); + return html` + ${rings()} + + + ${slices} + + ${ringLabels(ctx, scale, largest)} ${labels(ctx)} + `; + }, +}; diff --git a/src/styles/radar.ts b/src/styles/radar.ts new file mode 100644 index 0000000..9d51d64 --- /dev/null +++ b/src/styles/radar.ts @@ -0,0 +1,333 @@ +import { css, html, nothing, svg } from 'lit'; +import type { SVGTemplateResult, TemplateResult } from 'lit'; +import { seriesColor } from '../chart/colors'; +import { groupPath, parseGroupBy, splitGroup, validateGroups } from '../chart/hierarchy'; +import { axisAngle, labelAnchor, polar, polygonPoints } from '../chart/polar'; +import { cachedSchema, chartForm, choice } from '../editor/chart-editors'; +import { entityOptionsForm } from '../editor/entity-options'; +import { GROUP_FIELDS, HIERARCHY_LABELS, entityGroupsForm, groupBySchema, withGroupList } from '../editor/group-fields'; +import type { FieldBinding } from '../editor/schemas'; +import { localize, registerStrings } from '../localize'; +import type { ResolvedEntity } from '../resolve'; +import type { CardConfig, HomeAssistant } from '../types'; +import type { ChartContext, ChartEditorContext, ChartStyleModule } from './index'; + +registerStrings('en', { + editor_style_radar: 'Radar', + editor_radar_scale: 'Scale', + editor_radar_scale_limits: "Each axis from its entity's minimum to maximum", + editor_radar_scale_shared: 'One scale for all, from 0 to the largest value', + editor_radar_fill: 'Fill the shapes', + editor_radar_axis: 'Axis name (entities with the same axis name line up across groups)', + radar_aria: 'Radar chart of {names}', + radar_other: 'Other', +}); + +export const RADAR_SCALES = ['limits', 'shared'] as const; +export type RadarScale = (typeof RADAR_SCALES)[number]; + +/** Half the drawing's width and height, and the radius of the outer ring, in SVG units (≈ px). */ +const HALF_W = 170; +const HALF_H = 120; +const RADIUS = 84; +const RINGS = 4; + +/** Short names for device classes whose readable form is an abbreviation. */ +const DEVICE_CLASS_NAMES: Record = { + aqi: 'AQI', + carbon_dioxide: 'CO₂', + carbon_monoxide: 'CO', + pm1: 'PM1', + pm10: 'PM10', + pm25: 'PM2.5', + volatile_organic_compounds: 'VOC', + volatile_organic_compounds_parts: 'VOC', +}; + +/** "carbon_dioxide" → "CO₂", "humidity" → "Humidity". */ +export function deviceClassName(deviceClass: string): string { + if (DEVICE_CLASS_NAMES[deviceClass]) return DEVICE_CLASS_NAMES[deviceClass]; + const text = deviceClass.replace(/_/g, ' '); + return text.charAt(0).toUpperCase() + text.slice(1); +} + +export interface RadarPoint { + /** Index into the chart's items. */ + index: number; + axis: number; + /** Distance from the centre, 0–1; null when there is no value to plot. */ + r: number | null; +} + +export interface RadarSeries { + name: string; + color: string; + points: RadarPoint[]; +} + +export interface RadarModel { + axes: { name: string; entity?: ResolvedEntity }[]; + series: RadarSeries[]; +} + +/** How far out a value sits: its place between the entity's limits, or its share of the largest. */ +export function radius(e: ResolvedEntity, scale: RadarScale, largest: number): number | null { + if (!e.available || e.value === null) return null; + if (scale === 'limits') return e.position; + return largest > 0 ? Math.min(1, Math.max(0, e.value / largest)) : 0; +} + +/** + * Sort the entities into axes and series. Without groups there is one series and every entity is + * an axis. With groups, each group is a series and entities line up on an axis by their `axis` + * name, else their device class, else their own name. + */ +export function radarModel(items: ResolvedEntity[], raw: CardConfig, hass: HomeAssistant): RadarModel { + const levels = parseGroupBy(raw.group_by); + const grouped = levels.length > 0 || items.some((e) => splitGroup((e.config as { group?: unknown }).group).length); + const scale: RadarScale = (raw.scale as RadarScale | undefined) ?? 'limits'; + const values = items.filter((e) => e.available && e.value !== null).map((e) => e.value as number); + const largest = values.length ? Math.max(...values) : 0; + const ownAxis = (e: ResolvedEntity) => { + const axis = (e.config as { axis?: unknown }).axis; + return typeof axis === 'string' && axis.trim() ? axis.trim() : undefined; + }; + if (!grouped) { + return { + axes: items.map((e) => ({ name: ownAxis(e) ?? e.name, entity: e })), + series: [ + { + name: '', + color: seriesColor(0), + points: items.map((e, index) => ({ index, axis: index, r: radius(e, scale, largest) })), + }, + ], + }; + } + const other = localize(hass, 'radar_other'); + const axes: { name: string }[] = []; + const series: RadarSeries[] = []; + items.forEach((e, index) => { + const deviceClass = e.stateObj?.attributes?.device_class; + const axisName = ownAxis(e) ?? (typeof deviceClass === 'string' ? deviceClassName(deviceClass) : e.name); + let axis = axes.findIndex((a) => a.name === axisName); + if (axis === -1) axis = axes.push({ name: axisName }) - 1; + const name = groupPath(e, levels, hass, other).join(' / ') || other; + let s = series.find((x) => x.name === name); + if (!s) { + s = { name, color: seriesColor(series.length), points: [] }; + series.push(s); + } + s.points.push({ index, axis, r: radius(e, scale, largest) }); + }); + return { axes, series }; +} + +function grid(n: number): SVGTemplateResult { + const rings = Array.from({ length: RINGS }, (_, i) => (RADIUS * (i + 1)) / RINGS); + const ring = (r: number) => + n >= 3 + ? svg` polar(r, axisAngle(i, n))))}>` + : svg``; + return svg`${rings.map(ring)} + ${Array.from({ length: n }, (_, i) => { + const [x, y] = polar(RADIUS, axisAngle(i, n)); + return svg``; + })}`; +} + +function axisLabels(model: RadarModel, ctx: ChartContext, single: boolean): SVGTemplateResult { + const n = model.axes.length; + const showValue = single && ctx.config.show.value; + return svg`${model.axes.map((axis, i) => { + const angle = axisAngle(i, n); + const [x, y] = polar(RADIUS + 12, angle); + const anchor = labelAnchor(angle); + // Labels below the centre hang down from their point; above it they sit on it. + const below = Math.cos(angle) < -0.2; + const lines = showValue && axis.entity ? 2 : 1; + const dy = below ? 10 : Math.abs(Math.cos(angle)) <= 0.2 ? 4 - (lines - 1) * 6 : -(lines - 1) * 12; + return svg` + ${ctx.config.show.name || !showValue ? svg`${axis.name}` : nothing} + ${ + showValue && axis.entity + ? svg`${axis.entity.display}` + : nothing + } + `; + })}`; +} + +function shape(s: RadarSeries, n: number, items: ResolvedEntity[], fill: boolean): SVGTemplateResult { + const plotted = s.points + .filter((p) => p.r !== null) + .sort((a, b) => a.axis - b.axis) + .map((p) => ({ p, xy: polar(RADIUS * (p.r as number), axisAngle(p.axis, n)) })); + return svg` + ${ + plotted.length > 1 + ? svg` x.xy))} + stroke=${s.color} fill=${fill ? s.color : 'none'}>` + : nothing + } + ${plotted.map( + ({ p, xy }) => svg` + + + `, + )} + `; +} + +const FIELDS: FieldBinding[] = [ + { name: 'radar_scale', path: ['scale'], default: 'limits' }, + { name: 'radar_fill', path: ['fill'], default: true }, + ...GROUP_FIELDS, +]; + +export function radarEditor(ctx: ChartEditorContext): TemplateResult { + const schema = cachedSchema('radar', ctx.hass, () => [ + { name: 'radar_scale', selector: choice(ctx.hass, 'radar_scale', RADAR_SCALES) }, + { name: 'radar_fill', selector: { boolean: {} } }, + groupBySchema(ctx.hass), + ]); + return html`${chartForm(withGroupList(ctx), 'chart', schema, FIELDS, HIERARCHY_LABELS)} ${entityGroupsForm(ctx)} + ${entityOptionsForm(ctx, 'radar_axis', 'axis', { text: {} })}`; +} + +export const radar: ChartStyleModule = { + layout: 'chart', + group: 'compare', + height: 280, + styles: css` + .radar-plot { + display: block; + width: 100%; + height: 240px; + overflow: visible; + } + .radar-plot .grid { + fill: none; + stroke: var(--divider-color, rgba(127, 127, 127, 0.25)); + stroke-width: 1; + } + .radar-plot .axis-label { + fill: var(--secondary-text-color); + font-size: 12px; + } + .radar-plot .axis-value { + fill: var(--primary-text-color); + font-weight: 500; + } + .radar-plot .axis-value.muted { + fill: var(--secondary-text-color); + font-weight: 400; + } + .radar-plot .area { + stroke-width: 2; + stroke-linejoin: round; + fill-opacity: 0; + } + .radar-plot .area.filled { + fill-opacity: 0.2; + } + .radar-plot .dot { + fill: var(--card-background-color, #fff); + stroke-width: 2; + } + .radar-plot .hit { + fill: transparent; + } + .radar-plot .marker { + cursor: pointer; + outline: none; + } + .radar-plot .marker:focus-visible .dot { + stroke-width: 4; + } + .radar-grow { + transform-box: fill-box; + transform-origin: center; + transform: scale(0); + transition: transform var(--cd-duration) var(--cd-easing); + } + :host([ready]) .radar-grow { + transform: scale(1); + } + :host([static]) .radar-grow { + transition: none; + } + .radar-legend { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 4px 16px; + padding-top: 4px; + font-size: 0.875em; + color: var(--secondary-text-color); + } + .radar-legend-item { + display: flex; + align-items: center; + gap: 6px; + } + .radar-legend .swatch { + width: 10px; + height: 10px; + border-radius: 50%; + } + `, + thumbnail: () => svg` + + `, + validate(raw: CardConfig) { + validateGroups(raw); + if (raw.scale !== undefined && !RADAR_SCALES.includes(raw.scale as RadarScale)) { + throw new Error(`"scale" must be one of: ${RADAR_SCALES.join(', ')}`); + } + if (raw.fill !== undefined && typeof raw.fill !== 'boolean') throw new Error('"fill" must be true or false'); + (raw.entities ?? []).forEach((e, i) => { + const axis = typeof e === 'object' && e ? (e as { axis?: unknown }).axis : undefined; + if (axis !== undefined && axis !== null && typeof axis !== 'string') { + throw new Error(`Entity ${i + 1}: "axis" must be text`); + } + }); + }, + editor: radarEditor, + render(ctx) { + const model = radarModel(ctx.items, ctx.raw, ctx.hass); + const n = model.axes.length; + const single = model.series.length === 1 && model.series[0].name === ''; + const fill = ctx.raw.fill !== false; + const label = localize(ctx.hass, 'radar_aria', { + names: (single ? ctx.items.map((e) => e.name) : model.series.map((s) => s.name)).join(', '), + }); + return html` + ${grid(n)} ${axisLabels(model, ctx, single)} + + + ${model.series.map((s) => shape(s, n, ctx.items, fill))} + + + ${ + single + ? nothing + : html`
+ ${model.series.map( + (s) => + html`${s.name}`, + )} +
` + }`; + }, +}; diff --git a/src/styles/radial.ts b/src/styles/radial.ts index 4859aed..5c3354e 100644 --- a/src/styles/radial.ts +++ b/src/styles/radial.ts @@ -1,5 +1,6 @@ import { css, html, nothing, svg } from 'lit'; -import { solidColor } from '../bands'; +import { baseColor, solidColor } from '../bands'; +import { centerContent, customAngles, gaugeAngles, angleAt, arcBox, union } from '../gauge'; import { arcPath, polar, sweepAngles } from '../geometry'; import { iconOverride } from '../resolve'; import { fitStatusText } from '../status-text'; @@ -51,14 +52,14 @@ function statusText(text: string) { /** Ring (360) and three-quarter arc (270) gauges share one drawing. */ export function radialStyle(sweep: 360 | 270): ValueStyleModule { - const [a0, a1] = sweepAngles(sweep, [0, 1]); - const trackD = arcPath(CX, CY, R, a0, a1); + const defaults = sweepAngles(sweep, [0, 1]); + const defaultTrack = arcPath(CX, CY, R, defaults[0], defaults[1]); // The arc's open bottom is empty: crop the drawing just below its round end caps (~98). - const cropped = sweep < 360; + const defaultCropped = sweep < 360; return { layout: 'tiles', group: 'gauges', - height: cropped ? 135 : 155, + height: defaultCropped ? 135 : 155, minColumnWidth: 128, styles: css` /* A definite width: the dial is a size container and can't size the tile itself. */ @@ -129,6 +130,10 @@ export function radialStyle(sweep: 360 | 270): ValueStyleModule { font-weight: 500; fill: var(--primary-text-color); } + .radial .center-text { + font-weight: 500; + fill: var(--primary-text-color); + } .radial .unit { font-size: 11px; fill: var(--secondary-text-color); @@ -152,6 +157,13 @@ export function radialStyle(sweep: 360 | 270): ValueStyleModule { transform: translate(-50%, -50%); --mdc-icon-size: 22.58cqw; } + /* A custom sweep crops --y0 drawing units off the top: move the icon up by as much. */ + .radial .dial.fitted .icon { + top: calc(25cqw - var(--y0) * 0.8065cqw); + } + .radial .dial.fitted .icon.centre { + top: calc(48.39cqw - var(--y0) * 0.8065cqw); + } .radial .name { max-width: min(124px, 100%); font-size: 12px; @@ -163,6 +175,20 @@ export function radialStyle(sweep: 360 | 270): ValueStyleModule { `, render(e, { show, hass }) { const id = gradientId(e.config); + const options = e.config.gauge; + const angles = gaugeAngles(defaults, options); + const custom = customAngles(options); + const trackD = custom ? arcPath(CX, CY, R, angles[0], angles[1]) : defaultTrack; + // A custom sweep crops the drawing to the arc and the centre disc, full width kept. + let y0 = 0; + let h = defaultCropped ? 100 : 120; + if (custom) { + const box = union(arcBox(CX, CY, R, angles[0], angles[1], STROKE / 2 + 1), { x0: 26, y0: 26, x1: 94, y1: 94 }); + y0 = Math.max(0, Math.floor(box.y0)); + h = Math.min(120, Math.ceil(box.y1)) - y0; + } + const cropped = h < 120; + const center = e.available && show.value ? centerContent(e, options) : { kind: 'none' as const }; const empty = e.span[1] - e.span[0] <= 0; // Plain numbers (pathLength 100), not calc() on custom properties, so every engine draws them. const dash = `stroke-dasharray: ${num((e.span[1] - e.span[0]) * 100)} 100; stroke-dashoffset: ${num(-e.span[0] * 100)}`; @@ -175,14 +201,14 @@ export function radialStyle(sweep: 360 | 270): ValueStyleModule { const size = valueFontSize(e.valueText); let tick = nothing as unknown; if (show.target && e.targetPosition !== null && e.available) { - const deg = sweepAngles(sweep, [e.targetPosition, e.targetPosition])[0]; + const deg = angleAt(angles, e.targetPosition); const [x1, y1] = polar(CX, CY, R - STROKE / 2 - 1, deg); const [x2, y2] = polar(CX, CY, R + STROKE / 2 + 1, deg); tick = svg``; } return html`
-
-
+ ${e.name}` : nothing} + ${show.name && options?.center !== 'name' ? html`${e.name}` : nothing}
`; }, }; diff --git a/src/styles/range-area.ts b/src/styles/range-area.ts new file mode 100644 index 0000000..3543470 --- /dev/null +++ b/src/styles/range-area.ts @@ -0,0 +1,268 @@ +import { css, html, nothing, svg } from 'lit'; +import type { TemplateResult } from 'lit'; +import { validateAxisOptions } from '../chart/axes'; +import { + disconnectHistory, + frameMessage, + frameStyles, + historyFrame, + periodNav, + seriesHeader, + validateHistoryOptions, +} from '../chart/frame'; +import type { ChartSeries } from '../chart/frame'; +import { chartArea, gridStyles, plotAxes, plotWidth, stopChartWidth } from '../chart/grid'; +import type { PlotScales } from '../chart/grid'; +import { chartFrame, chartLayout, headerOver, layoutStyles, validateLayout } from '../chart/layout'; +import { CURVES, runPath } from '../chart/paths'; +import type { Curve, XY } from '../chart/paths'; +import { extent } from '../chart/scale'; +import { DEFAULT_LINE_WIDTH, validateSeriesStyle } from '../chart/series-style'; +import { + hoverHandlers, + hoverTime, + stopHover, + tooltipEnabled, + tooltipLayer, + tooltipStyles, + validateTooltipOptions, +} from '../chart/tooltip'; +import type { TooltipOptions } from '../chart/tooltip'; +import { + AXES_FIELDS, + HISTORY_FIELDS, + LAYOUT_FIELDS, + axesSchema, + cachedSchema, + chartForm, + choice, + historySchema, + historyVariant, + layoutSchema, +} from '../editor/chart-editors'; +import { LINE_WIDTH_SELECTOR } from '../editor/schemas'; +import type { FieldBinding } from '../editor/schemas'; +import { formatNumber, withUnit } from '../format'; +import type { Point } from '../history/types'; +import { localize, registerStrings } from '../localize'; +import type { CardConfig } from '../types'; +import type { ChartContext, ChartEditorContext, ChartStyleModule } from './index'; + +registerStrings('en', { + editor_style_range_area: 'Range area', + editor_mean: 'Draw the average as a line', + editor_range_color: 'Colour', +}); + +/** + * Where a range area reads from when the card doesn't say: long-term statistics, which keep each + * period's lowest, average and highest. History has no spread until it is grouped, so a history + * range area with no grouping is grouped by hour (two days or less) or by day. + */ +export function rangeRaw(raw: CardConfig): CardConfig { + const out: Record = { ...raw, source: raw.source ?? 'statistics' }; + if (out.source === 'history' && raw.group_by === undefined) { + const period = (raw.period ?? {}) as { last?: number; unit?: string; calendar?: string }; + const hours = (period.last ?? 24) * ({ hour: 1, day: 24, week: 168, month: 720 }[period.unit ?? 'hour'] ?? 1); + out.group_by = period.calendar === 'day' || (!period.calendar && hours <= 48) ? 'hour' : 'day'; + } + return out as CardConfig; +} + +/** Corners of a stepped line, so the band's lower edge can be walked back along the same steps. */ +function corners(pts: XY[]): XY[] { + return pts.flatMap((p, i) => (i > 0 ? [[p[0], pts[i - 1][1]] as XY, p] : [p])); +} + +/** The closed band between the highs and lows of one unbroken run. */ +export function bandPath(highs: XY[], lows: XY[], curve: Curve): string { + if (highs.length < 2) return ''; + const [h, l, c] = curve === 'step' ? [corners(highs), corners(lows), 'straight' as Curve] : [highs, lows, curve]; + return `${runPath(h, c)}${runPath([...l].reverse(), c).replace(/^M/, 'L')}Z`; +} + +/** Unbroken runs of points that have a value; each point's low and high default to its value. */ +export function bandRuns(points: Point[]): { t: number; v: number; lo: number; hi: number }[][] { + const out: { t: number; v: number; lo: number; hi: number }[][] = []; + let run: { t: number; v: number; lo: number; hi: number }[] = []; + for (const p of points) { + if (p.v === null) { + if (run.length) out.push(run); + run = []; + continue; + } + run.push({ t: p.t, v: p.v, lo: p.min ?? p.v, hi: p.max ?? p.v }); + } + if (run.length) out.push(run); + return out; +} + +function drawSeries(s: ChartSeries, scales: PlotScales, mean: boolean, width: number) { + const bands: string[] = []; + const edges: string[] = []; + const lines: string[] = []; + for (const run of bandRuns(s.points)) { + const highs = run.map((p) => [scales.x(p.t), scales.y(p.hi)] as XY); + const lows = run.map((p) => [scales.x(p.t), scales.y(p.lo)] as XY); + bands.push(bandPath(highs, lows, s.curve)); + edges.push(runPath(highs, s.curve), runPath(lows, s.curve)); + lines.push( + runPath( + run.map((p) => [scales.x(p.t), scales.y(p.v)] as XY), + s.curve, + ), + ); + } + return svg` + + + ${ + mean + ? svg`` + : nothing + } + `; +} + +function render(ctx0: ChartContext): TemplateResult { + const ctx = { ...ctx0, raw: rangeRaw(ctx0.raw) }; + const frame = historyFrame(ctx, Date.now(), { range: true }); + const nav = periodNav(frame, ctx); + const names = seriesHeader(frame, ctx); + const header = nav === nothing && names === nothing ? nothing : html`${nav}${names}`; + const message = frameMessage(frame, ctx); + const layout = chartLayout(ctx.raw); + if (message) return chartFrame(layout.header === 'over' ? { ...layout, header: 'above' } : layout, header, message); + const series = frame.series; + const axes = plotAxes(ctx, frame.range, extent(series.map((s) => s.points))); + const mean = ctx.raw.mean !== false; + const width = (ctx.raw.line_width as number | undefined) ?? DEFAULT_LINE_WIDTH; + const tip: TooltipOptions = { + text: (s, p) => { + if (!p || p.v === null) return '—'; + const f = (v: number) => formatNumber(v, s.entity.decimals, ctx.hass); + const lo = p.min ?? p.v; + const hi = p.max ?? p.v; + return withUnit(`${f(lo)} – ${f(hi)}`, s.entity.unit) + (mean ? ` · ${f(p.v)}` : ''); + }, + }; + if (!mean) tip.dotY = () => null; + const tooltip = tooltipEnabled(ctx.raw); + const label = localize(ctx.hass, 'chart_aria', { + style: localize(ctx.hass, 'editor_style_range_area'), + names: frame.series.map((s) => s.entity.name).join(', '), + }); + return chartFrame( + layout, + header, + chartArea({ + axes, + ctx, + content: svg`${series.map((s) => drawSeries(s, axes.scales, mean, width))}`, + label, + overlay: html`${layout.header === 'over' ? headerOver(header) : nothing} + ${tooltip ? tooltipLayer(ctx, series, axes.scales, hoverTime(ctx.host), plotWidth(ctx), tip) : nothing}`, + hover: tooltip + ? hoverHandlers( + ctx, + frame.range, + series.map((s) => s.points), + ) + : undefined, + }), + ); +} + +const RANGE_FIELDS: FieldBinding[] = [ + ...HISTORY_FIELDS.map((f) => (f.name === 'source' ? { ...f, default: 'statistics' } : f)), + { name: 'curve', path: ['curve'], default: 'smooth' }, + { name: 'mean', path: ['mean'], default: true }, + { name: 'line_width', path: ['line_width'], default: DEFAULT_LINE_WIDTH }, + ...AXES_FIELDS, + ...LAYOUT_FIELDS, +]; + +export function rangeAreaEditor(ctx: ChartEditorContext): TemplateResult { + const schema = cachedSchema(`range_area|${historyVariant(ctx.config)}`, ctx.hass, () => [ + ...historySchema(ctx.hass, ctx.config), + { name: 'curve', selector: choice(ctx.hass, 'curve', CURVES) }, + { + type: 'grid', + name: '', + schema: [ + { name: 'mean', selector: { boolean: {} } }, + { name: 'line_width', selector: LINE_WIDTH_SELECTOR }, + ], + }, + ...axesSchema(ctx.hass), + ...layoutSchema(ctx.hass), + ]); + return chartForm(ctx, 'chart', schema, RANGE_FIELDS); +} + +const ENTITY_FIELDS: FieldBinding[] = [ + { name: 'range_color', path: ['color'] }, + { name: 'curve', path: ['curve'] }, + { name: 'dash', path: ['dash'], default: false }, +]; + +export const rangeArea: ChartStyleModule = { + layout: 'chart', + group: 'history', + height: 240, + styles: css` + ${frameStyles} + ${gridStyles} + ${tooltipStyles} + ${layoutStyles} + .plot .band { + fill-opacity: 0.22; + } + .plot .band-edge { + stroke-width: 1px; + stroke-opacity: 0.45; + } + .plot .series .line { + stroke-linecap: round; + stroke-linejoin: round; + } + .plot .series .line.dashed { + stroke-dasharray: 6 5; + } + `, + thumbnail: () => svg` + + `, + validate(raw: CardConfig) { + validateHistoryOptions(raw); + validateAxisOptions(raw); + validateTooltipOptions(raw); + validateSeriesStyle(raw); + validateLayout(raw); + if (raw.mean !== undefined && typeof raw.mean !== 'boolean') throw new Error('"mean" must be true or false'); + }, + editor: rangeAreaEditor, + entityFields: (hass) => ({ + schema: cachedSchema('range-area-entity', hass, () => [ + { + type: 'grid', + name: '', + schema: [ + { name: 'range_color', selector: { text: {} } }, + { name: 'curve', selector: choice(hass, 'curve', CURVES) }, + { name: 'dash', selector: { boolean: {} } }, + ], + }, + ]), + fields: ENTITY_FIELDS, + }), + disconnect(host) { + disconnectHistory(host); + stopChartWidth(host); + stopHover(host); + }, + render, +}; diff --git a/src/styles/rings.ts b/src/styles/rings.ts index 611bd3f..3c956c3 100644 --- a/src/styles/rings.ts +++ b/src/styles/rings.ts @@ -23,7 +23,7 @@ import type { CardConfig } from '../types'; registerStrings('en', { editor_style_rings: 'Rings', - editor_start_angle: 'Start angle (0 is the top)', + editor_rings_start_angle: 'Start angle (0 is the top)', editor_end_angle: 'End angle', editor_rounded_ends: 'Rounded ends', }); @@ -140,7 +140,7 @@ export function ringsEditor(ctx: ChartEditorContext) { }, { name: 'rounded_ends', selector: { boolean: {} } }, ]); - return chartForm(ctx, 'chart', schema, FIELDS); + return chartForm(ctx, 'chart', schema, FIELDS, { start_angle: 'editor_rings_start_angle' }); } export const rings: ChartStyleModule = { diff --git a/src/styles/sankey.ts b/src/styles/sankey.ts new file mode 100644 index 0000000..9d8ac3b --- /dev/null +++ b/src/styles/sankey.ts @@ -0,0 +1,447 @@ +import { css, html, nothing, svg } from 'lit'; +import type { SVGTemplateResult, TemplateResult } from 'lit'; +import { isInteractive } from '../actions'; +import { baseColor } from '../bands'; +import { SERIES_COLORS, frameStyles } from '../chart/frame'; +import { chartWidth, stopChartWidth } from '../chart/grid'; +import { acceptFlows, findCycle, fitText, linkPath, sankeyLayout, spreadLabels, textWidth } from '../chart/sankey'; +import type { Flow, SankeyLayout, SankeyLink, SankeyNode } from '../chart/sankey'; +import { tooltipStyles } from '../chart/tooltip'; +import { cachedSchema, chartForm, choice } from '../editor/chart-editors'; +import type { FieldBinding } from '../editor/schemas'; +import { formatNumber, withUnit } from '../format'; +import { localize, registerStrings } from '../localize'; +import type { ChartContext, ChartEditorContext, ChartStyleModule } from './index'; +import type { CardConfig, EntityConfig, HomeAssistant } from '../types'; + +registerStrings('en', { + editor_style_sankey: 'Sankey (flow)', + editor_link_color: 'Colour flows by', + editor_link_color_auto: 'Where they come from, or where they go after a middle node', + editor_link_color_source: 'Where they come from', + editor_link_color_target: 'Where they go', + editor_from: 'Flows from', + editor_to: 'Flows to', + editor_flow_color: 'Flow colour', + sankey_aria: 'Flow chart: {flows}', + sankey_flow: '{from} to {to} {value}', + sankey_no_flows: 'Give each entity a "Flows from" and a "Flows to" in its options.', + sankey_nothing: 'Nothing is flowing right now.', +}); + +export const LINK_COLORS = ['auto', 'source', 'target'] as const; +export type LinkColor = (typeof LINK_COLORS)[number]; + +const NODE_W = 10; +const GAP = 12; +/** Room above a middle node for its one-line label. */ +const ABOVE = 20; +/** Space between a node and its label. */ +const PAD = 6; +const MIN_H = 200; +/** Height per node in the fullest column, so two-line labels have room. */ +const SLOT_H = 44; + +type FlowEntity = EntityConfig & { from?: unknown; to?: unknown }; + +const nodeName = (v: unknown) => (typeof v === 'string' ? v.trim() : ''); + +/** The card's entities as flows; ones without both ends are left out. */ +export function flowsOf(ctx: Pick): Flow[] { + return ctx.items.flatMap((e, index) => { + const own = e.config as FlowEntity; + const from = nodeName(own.from); + const to = nodeName(own.to); + return from && to ? [{ index, from, to, value: e.available ? e.value : null }] : []; + }); +} + +/** + * Each node's colour and each link's. Nodes take the series colours in order of first + * appearance. A link is coloured by its source, or (`auto`) by its target when the source is a + * middle node, as "House → Heat pump" takes the heat pump's colour. A flow's own `color` paints + * its link and the node it is coloured by (the first such flow wins for a node). + */ +export function sankeyColors( + flows: Pick[], + mode: LinkColor, + own: (index: number) => string | undefined, +): { node: Map; link: Map } { + const accepted = acceptFlows(flows); + const receives = new Set(accepted.map((f) => f.to)); + const node = new Map(); + for (const f of accepted) { + for (const name of [f.from, f.to]) if (!node.has(name)) node.set(name, ''); + } + const by = (f: Pick) => + mode === 'target' || (mode === 'auto' && receives.has(f.from)) ? f.to : f.from; + const set = new Set(); + for (const f of accepted) { + const color = own(f.index); + if (color && !set.has(by(f))) { + node.set(by(f), baseColor(color)); + set.add(by(f)); + } + } + [...node.keys()].forEach((name, i) => { + if (!set.has(name)) node.set(name, baseColor(SERIES_COLORS[i % SERIES_COLORS.length])); + }); + const link = new Map(); + for (const f of accepted) { + const color = own(f.index); + link.set(f.index, color ? baseColor(color) : node.get(by(f))!); + } + return { node, link }; +} + +function linkColor(raw: CardConfig): LinkColor { + return (raw.link_color as LinkColor | undefined) ?? 'auto'; +} + +/** Which link the pointer or focus is on, per card. */ +const hovered = new WeakMap(); + +interface Label { + node: SankeyNode; + side: 'left' | 'right' | 'above'; + name: string; + value: string; + /** Centre line for side labels; baseline for labels above. */ + y: number; +} + +const isMiddle = (n: SankeyNode, columns: number) => n.column > 0 && n.column < columns - 1; +const sits = (n: SankeyNode, columns: number): Label['side'] => { + if (n.column === 0) return 'left'; + if (isMiddle(n, columns) && n.in > 0 && n.out > 0) return 'above'; + return 'right'; +}; + +interface Drawing { + width: number; + height: number; + font: number; + layout: SankeyLayout; + labels: Label[]; +} + +/** + * Size the drawing to the card: room either side for the outer columns' labels (at most 30 % of + * the width each; longer names are cut short), a node height scale set by the fullest column, + * and labels moved apart where small nodes sit close together. + */ +export function drawSankey( + flows: Flow[], + width: number, + text: { name: boolean; value: boolean; format(v: number): string }, +): Drawing { + const font = width < 400 ? 11 : 12; + const line = font + 3; + // A first pass finds the columns and how many nodes the fullest one holds. + const probe = sankeyLayout(flows, { width, height: MIN_H, left: 0, right: 0, nodeWidth: NODE_W, gap: GAP }); + const columns = probe.columns; + const counts = new Map(); + for (const n of probe.nodes) counts.set(n.column, (counts.get(n.column) ?? 0) + 1); + const height = Math.max(MIN_H, Math.max(0, ...counts.values()) * SLOT_H); + const max = width * 0.3; + const words = (n: SankeyNode) => ({ name: text.name ? n.name : '', value: text.value ? text.format(n.value) : '' }); + const gutter = (side: 'left' | 'right') => { + const wanted = probe.nodes + .filter((n) => sits(n, columns) === side && !isMiddle(n, columns)) + .map((n) => Math.max(textWidth(words(n).name, font), textWidth(words(n).value, font))); + return wanted.length && Math.max(...wanted) > 0 ? Math.min(max, Math.max(...wanted)) + PAD + 3 : 2; + }; + const left = gutter('left'); + const right = gutter('right'); + const anyAbove = probe.nodes.some((n) => sits(n, columns) === 'above'); + const top = anyAbove && (text.name || text.value) ? ABOVE : 0; + const layout = sankeyLayout(flows, { + width, + height: height - top, + left, + right, + nodeWidth: NODE_W, + gap: GAP, + above: (n, cols) => (sits(n, cols) === 'above' && top ? ABOVE : 0), + }); + for (const n of layout.nodes) n.y += top; + for (const l of layout.links) { + l.y0 += top; + l.y1 += top; + } + const spacing = columns > 1 ? (width - left - right - NODE_W) / (columns - 1) : width; + const labels: Label[] = []; + if (text.name || text.value) { + for (const n of layout.nodes) { + const side = sits(n, columns); + const w = words(n); + if (side === 'above') { + // Centred over the node: it may reach most of the way to the next column on each side. + const centre = n.x + NODE_W / 2; + const room = Math.min(spacing * 1.6, 2 * centre, 2 * (width - centre)); + const both = textWidth(`${w.name} ${w.value}`, font); + const value = both <= room || !w.name ? w.value : ''; + const name = fitText(w.name, value ? room - textWidth(` ${value}`, font) : room, font); + labels.push({ node: n, side, name, value, y: n.y - PAD }); + continue; + } + const room = isMiddle(n, columns) ? spacing - NODE_W - PAD * 2 : (side === 'left' ? left : right) - PAD - 2; + const value = textWidth(w.value, font) <= room ? w.value : ''; + labels.push({ node: n, side, name: fitText(w.name, room, font), value, y: n.y + n.h / 2 }); + } + // Side labels in one column must not sit on each other. + const size = text.name && text.value ? line * 2 : line; + for (let c = 0; c < columns; c++) { + const col = labels.filter((l) => l.side !== 'above' && l.node.column === c); + spreadLabels( + col.map((l) => l.y), + size, + height, + ).forEach((y, i) => (col[i].y = y)); + } + } + return { width, height, font, layout, labels }; +} + +function labelText(l: Label, font: number): SVGTemplateResult { + const n = l.node; + if (l.side === 'above') { + return svg` + ${l.name}${l.value ? svg`${l.name ? ' ' : ''}${l.value}` : nothing} + `; + } + const x = l.side === 'left' ? n.x - PAD : n.x + NODE_W + PAD; + const anchor = l.side === 'left' ? 'end' : 'start'; + const two = l.name && l.value; + const line = font + 3; + return svg` + ${l.name ? svg`${l.name}` : nothing} + ${l.value ? svg`${l.value}` : nothing} + `; +} + +function tooltip(l: SankeyLink | undefined, d: Drawing, color: string, value: string): TemplateResult | typeof nothing { + if (!l) return nothing; + const x = (l.source.x + NODE_W + l.target.x) / 2; + const y = (l.y0 + l.y1) / 2 - l.width / 2; + const left = Math.min(Math.max(x, 70), d.width - 70); + return html``; +} + +function message(key: string, hass: HomeAssistant) { + return html`
${localize(hass, key)}
`; +} + +function render(ctx: ChartContext): TemplateResult { + const flows = flowsOf(ctx); + if (!flows.length) return message('sankey_no_flows', ctx.hass); + // Unit and decimals come from the first flow, like a total's. + const first = ctx.items[flows[0].index]; + const format = (v: number) => withUnit(formatNumber(v, first.decimals, ctx.hass), first.unit); + const width = Math.max(200, chartWidth(ctx) - 32); + const { show } = ctx.config; + const d = drawSankey(flows, width, { name: show.name, value: show.value, format }); + const { layout } = d; + if (!layout.links.length) return message('sankey_nothing', ctx.hass); + const colors = sankeyColors(flows, linkColor(ctx.raw), (i) => { + const c = (ctx.items[i].config as FlowEntity).color; + return typeof c === 'string' && c ? c : undefined; + }); + const active = hovered.get(ctx.host); + const hover = (index: number | undefined) => () => { + if (index === undefined) hovered.delete(ctx.host); + else hovered.set(ctx.host, index); + ctx.requestUpdate(); + }; + const dark = ctx.hass.themes?.darkMode === true; + const describe = (l: SankeyLink) => + localize(ctx.hass, 'sankey_flow', { from: l.flow.from, to: l.flow.to, value: format(l.value) }); + const label = localize(ctx.hass, 'sankey_aria', { flows: layout.links.map(describe).join(', ') }); + const shown = layout.links.find((l) => l.flow.index === active); + + const links = layout.links.map((l) => { + const e = ctx.items[l.flow.index]; + const interactive = isInteractive(e.config); + return svg` + ${describe(l)} + + `; + }); + const nodes = layout.nodes.map( + (n) => + svg``, + ); + return html`
+ + ${links} + ${nodes} + + + ${tooltip(shown, d, shown ? colors.link.get(shown.flow.index)! : '', shown ? format(shown.value) : '')} +
`; +} + +const FIELDS: FieldBinding[] = [{ name: 'link_color', path: ['link_color'], default: 'auto' }]; + +export function sankeyEditor(ctx: ChartEditorContext): TemplateResult { + const schema = cachedSchema('sankey', ctx.hass, () => [ + { name: 'link_color', selector: choice(ctx.hass, 'link_color', LINK_COLORS) }, + ]); + return chartForm(ctx, 'chart', schema, FIELDS); +} + +const ENTITY_FIELDS: FieldBinding[] = [ + { name: 'from', path: ['from'], default: '' }, + { name: 'to', path: ['to'], default: '' }, + { name: 'flow_color', path: ['color'] }, +]; +const ENTITY_SCHEMA = [ + { + type: 'grid', + name: '', + schema: [ + { name: 'from', selector: { text: {} } }, + { name: 'to', selector: { text: {} } }, + ], + }, + { name: 'flow_color', selector: { text: {} } }, +]; + +/** Readable errors for the options this style reads, including flows that go round in a loop. */ +export function validateSankey(raw: CardConfig): void { + if (raw.link_color !== undefined && !LINK_COLORS.includes(raw.link_color as LinkColor)) { + throw new Error(`"link_color" must be one of: ${LINK_COLORS.join(', ')}`); + } + const pairs: [string, string][] = []; + (raw.entities ?? []).forEach((e, i) => { + if (typeof e !== 'object' || e === null) return; + const own = e as FlowEntity; + for (const key of ['from', 'to'] as const) { + if (own[key] !== undefined && typeof own[key] !== 'string') { + throw new Error(`Entity ${i + 1}: "${key}" must be a node name`); + } + } + const from = nodeName(own.from); + const to = nodeName(own.to); + if (from && to && from === to) throw new Error(`Entity ${i + 1}: "from" and "to" must be different nodes`); + if (from && to) pairs.push([from, to]); + }); + const loop = findCycle(pairs); + if (loop) throw new Error(`Flows can't go round in a loop: ${loop.join(' → ')}`); +} + +export const sankey: ChartStyleModule = { + layout: 'chart', + group: 'totals', + height: 220, + styles: css` + ${frameStyles} + ${tooltipStyles} + .sankey-wrap { + position: relative; + } + .sankey { + display: block; + width: 100%; + height: auto; + overflow: visible; + } + .sankey .link { + fill-opacity: 0.35; + transition: + fill-opacity 0.15s ease, + opacity var(--cd-duration) var(--cd-easing); + } + .sankey.dark .link { + fill-opacity: 0.45; + } + .sankey.hovering .link { + fill-opacity: 0.2; + } + .sankey .flow:hover .link, + .sankey .flow:focus-visible .link, + .sankey .flow.active .link { + fill-opacity: 0.75; + } + .sankey .flow { + cursor: pointer; + outline: none; + } + .sankey .flow:focus-visible { + box-shadow: none; + } + .sankey .flow:focus-visible .link { + fill-opacity: 0.85; + } + :host(:not([ready])) .sankey .link { + opacity: 0; + } + .sankey .node { + transition: opacity var(--cd-duration) var(--cd-easing); + } + :host(:not([ready])) .sankey .node { + opacity: 0; + } + :host([static]) .sankey .node { + transition: none; + } + .sankey .label { + pointer-events: none; + } + .sankey .name { + fill: var(--primary-text-color); + } + .sankey .value { + fill: var(--secondary-text-color); + } + .sankey-tip { + pointer-events: none; + white-space: nowrap; + max-width: none; + transform: translate(-50%, calc(-100% - 6px)); + } + `, + thumbnail: () => svg` + + + + + + + + + `, + validate: validateSankey, + editor: sankeyEditor, + entityFields: () => ({ schema: ENTITY_SCHEMA, fields: ENTITY_FIELDS }), + disconnect(host) { + stopChartWidth(host); + hovered.delete(host); + }, + render, +}; diff --git a/src/styles/slope.ts b/src/styles/slope.ts new file mode 100644 index 0000000..7d892c4 --- /dev/null +++ b/src/styles/slope.ts @@ -0,0 +1,486 @@ +import { statisticsPeriod } from '../history/fetch'; +import { css, html, nothing, svg } from 'lit'; +import type { TemplateResult } from 'lit'; +import { baseColor } from '../bands'; +import { direction, extentOf, readingAt, relativeChange } from '../chart/compare'; +import { + SERIES_COLORS, + disconnectHistory, + frameMessage, + frameStyles, + historyFrame, + validateHistoryOptions, +} from '../chart/frame'; +import type { HistoryFrame } from '../chart/frame'; +import { padDomain } from '../chart/scale'; +import { ROLLING_HISTORY_FIELDS, cachedSchema, chartForm, choice, rollingHistorySchema } from '../editor/chart-editors'; +import type { FieldBinding } from '../editor/schemas'; +import { formatNumber } from '../format'; +import { parsePeriod, rollingRange } from '../history/period'; +import type { Period } from '../history/period'; +import { localize, registerStrings } from '../localize'; +import type { ResolvedEntity } from '../resolve'; +import type { CardConfig, HomeAssistant } from '../types'; +import type { ChartContext, ChartStyleModule } from './index'; + +export const MEASURES = ['value', 'change'] as const; +export const SLOPE_COLOR_BY = ['direction', 'group'] as const; +export const INCREASE = ['bad', 'good'] as const; +type Measure = (typeof MEASURES)[number]; + +/** Vertical room one label needs, px. */ +const LABEL_GAP = 18; +/** Plot height: at least this, more when there are many entities. */ +const MIN_PLOT_H = 160; + +registerStrings('en', { + editor_style_slope: 'Slope', + editor_measure: 'Compare', + editor_measure_value: 'The reading then and now', + editor_measure_change: 'How much it rose in each period (meters)', + editor_color_by_direction: 'Up or down', + editor_color_by_group: 'By group', + editor_increase: 'A rise is', + editor_increase_bad: 'Bad (red)', + editor_increase_good: 'Good (green)', + editor_group: 'Group', + slope_now: 'Now', + slope_ago: '{n} {unit} ago', + slope_ago_one: 'A {unit} ago', + slope_previous: 'Previous {n} {unit}', + slope_previous_one: 'Previous {unit}', + slope_last: 'Last {n} {unit}', + slope_last_one: 'Last {unit}', + slope_unit_hour: 'hour', + slope_unit_hour_other: 'hours', + slope_unit_day: 'day', + slope_unit_day_other: 'days', + slope_unit_week: 'week', + slope_unit_week_other: 'weeks', + slope_unit_month: 'month', + slope_unit_month_other: 'months', + slope_entity_aria: '{name}, {from} to {to}', + slope_no_reading: 'no reading', +}); + +/** + * Spread label centres apart so none are closer than `gap`, keeping their order and staying + * within `lo`..`hi` where there is room. Returns the new positions in the input's order. + */ +export function spreadLabels(positions: number[], gap: number, lo: number, hi: number): number[] { + const order = positions.map((y, i) => ({ y, i })).sort((a, b) => a.y - b.y || a.i - b.i); + const ys = order.map((o) => Math.min(hi, Math.max(lo, o.y))); + for (let k = 1; k < ys.length; k++) ys[k] = Math.max(ys[k], ys[k - 1] + gap); + if (ys.length && ys[ys.length - 1] > hi) { + ys[ys.length - 1] = hi; + for (let k = ys.length - 2; k >= 0; k--) ys[k] = Math.min(ys[k], ys[k + 1] - gap); + } + // Too many to fit: start at the top and let the last ones run past the bottom. + if (ys.length && ys[0] < lo) { + ys[0] = lo; + for (let k = 1; k < ys.length; k++) ys[k] = Math.max(ys[k], ys[k - 1] + gap); + } + const out = new Array(positions.length); + order.forEach((o, k) => (out[o.i] = ys[k])); + return out; +} + +const STATISTICS_MS = { '5minute': 300_000, hour: 3_600_000, day: 86_400_000, month: 31 * 86_400_000 }; + +/** The two values a slope joins, for one entity's points. */ +export function slopeEnds( + measure: Measure, + points: { t: number; v: number | null }[], + times: { start: number; middle: number }, + now: number | null, + /** Statistics rows start on the hour (or day), just after the span starts: within this many ms, + * the first row stands in for the reading at the start. 0 for history. */ + slack = 0, +): [number | null, number | null] { + const first = points.find((p) => p.v !== null); + const start = readingAt(points, times.start) ?? (first && first.t - times.start <= slack ? first.v : null); + if (measure === 'value') return [start, now]; + const middle = readingAt(points, times.middle); + const before = start === null || middle === null ? null : middle - start; + const after = middle === null || now === null ? null : now - middle; + return [before, after]; +} + +/** The colour of a slope by which way it went: rises red unless a rise is good, flat grey. */ +export function directionColor(from: number, to: number, increase: 'bad' | 'good'): string { + const way = direction(from, to); + if (way === 'flat') return 'grey'; + return (way === 'up') === (increase === 'bad') ? 'red' : 'green'; +} + +function periodText(hass: HomeAssistant | undefined, key: 'ago' | 'previous' | 'last', period: Period): string { + const one = period.last === 1; + const unit = localize(hass, `slope_unit_${period.unit}${one ? '' : '_other'}`); + return localize(hass, one ? `slope_${key}_one` : `slope_${key}`, { n: period.last, unit }); +} + +/** "+12 %" / "−20 %", or '' when a percentage means nothing. */ +function changeText(from: number, to: number, hass: HomeAssistant): string { + const rel = relativeChange(from, to); + if (rel === undefined) return ''; + const pct = Math.round(rel * 100); + const sign = pct > 0 ? '+' : pct < 0 ? '−' : ''; + return `${sign}${formatNumber(Math.abs(pct), 0, hass)} %`; +} + +interface Row { + index: number; + entity: ResolvedEntity; + from: number | null; + to: number | null; + color: string; + group?: string; +} + +function groupOf(e: ResolvedEntity): string | undefined { + const g = (e.config as { group?: unknown }).group; + return typeof g === 'string' && g.trim() !== '' ? g.trim() : undefined; +} + +function rowsFor(frame: HistoryFrame, ctx: ChartContext, measure: Measure, times: { start: number; middle: number }) { + const slack = frame.source === 'statistics' ? STATISTICS_MS[statisticsPeriod(frame.range)] : 0; + const byGroup = ctx.raw.color_by === 'group'; + const increase = ctx.raw.increase === 'good' ? 'good' : 'bad'; + const groupColors = new Map(); + const rows: Row[] = frame.series.map((s) => { + const now = s.entity.available ? s.entity.value : null; + const [from, to] = slopeEnds(measure, s.points, times, now, slack); + const group = groupOf(s.entity); + const own = (s.entity.config as { color?: string }).color; + let color: string; + if (byGroup) { + const k = group ?? `#${s.index}`; + if (!groupColors.has(k)) groupColors.set(k, SERIES_COLORS[groupColors.size % SERIES_COLORS.length]); + color = own ?? groupColors.get(k)!; + } else { + color = from !== null && to !== null ? directionColor(from, to, increase) : 'grey'; + } + return { index: s.index, entity: s.entity, from, to, color: baseColor(color), group }; + }); + return rows; +} + +function legend(rows: Row[]): TemplateResult | typeof nothing { + const seen = new Map(); + for (const r of rows) if (r.group && !seen.has(r.group)) seen.set(r.group, r.color); + if (!seen.size) return nothing; + return html`
+ ${[...seen].map( + ([name, color]) => + html`${name}`, + )} +
`; +} + +const FIELDS: FieldBinding[] = [ + ...ROLLING_HISTORY_FIELDS, + { name: 'measure', path: ['measure'], default: 'value' }, + { name: 'color_by', path: ['color_by'], default: 'direction' }, + { name: 'increase', path: ['increase'], default: 'bad' }, +]; + +const ENTITY_FIELDS: FieldBinding[] = [{ name: 'group', path: ['group'] }]; + +export const slope: ChartStyleModule = { + layout: 'chart', + group: 'compare', + height: 230, + styles: css` + ${frameStyles} + .style-slope .slope-head, + .style-slope .slope-plot { + --lw: min(36%, 180px); + } + .slope-head { + display: grid; + grid-template-columns: var(--lw) 1fr var(--lw); + gap: 0 8px; + padding: 4px 0 6px; + font-size: 0.875em; + color: var(--secondary-text-color); + } + .slope-head .from { + grid-column: 1; + text-align: right; + } + .slope-head .to { + grid-column: 3; + } + .slope-head .unit { + grid-column: 2; + text-align: center; + } + .slope-plot { + position: relative; + margin: 8px 0; + } + .slope-plot .axis { + position: absolute; + top: -6px; + bottom: -6px; + border-left: 1px dashed var(--divider-color, rgba(127, 127, 127, 0.3)); + } + .slope-plot .axis.from { + left: calc(var(--lw) + 8px); + } + .slope-plot .axis.to { + right: calc(var(--lw) + 8px); + } + .slope-plot .entity { + position: absolute; + inset: 0; + pointer-events: none; + outline: none; + opacity: 1; + transition: opacity var(--cd-duration) var(--cd-easing); + } + :host(:not([ready])) .slope-plot .entity { + opacity: 0; + } + .slope-plot .entity > * { + pointer-events: auto; + cursor: pointer; + } + .slope-plot .entity:focus-visible .lbl { + outline: 2px solid var(--primary-color); + outline-offset: 1px; + border-radius: 3px; + } + .slope-plot svg { + position: absolute; + top: 0; + bottom: 0; + left: calc(var(--lw) + 8px); + right: calc(var(--lw) + 8px); + width: calc(100% - 2 * var(--lw) - 16px); + height: 100%; + overflow: visible; + pointer-events: none; + } + .slope-plot svg line { + vector-effect: non-scaling-stroke; + stroke-width: 3; + stroke-linecap: round; + pointer-events: stroke; + } + .slope-plot .dot { + position: absolute; + width: 10px; + height: 10px; + margin: -5px 0 0 -5px; + border-radius: 50%; + } + .slope-plot .dot.from { + left: calc(var(--lw) + 8px); + } + .slope-plot .dot.to { + left: calc(100% - var(--lw) - 8px); + } + .slope-plot .lbl { + position: absolute; + transform: translateY(-50%); + width: var(--lw); + display: flex; + gap: 4px; + align-items: baseline; + font-size: 0.875em; + line-height: 1.2; + white-space: nowrap; + min-width: 0; + } + .slope-plot .lbl.from { + left: 0; + justify-content: flex-end; + } + .slope-plot .lbl.to { + right: 0; + } + .slope-plot .lbl .name { + color: var(--secondary-text-color); + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; + } + .slope-plot .lbl .value { + font-weight: 500; + flex: none; + } + .slope-plot .lbl .pct { + flex: none; + padding-left: 2px; + font-size: 0.9em; + } + /* Narrow cards: the change column would squeeze the names to nothing. */ + @container (max-width: 380px) { + .slope-plot .lbl .pct { + display: none; + } + } + .slope-legend { + display: flex; + flex-wrap: wrap; + gap: 4px 16px; + padding: 4px 0; + font-size: 0.875em; + color: var(--secondary-text-color); + } + .slope-legend-item { + display: flex; + align-items: center; + gap: 6px; + } + `, + thumbnail: () => svg` + + + + ${[ + [14, 28], + [50, 10], + [14, 12], + [50, 22], + [14, 32], + [50, 30], + ].map(([x, y]) => svg``)}`, + validate(raw: CardConfig) { + validateHistoryOptions(raw); + const check = (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(', ')}`); + } + }; + check('measure', MEASURES); + check('color_by', SLOPE_COLOR_BY); + check('increase', INCREASE); + for (const e of raw.entities ?? []) { + const group = typeof e === 'object' && e ? (e as { group?: unknown }).group : undefined; + if (group !== undefined && typeof group !== 'string') throw new Error('"group" must be text'); + } + }, + editor(ctx) { + const schema = cachedSchema('slope', ctx.hass, () => [ + ...rollingHistorySchema(ctx.hass), + { name: 'measure', selector: choice(ctx.hass, 'measure', MEASURES) }, + { + type: 'grid', + name: '', + schema: [ + { name: 'color_by', selector: choice(ctx.hass, 'color_by', SLOPE_COLOR_BY) }, + { name: 'increase', selector: choice(ctx.hass, 'increase', INCREASE) }, + ], + }, + ]); + return chartForm(ctx, 'chart', schema, FIELDS); + }, + entityFields: (hass) => ({ + schema: cachedSchema('slope-entity', hass, () => [{ name: 'group', selector: { text: {} } }]), + fields: ENTITY_FIELDS, + }), + disconnect: disconnectHistory, + render(ctx) { + const now = Date.now(); + const period = parsePeriod(ctx.raw.period as CardConfig['period']); + const measure: Measure = ctx.raw.measure === 'change' ? 'change' : 'value'; + // Change needs the period before this one too: load twice as far back. + const frameCtx = + measure === 'change' + ? { ...ctx, raw: { ...ctx.raw, period: { last: period.last * 2, unit: period.unit } } } + : ctx; + const frame = historyFrame(frameCtx, now); + const middle = rollingRange(period, now).start; + const times = { start: frame.range.start, middle }; + const hass = ctx.hass; + const fromText = measure === 'change' ? periodText(hass, 'previous', period) : periodText(hass, 'ago', period); + const toText = measure === 'change' ? periodText(hass, 'last', period) : localize(hass, 'slope_now'); + if (frame.status !== 'ready') return html`${frameMessage(frame, ctx)}`; + const rows = rowsFor(frame, ctx, measure, times); + const values = rows.flatMap((r) => [r.from, r.to]); + const ext = extentOf(values); + if (!ext) return html`
${localize(hass, 'chart_no_data')}
`; + const { show } = ctx.config; + // Missing entities have no unit; they shouldn't stop the others sharing one. + const units = new Set(rows.filter((r) => r.entity.stateObj).map((r) => r.entity.unit)); + const sharedUnit = units.size === 1 ? [...units][0] : undefined; + const fmt = (r: Row, v: number) => { + const n = formatNumber(v, r.entity.decimals, hass); + return sharedUnit !== undefined || !r.entity.unit ? n : `${n} ${r.entity.unit}`; + }; + + const drawn = rows.filter((r) => r.from !== null || r.to !== null); + const height = Math.max(MIN_PLOT_H, drawn.length * (LABEL_GAP + 4)); + const [lo, hi] = padDomain(ext, 0.06); + const pad = LABEL_GAP / 2; + const y = (v: number) => pad + ((hi - v) / (hi - lo)) * (height - 2 * pad); + const side = (pick: (r: Row) => number | null) => { + const list = drawn.filter((r) => pick(r) !== null); + const spread = spreadLabels( + list.map((r) => y(pick(r)!)), + LABEL_GAP, + pad, + height - pad, + ); + return new Map(list.map((r, k) => [r, spread[k]])); + }; + const leftY = side((r) => r.from); + const rightY = side((r) => r.to); + const noReading = localize(hass, 'slope_no_reading'); + + const entity = (r: Row) => { + const aria = localize(hass, 'slope_entity_aria', { + name: r.entity.name, + from: r.from === null ? noReading : fmt(r, r.from), + to: r.to === null ? noReading : fmt(r, r.to), + }); + const pct = show.value && r.from !== null && r.to !== null ? changeText(r.from, r.to, hass) : ''; + const name = (n: string) => (show.name ? html`${n}` : nothing); + return html``; + }; + + const label = localize(hass, 'chart_aria', { + style: localize(hass, 'editor_style_slope'), + names: rows.map((r) => r.entity.name).join(', '), + }); + return html`
+ ${fromText} + ${sharedUnit ? html`${sharedUnit}` : nothing} + ${toText} +
+
+
+
+ ${drawn.map(entity)} +
+ ${ctx.raw.color_by === 'group' ? legend(rows) : nothing}`; + }, +}; diff --git a/src/styles/sparkline.ts b/src/styles/sparkline.ts new file mode 100644 index 0000000..7658088 --- /dev/null +++ b/src/styles/sparkline.ts @@ -0,0 +1,436 @@ +import { boundaries, zoneFor } from '../history/timezone'; +import { groupHistory } from '../history/group'; +import type { Aggregate } from '../history/group'; +import type { Point, Range } from '../history/types'; +import { css, html, nothing, svg } from 'lit'; +import type { SVGTemplateResult, TemplateResult } from 'lit'; +import { baseColor, colorStops } from '../bands'; +import { downsample } from '../chart/downsample'; +import { disconnectHistory, frameStyles, historyFrame, periodNav, validateHistoryOptions } from '../chart/frame'; +import type { ChartSeries } from '../chart/frame'; +import { CURVES, runPath, runs } from '../chart/paths'; +import type { XY } from '../chart/paths'; +import { linear, padDomain } from '../chart/scale'; +import { MAX_LINE_WIDTH, MIN_LINE_WIDTH } from '../chart/series-style'; +import { + HISTORY_FIELDS, + cachedSchema, + chartForm, + choice, + historySchema, + historyVariant, +} from '../editor/chart-editors'; +import { LINE_WIDTH_SELECTOR } from '../editor/schemas'; +import type { FieldBinding } from '../editor/schemas'; +import { localize, registerStrings } from '../localize'; +import { iconOverride } from '../resolve'; +import type { ResolvedEntity } from '../resolve'; +import type { CardConfig, HomeAssistant } from '../types'; +import type { ChartContext, ChartEditorContext, ChartStyleModule } from './index'; + +/** Kinds of sparkline. */ +export const SPARK_KINDS = ['line', 'area', 'column'] as const; +export type SparkKind = (typeof SPARK_KINDS)[number]; + +/** A sparkline's drawing box; it stretches to its cell, strokes don't. */ +const W = 100; +const H = 24; +/** Points kept per sparkline: it is only a hundred or so pixels wide. */ +const MAX_POINTS = 120; +/** A sparkline is thin: its default line is lighter than a line chart's. */ +const SPARK_LINE_WIDTH = 1.5; + +registerStrings('en', { + editor_style_sparkline: 'Sparklines', + editor_spark: 'Sparkline', + editor_spark_line: 'Line', + editor_spark_area: 'Area', + editor_spark_column: 'Columns', + editor_spark_bar: 'Bar under the name and value', + editor_spark_color: 'Colour', + spark_aria: 'Trend of {name}', +}); + +/** + * Which kind a row draws: its own `spark`, else the card's, else a line. Columns of raw readings + * would be hairlines, so an ungrouped column sparkline is grouped by hour (two days or less) or + * by day: see `sparkRaw`. + */ +export function sparkKind(e: ResolvedEntity, raw: CardConfig): SparkKind { + const own = (e.config as { spark?: SparkKind }).spark; + return own ?? (raw.spark as SparkKind | undefined) ?? 'line'; +} + +/** The grouping an ungrouped column sparkline gets: by hour up to two days, else by day. */ +export function columnUnit(raw: CardConfig): 'hour' | 'day' { + const period = (raw.period ?? {}) as { last?: number; unit?: string; calendar?: string }; + const hours = (period.last ?? 24) * ({ hour: 1, day: 24, week: 168, month: 720 }[period.unit ?? 'hour'] ?? 1); + const short = period.calendar ? period.calendar === 'day' : hours <= 48; + return short ? 'hour' : 'day'; +} + +/** + * When every row is a column sparkline and nothing is grouped, group the whole card (statistics + * can then be asked for in the right period). Mixed cards stay ungrouped, so line and area rows + * keep every reading; their column rows are grouped on their own (see `columnPoints`). + */ +export function sparkRaw(raw: CardConfig, kinds: SparkKind[]): CardConfig { + if (raw.group_by !== undefined || kinds.length === 0 || !kinds.every((k) => k === 'column')) return raw; + return { ...raw, group_by: columnUnit(raw), aggregate: raw.aggregate ?? 'mean' }; +} + +/** A column row's points in a card left ungrouped: averaged per hour or day in HA's time zone. */ +export function columnPoints(points: Point[], raw: CardConfig, range: Range, hass: HomeAssistant | undefined): Point[] { + const bounds = boundaries(columnUnit(raw), range.start, range.end, zoneFor(hass)); + return groupHistory(points, bounds, (raw.aggregate as Aggregate | undefined) ?? 'mean', Date.now()); +} + +/** The value range a sparkline is drawn over: its own readings, padded; zero kept in for columns. */ +export function sparkDomain(values: number[], zero: boolean): [number, number] | undefined { + if (values.length === 0) return undefined; + let lo = Math.min(...values); + let hi = Math.max(...values); + if (zero) { + lo = Math.min(lo, 0); + hi = Math.max(hi, 0); + return lo === hi ? [lo, lo + 1] : [lo, hi]; + } + [lo, hi] = padDomain([lo, hi], 0.1); + return [lo, hi]; +} + +/** One sparkline: a line, a filled area or columns, in the row's colour. */ +export function sparkline(s: ChartSeries, kind: SparkKind, range: { start: number; end: number }, width: number) { + const points = downsample(s.points, MAX_POINTS); + const values = points.filter((p) => p.v !== null).map((p) => p.v as number); + const domain = sparkDomain(values, kind === 'column'); + if (!domain) return svg``; + const x = linear([range.start, range.end], [0, W]); + // A pixel of room at top and bottom, so a line along the edge isn't cut in half. + const y = linear(domain, [H - 1, 1]); + const base = y(Math.min(Math.max(0, domain[0]), domain[1])); + if (kind === 'column') { + const bars: SVGTemplateResult[] = []; + points.forEach((p, i) => { + if (p.v === null) return; + const end = p.end ?? points[i + 1]?.t ?? range.end; + const x0 = x(p.t); + const w = Math.max(0, Math.min(W, x(end)) - x0); + if (w <= 0) return; + const top = Math.min(y(p.v), base); + bars.push(svg``); + }); + return svg`${bars}`; + } + const pieces = runs(points).map((r) => r.map((p) => [x(p.t), y(p.v as number)] as XY)); + const line = pieces.map((pts) => runPath(pts, s.curve)).join(''); + const fill = + kind === 'area' + ? pieces + .filter((pts) => pts.length > 1) + .map( + (pts) => + `${runPath(pts, s.curve === 'step' ? 'straight' : s.curve)}L${pts[pts.length - 1][0]},${base}L${pts[0][0]},${base}Z`, + ) + .join('') + : ''; + return svg`${fill ? svg`` : nothing} + `; +} + +/** The 1.0 thin bar: the current value between min and max, in its band's colour, with the target. */ +function bar(e: ResolvedEntity, showTarget: boolean) { + const stops = colorStops(e.band.color); + return html`
+
+ ${showTarget && e.targetPosition !== null && e.available ? html`
` : nothing} +
`; +} + +function render(ctx0: ChartContext): TemplateResult { + const kinds = ctx0.items.map((e) => sparkKind(e, ctx0.raw)); + const ctx = { ...ctx0, raw: sparkRaw(ctx0.raw, kinds) }; + const frame = historyFrame(ctx); + const { show } = ctx.config; + const withBar = ctx.raw.bar === true; + const width = (ctx.raw.line_width as number | undefined) ?? SPARK_LINE_WIDTH; + const message = + frame.status === 'error' + ? html`
${localize(ctx.hass, 'chart_error', { error: frame.error ?? '' })}
` + : nothing; + const rows = frame.series.map((s, i) => { + const e = s.entity; + // A row is coloured like the 1.0 bars (its value's band) unless the entity sets a colour. + const own = (e.config as { color?: string }).color; + const color = own ? baseColor(own) : baseColor(e.band.color); + const grouped = sparkKind(e, ctx0.raw) === 'column' && ctx.raw.group_by === undefined; + const series = { ...s, color, points: grouped ? columnPoints(s.points, ctx.raw, frame.range, ctx.hass) : s.points }; + const lineWidth = (e.config as { line_width?: number }).line_width ?? width; + const name = show.name ? html`${e.name}` : nothing; + const value = show.value || !e.available ? html`${e.display}` : nothing; + return html`
+ ${ + show.icon + ? html`` + : nothing + } + ${ + withBar + ? html`
+
${name}${value}
+ ${bar(e, show.target)} +
` + : name + } + + ${frame.status === 'ready' ? sparkline(series, kinds[i], frame.range, lineWidth) : nothing} + + ${withBar ? nothing : value} +
`; + }); + return html`${periodNav(frame, ctx)}${message} +
${rows}
`; +} + +const SPARK_FIELDS: FieldBinding[] = [ + ...HISTORY_FIELDS, + { name: 'spark', path: ['spark'], default: 'line' }, + { name: 'curve', path: ['curve'], default: 'smooth' }, + { name: 'line_width', path: ['line_width'], default: SPARK_LINE_WIDTH }, + { name: 'spark_bar', path: ['bar'], default: false }, +]; + +export function sparklineEditor(ctx: ChartEditorContext): TemplateResult { + const schema = cachedSchema(`sparkline|${historyVariant(ctx.config)}`, ctx.hass, () => [ + ...historySchema(ctx.hass, ctx.config), + { + type: 'grid', + name: '', + schema: [ + { name: 'spark', selector: choice(ctx.hass, 'spark', SPARK_KINDS) }, + { name: 'curve', selector: choice(ctx.hass, 'curve', CURVES) }, + ], + }, + { + type: 'grid', + name: '', + schema: [ + { name: 'line_width', selector: LINE_WIDTH_SELECTOR }, + { name: 'spark_bar', selector: { boolean: {} } }, + ], + }, + ]); + return chartForm(ctx, 'chart', schema, SPARK_FIELDS); +} + +const ENTITY_FIELDS: FieldBinding[] = [ + { name: 'spark', path: ['spark'] }, + { name: 'spark_color', path: ['color'] }, +]; + +function validate(raw: CardConfig): void { + validateHistoryOptions(raw); + const kinds = [ + raw.spark, + ...(raw.entities ?? []).map((e) => (typeof e === 'object' ? (e as { spark?: unknown }).spark : undefined)), + ]; + for (const k of kinds) { + if (k !== undefined && !SPARK_KINDS.includes(k as SparkKind)) { + throw new Error(`"spark" must be one of: ${SPARK_KINDS.join(', ')}`); + } + } + if (raw.bar !== undefined && typeof raw.bar !== 'boolean') throw new Error('"bar" must be true or false'); + const w = raw.line_width; + if (w !== undefined && (typeof w !== 'number' || w < MIN_LINE_WIDTH || w > MAX_LINE_WIDTH)) { + throw new Error(`"line_width" must be a number from ${MIN_LINE_WIDTH} to ${MAX_LINE_WIDTH}`); + } +} + +export const sparklineStyle: ChartStyleModule = { + layout: 'chart', + group: 'history', + height: 120, + styles: css` + ${frameStyles} + /* One grid for every row, so the sparklines line up whatever each value's width. */ + .spark-rows { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(64px, 38%) auto; + column-gap: 12px; + } + .spark-rows.icons { + grid-template-columns: auto minmax(0, 1fr) minmax(64px, 38%) auto; + } + .spark-rows.bars { + grid-template-columns: minmax(0, 1fr) minmax(64px, 38%); + } + .spark-rows.icons.bars { + grid-template-columns: auto minmax(0, 1fr) minmax(64px, 38%); + } + .spark-row { + grid-column: 1 / -1; + display: grid; + grid-template-columns: subgrid; + align-items: center; + padding: 6px 0; + min-height: 28px; + border-radius: 6px; + cursor: pointer; + } + .spark-row:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: 2px; + } + .spark-row .icon { + color: var(--state-icon-color, var(--secondary-text-color)); + --mdc-icon-size: 22px; + } + .spark-row .name { + font-size: 14px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + } + .spark-row .value { + font-size: 14px; + font-weight: 500; + white-space: nowrap; + text-align: right; + min-width: calc(var(--cd-value-ch, 4) * 1ch); + font-variant-numeric: tabular-nums; + } + .spark-row.unavailable .value { + color: var(--secondary-text-color); + font-weight: 400; + font-size: 13px; + max-width: 7em; + overflow: hidden; + text-overflow: ellipsis; + } + /* Narrow cards: the names keep their room, the sparklines give some up. */ + @container (max-width: 360px) { + .spark-rows { + grid-template-columns: minmax(0, 1fr) minmax(48px, 30%) auto; + } + .spark-rows.icons { + grid-template-columns: auto minmax(0, 1fr) minmax(48px, 30%) auto; + } + .spark-rows.bars { + grid-template-columns: minmax(0, 1fr) minmax(48px, 30%); + } + .spark-rows.icons.bars { + grid-template-columns: auto minmax(0, 1fr) minmax(48px, 30%); + } + } + .spark { + width: 100%; + height: 28px; + overflow: visible; + } + .spark path { + vector-effect: non-scaling-stroke; + stroke-linecap: round; + stroke-linejoin: round; + } + .spark .spark-fill { + fill-opacity: 0.22; + } + .spark-body { + min-width: 0; + } + .spark-body .top { + display: flex; + justify-content: space-between; + gap: 8px; + margin-bottom: 6px; + } + .spark-body .value { + font-weight: 400; + font-size: 13px; + color: var(--secondary-text-color); + min-width: 0; + } + .spark-track { + position: relative; + height: 8px; + border-radius: 4px; + overflow: hidden; + background: var(--cd-track); + } + .spark-bar { + position: absolute; + top: 0; + bottom: 0; + left: calc(var(--start) * 100%); + width: calc((var(--end) - var(--start)) * 100%); + border-radius: 4px; + background: var(--c-start); + background-image: linear-gradient(90deg, var(--c-start), var(--c-end)); + } + .spark-target { + position: absolute; + top: 0; + bottom: 0; + width: 2px; + left: calc(var(--target) * (100% - 2px)); + background: var(--primary-text-color); + } + .spark-message { + color: var(--secondary-text-color); + font-size: 0.875em; + padding: 4px 0; + } + .spark-message.error { + color: var(--error-color, #db4437); + } + `, + thumbnail: () => svg` + + + + + `, + validate, + editor: sparklineEditor, + entityFields: (hass) => ({ + schema: cachedSchema('sparkline-entity', hass, () => [ + { + type: 'grid', + name: '', + schema: [ + { name: 'spark', selector: choice(hass, 'spark', SPARK_KINDS) }, + { name: 'spark_color', selector: { text: {} } }, + ], + }, + ]), + fields: ENTITY_FIELDS, + }), + disconnect(host) { + disconnectHistory(host); + }, + render, +}; diff --git a/src/styles/streamgraph.ts b/src/styles/streamgraph.ts new file mode 100644 index 0000000..d9e711f --- /dev/null +++ b/src/styles/streamgraph.ts @@ -0,0 +1,472 @@ +import { css, html, nothing, svg } from 'lit'; +import type { TemplateResult } from 'lit'; +import { onFillText } from '../bands'; +import { formatDateTime, validateAxisOptions } from '../chart/axes'; +import { + SERIES_COLORS, + VIEW_H, + VIEW_W, + disconnectHistory, + frameMessage, + frameStyles, + historyFrame, + periodNav, + seriesHeader, + validateHistoryOptions, +} from '../chart/frame'; +import type { ChartSeries, HistoryFrame } from '../chart/frame'; +import { chartArea, gridStyles, plotAxes, plotWidth, stopChartWidth } from '../chart/grid'; +import type { PlotScales } from '../chart/grid'; +import { chartFrame, chartLayout, headerOver, layoutStyles, validateLayout } from '../chart/layout'; +import { runPath } from '../chart/paths'; +import type { Curve, XY } from '../chart/paths'; +import { + BASELINES, + STREAM_ORDERS, + binSeries, + insideOut, + placeLabels, + layersExtent, + sampleBounds, + stack, + stackable, + widest, +} from '../chart/stream'; +import type { Baseline, LabelSpot, Layer, StreamOrder } from '../chart/stream'; +import { + hoverHandlers, + hoverTime, + nearestIndex, + stopHover, + tooltipEnabled, + tooltipSide, + tooltipStyles, + validateTooltipOptions, +} from '../chart/tooltip'; +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 } from '../types'; +import type { ChartContext, ChartEditorContext, ChartStyleModule } from './index'; + +/** Samples across the plot: one per 4 px or so, never more than this. */ +const MAX_SAMPLES = 240; +/** A stream thinner than this (px) gets no name on it. */ +const LABEL_MIN_PX = 18; +/** Plot height in px, for deciding which streams are thick enough to label. */ +const PLOT_PX = 180; +const COMPACT_PLOT_PX = 110; +/** Rough width of one character of a stream's name (11 px text), for keeping names apart. */ +const LABEL_CHAR_PX = 6.2; + +registerStrings('en', { + editor_style_streamgraph: 'Streamgraph', + editor_baseline: 'Shape', + editor_baseline_wiggle: 'Streamgraph (flowing)', + editor_baseline_centre: 'Centred', + editor_baseline_share: 'Share of the total (100 %)', + editor_baseline_zero: 'Stacked from zero', + editor_stream_order: 'Order', + editor_stream_order_inside_out: 'Biggest in the middle', + editor_stream_order_listed: 'As listed, first at the bottom', + editor_stream_labels: 'Names on the streams', + editor_stream_curve: 'Edges', + editor_fill_color: 'Colour', + stream_total: 'Total', +}); + +/** One sample across every stream: when, and each series' value there. */ +interface Samples { + times: number[]; + /** Per series (frame order): the value in each sample, null for a gap. */ + values: (number | null)[][]; +} + +/** + * The series on shared times. Grouped readings already are (one value per group, at its middle); + * otherwise each series is binned into even slices of the range by its time-weighted mean. + * Nothing is drawn past now. + */ +export function streamSamples(frame: HistoryFrame, now: number, width: number): Samples { + const until = Math.min(frame.range.end, now); + if (frame.group) { + const first = frame.series[0]?.points ?? []; + const keep = first.map((_, j) => j).filter((j) => first[j].t < until); + return { + times: keep.map((j) => (first[j].t + Math.min(first[j].end ?? first[j].t, until)) / 2), + values: frame.series.map((s) => keep.map((j) => s.points[j]?.v ?? null)), + }; + } + let count = Math.min(MAX_SAMPLES, Math.max(24, Math.round(width / 4))); + // Statistics rows are a fixed length: never cut finer than they are, or bins come up empty. + if (frame.source === 'statistics') { + const rows = Math.max(1, ...frame.series.map((s) => s.points.filter((p) => !p.carried).length)); + count = Math.min(count, rows); + } + const bounds = sampleBounds(frame.range, until, count); + return { + times: bounds.slice(0, -1).map((t, i) => (t + bounds[i + 1]) / 2), + values: frame.series.map((s) => binSeries(s.points, bounds, frame.source, until)), + }; +} + +function baselineOf(raw: CardConfig): Baseline { + return (raw.baseline as Baseline | undefined) ?? 'wiggle'; +} + +function orderOf(raw: CardConfig): StreamOrder { + return (raw.stream_order as StreamOrder | undefined) ?? 'inside_out'; +} + +/** The area between a layer's edges, from the first sample to the last. */ +export function layerPath(layer: Layer, xs: number[], scales: PlotScales, curve: Curve): string { + const top: XY[] = xs.map((x, j) => [x, scales.y(layer.upper[j])]); + const bottom: XY[] = xs.map((x, j) => [x, scales.y(layer.lower[j])]).reverse() as XY[]; + if (top.length === 0) return ''; + return `${runPath(top, curve)}L${runPath(bottom, curve).slice(1)}Z`; +} + +/** Name of a series' colour before it is turned into CSS, for picking text that reads on it. */ +function colorName(s: ChartSeries): string { + return (s.entity.config as { color?: string }).color ?? SERIES_COLORS[s.index % SERIES_COLORS.length]; +} + +const pct = (n: number) => `${Math.round(n * 10000) / 100}%`; + +function streamLabels( + frame: HistoryFrame, + layers: Layer[], + xs: number[], + scales: PlotScales, + ctx: ChartContext, + share: boolean, + values: (number | null)[][], +) { + const [lo, hi] = scales.domain; + const plotPx = chartLayout(ctx.raw).compact ? COMPACT_PLOT_PX : PLOT_PX; + const plotW = plotWidth(ctx); + const px = (v: number) => (v / (hi - lo)) * plotPx; + const texts = new Map(); + const spots: LabelSpot[] = []; + for (const s of frame.series) { + const layer = layers[s.index]; + const { at, thickness } = widest(layer); + if (px(thickness) < LABEL_MIN_PX) continue; + const v = values[s.index][at]; + const amount = share + ? `${formatNumber(layer.upper[at] - layer.lower[at], 0, ctx.hass)} %` + : v === null + ? '' + : withUnit(formatNumber(v, s.entity.decimals, ctx.hass), s.entity.unit); + const text = `${s.entity.name} ${amount}`.trim(); + texts.set(s.index, text); + spots.push({ + key: s.index, + x: (xs[at] / VIEW_W) * plotW, + y: (scales.y((layer.upper[at] + layer.lower[at]) / 2) / VIEW_H) * plotPx, + width: text.length * LABEL_CHAR_PX, + thickness: px(thickness), + }); + } + const placed = placeLabels(spots, plotW); + return html``; +} + +/** The guide and a box listing each stream's value (and share) at the sample under the pointer. */ +function streamTooltip( + ctx: ChartContext, + frame: HistoryFrame, + samples: Samples, + layers: Layer[], + order: number[], + xs: number[], + t: number | undefined, + share: boolean, +) { + if (t === undefined) return nothing; + // Rolling spans move with the clock between redraws: take the nearest sample, not an exact one. + const j = nearestIndex( + samples.times.map((x) => ({ t: x })), + t, + ); + if (j < 0) return nothing; + const x = xs[j] / VIEW_W; + const side = tooltipSide(x, plotWidth(ctx)); + // Top of the stack first, as it is drawn. + const rows = [...order].reverse().map((i) => frame.series[i]); + const shares = layers.map((l) => l.upper[j] - l.lower[j]); + const total = samples.values.reduce((a, row) => a + Math.max(0, row[j] ?? 0), 0); + const units = new Set(frame.series.map((s) => s.entity.unit)); + const value = (s: ChartSeries) => { + const v = samples.values[s.index][j]; + if (v === null) return '—'; + const text = withUnit(formatNumber(v, s.entity.decimals, ctx.hass), s.entity.unit); + return share ? `${text} · ${formatNumber(shares[s.index], 0, ctx.hass)} %` : text; + }; + return html``; +} + +/** Shape fields come first; value labels only mean something on a stack from zero or of shares. */ +function streamVariant(config: CardConfig): string { + const b = baselineOf(config); + return `${historyVariant(config)}|${b === 'wiggle' || b === 'centre' ? 'floating' : 'axis'}`; +} + +const STREAM_FIELDS: FieldBinding[] = [ + ...HISTORY_FIELDS, + { name: 'baseline', path: ['baseline'], default: 'wiggle' }, + { name: 'stream_order', path: ['stream_order'], default: 'inside_out' }, + { name: 'stream_curve', path: ['curve'], default: 'smooth' }, + { name: 'stream_labels', path: ['labels'], default: true }, + ...AXES_FIELDS, + ...LAYOUT_FIELDS, +]; + +/** Floating streams have no value scale to label: drop value labels, lines across and the range. */ +const FLOATING_HIDDEN = new Set(['y_axis', 'grid_horizontal', 'y_min', 'y_max']); + +function withoutFloating(schema: unknown[]): unknown[] { + return schema + .map((item) => { + const s = item as { name: string; schema?: { name: string }[] }; + if (FLOATING_HIDDEN.has(s.name)) return undefined; + if (s.schema) { + const inner = s.schema.filter((x) => !FLOATING_HIDDEN.has(x.name)); + return inner.length ? { ...s, schema: inner } : undefined; + } + return s; + }) + .filter((x) => x !== undefined); +} + +export function streamEditor(ctx: ChartEditorContext): TemplateResult { + const variant = streamVariant(ctx.config); + const schema = cachedSchema(`streamgraph|${variant}`, ctx.hass, () => { + const axes = axesSchema(ctx.hass); + return [ + ...historySchema(ctx.hass, ctx.config), + { + type: 'grid', + name: '', + schema: [ + { name: 'baseline', selector: choice(ctx.hass, 'baseline', BASELINES) }, + { name: 'stream_order', selector: choice(ctx.hass, 'stream_order', STREAM_ORDERS) }, + ], + }, + { + type: 'grid', + name: '', + schema: [ + { name: 'stream_curve', selector: choice(ctx.hass, 'curve', ['smooth', 'straight', 'step']) }, + { name: 'stream_labels', selector: { boolean: {} } }, + ], + }, + ...(variant.endsWith('floating') ? withoutFloating(axes) : axes), + ...layoutSchema(ctx.hass), + ]; + }); + return chartForm(ctx, 'chart', schema, STREAM_FIELDS); +} + +const ENTITY_FIELDS = { + schema: [{ name: 'fill_color', selector: { text: {} } }], + fields: [{ name: 'fill_color', path: ['color'] }], +}; + +export const streamgraph: ChartStyleModule = { + layout: 'chart', + group: 'totals', + height: 240, + styles: css` + ${frameStyles} + ${gridStyles} + ${tooltipStyles} + ${layoutStyles} + .style-streamgraph .stream { + cursor: pointer; + stroke: var(--card-background-color, #fff); + stroke-width: 1px; + stroke-linejoin: round; + } + .stream-labels { + position: absolute; + inset: 0; + pointer-events: none; + overflow: hidden; + } + .stream-label { + position: absolute; + transform: translateY(-50%); + font-size: 11px; + font-weight: 500; + line-height: 1; + white-space: nowrap; + color: #fff; + } + .stream-label.dark { + color: rgba(0, 0, 0, 0.8); + } + .stream-label.theme { + color: var(--primary-text-color); + } + .tooltip-row.total { + border-top: 1px solid var(--divider-color, rgba(127, 127, 127, 0.3)); + margin-top: 2px; + padding-top: 2px; + } + `, + thumbnail: () => svg` + + + `, + validate(raw: CardConfig) { + validateHistoryOptions(raw); + validateAxisOptions(raw); + validateTooltipOptions(raw); + validateLayout(raw); + if (raw.baseline !== undefined && !BASELINES.includes(raw.baseline as Baseline)) { + throw new Error(`"baseline" must be one of: ${BASELINES.join(', ')}`); + } + if (raw.stream_order !== undefined && !STREAM_ORDERS.includes(raw.stream_order as StreamOrder)) { + throw new Error(`"stream_order" must be one of: ${STREAM_ORDERS.join(', ')}`); + } + if (raw.labels !== undefined && typeof raw.labels !== 'boolean') throw new Error('"labels" must be true or false'); + }, + editor: streamEditor, + entityFields: () => ENTITY_FIELDS, + disconnect(host) { + disconnectHistory(host); + stopChartWidth(host); + stopHover(host); + }, + render(ctx) { + const now = Date.now(); + const frame = historyFrame(ctx, now); + const nav = periodNav(frame, ctx); + const names = seriesHeader(frame, ctx); + const header = nav === nothing && names === nothing ? nothing : html`${nav}${names}`; + const message = frameMessage(frame, ctx); + const layout = chartLayout(ctx.raw); + if (message) return chartFrame(layout.header === 'over' ? { ...layout, header: 'above' } : layout, header, message); + const kind = baselineOf(ctx.raw); + const share = kind === 'share'; + const floating = kind === 'wiggle' || kind === 'centre'; + const samples = streamSamples(frame, now, plotWidth(ctx)); + const values = samples.values.map(stackable); + const order = orderOf(ctx.raw) === 'listed' ? values.map((_, i) => i) : insideOut(values); + const layers = stack(values, order, kind); + const extent = layersExtent(layers); + // A floating stream has no scale worth labelling, so it fills the plot; a share runs 0 to 100, + // and a stack from zero starts at zero unless the card says otherwise. + let axisRaw: CardConfig = ctx.raw; + if (floating) { + const [lo, hi] = extent ?? [0, 1]; + const pad = hi > lo ? (hi - lo) * 0.04 : 1; + axisRaw = { + ...ctx.raw, + y_axis: false, + y_min: lo - pad, + y_max: hi + pad, + gridlines: { ...((ctx.raw.gridlines as object | undefined) ?? {}), horizontal: false }, + }; + } else if (share) axisRaw = { ...ctx.raw, y_min: 0, y_max: 100 }; + else if (ctx.raw.y_min === undefined) axisRaw = { ...ctx.raw, y_min: 0 }; + const axes = plotAxes({ ...ctx, raw: axisRaw }, frame.range, extent); + const { scales } = axes; + const xs = samples.times.map((t) => scales.x(t)); + const curve = (ctx.raw.curve as Curve | undefined) ?? 'smooth'; + // Carry the edge samples out to the edges of the data, so the streams don't start in mid-air. + const until = Math.min(frame.range.end, now); + const edgeX = [scales.x(frame.range.start), ...xs, scales.x(until)]; + const edged = (l: Layer): Layer => ({ + lower: [l.lower[0], ...l.lower, l.lower[l.lower.length - 1]], + upper: [l.upper[0], ...l.upper, l.upper[l.upper.length - 1]], + }); + const streams = + samples.times.length === 0 + ? svg`` + : svg`${order.map((i) => { + const s = frame.series[i]; + return svg``; + })}`; + const tooltip = tooltipEnabled(ctx.raw); + const label = localize(ctx.hass, 'chart_aria', { + style: localize(ctx.hass, 'editor_style_streamgraph'), + names: frame.series.map((s) => s.entity.name).join(', '), + }); + const hoverPoints = samples.times.map((t) => ({ t, v: 0 })); + return chartFrame( + layout, + header, + chartArea({ + axes, + ctx: { ...ctx, raw: axisRaw }, + content: streams, + label, + overlay: html`${layout.header === 'over' ? headerOver(header) : nothing} + ${ + ctx.raw.labels !== false && samples.times.length + ? streamLabels(frame, layers, xs, scales, ctx, share, samples.values) + : nothing + } + ${tooltip ? streamTooltip(ctx, frame, samples, layers, order, xs, hoverTime(ctx.host), share) : nothing}`, + hover: tooltip ? hoverHandlers(ctx, frame.range, [hoverPoints]) : undefined, + }), + ); + }, +}; diff --git a/src/styles/sunburst.ts b/src/styles/sunburst.ts new file mode 100644 index 0000000..60a8172 --- /dev/null +++ b/src/styles/sunburst.ts @@ -0,0 +1,588 @@ +import { css, html, nothing, svg } from 'lit'; +import type { TemplateResult } from 'lit'; +import { baseColor, onFillText } from '../bands'; +import { SERIES_COLORS, shade } from '../chart/colors'; +import { buildTree, groupPath, parseGroupBy, partition, treeHeight, validateGroups } from '../chart/hierarchy'; +import type { Span, TreeNode } from '../chart/hierarchy'; +import { drillBar, drillStyles, drillTarget, drillView, validateDrill } from '../chart/drill'; +import { sectorPath } from '../chart/polar'; +import { cachedSchema, chartForm, choice } from '../editor/chart-editors'; +import { GROUP_FIELDS, HIERARCHY_LABELS, entityGroupsForm, groupBySchema, withGroupList } from '../editor/group-fields'; +import type { FieldBinding } from '../editor/schemas'; +import { formatNumber, withUnit } from '../format'; +import { localize, registerStrings } from '../localize'; +import type { ResolvedEntity } from '../resolve'; +import type { CardConfig } from '../types'; +import type { ChartContext, ChartEditorContext, ChartStyleModule } from './index'; + +registerStrings('en', { + editor_style_sunburst: 'Sunburst', + editor_style_icicle: 'Icicle', + editor_sunburst_shape: 'Shape', + editor_sunburst_shape_full: 'Full circle', + editor_sunburst_shape_half: 'Half circle', + editor_hierarchy_color_by: 'Colour', + editor_hierarchy_color_by_group: 'One colour per group', + editor_hierarchy_color_by_bands: 'By colour band', + hierarchy_total: 'Total', + hierarchy_other: 'Other', + hierarchy_empty: 'Nothing to show: no entity has a value above zero.', + hierarchy_no_reading: 'No reading:', + sunburst_aria: 'Sunburst of {names}', + icicle_aria: 'Icicle chart of {names}', +}); + +export const SUNBURST_SHAPES = ['full', 'half'] as const; +export type SunburstShape = (typeof SUNBURST_SHAPES)[number]; +export const HIERARCHY_COLOR_BY = ['group', 'bands'] as const; +export type HierarchyColorBy = (typeof HIERARCHY_COLOR_BY)[number]; + +/** Drawing height in px; the sunburst's radius and hole, in SVG units (≈ px). */ +const HEIGHT = 240; +const RADIUS = 112; +const HOLE = 40; + +/** How strong each level's colour is: the first ring full, deeper ones fading. */ +export function levelStrength(level: number): number { + return Math.max(45, 100 - (level - 1) * 20); +} + +/** The tree the card's entities make, with groups from `group_by` and their own `group`. */ +export function hierarchyTree(ctx: ChartContext): TreeNode { + const levels = parseGroupBy(ctx.raw.group_by); + const other = localize(ctx.hass, 'hierarchy_other'); + const paths = ctx.items.map((e) => groupPath(e, levels, ctx.hass, other)); + return buildTree(ctx.items, paths, localize(ctx.hass, 'hierarchy_total')); +} + +/** The unit every item with a value shares, or undefined when they differ. */ +export function sharedUnit(items: ResolvedEntity[]): string | undefined { + const units = new Set(items.filter((e) => e.available).map((e) => e.unit)); + return units.size === 1 ? [...units][0] : undefined; +} + +export interface Painted { + /** A colour ready for CSS. */ + fill: string; + /** Text on top: from the colour as given, or the theme's own when the fill is faint. */ + text: 'light' | 'dark' | 'theme'; +} + +/** + * Each node's colour. A top-level node takes the next series colour (an entity's own `color` + * wins) and passes it down, fading with depth; by bands, entities take their band's colour and + * groups a neutral grey. + */ +export function paintSpans(spans: Span[], colorBy: HierarchyColorBy): Map { + const painted = new Map(); + const family = new Map(); + let next = 0; + for (const { node, level } of spans) { + if (level === 0) continue; + const own = node.entity ? (node.entity.config as { color?: unknown }).color : undefined; + let name: string; + if (level === 1) name = typeof own === 'string' && own ? own : SERIES_COLORS[next++ % SERIES_COLORS.length]; + else name = family.get(node) ?? 'grey'; + for (const child of node.children) family.set(child, name); + if (colorBy === 'bands') { + painted.set( + node, + node.entity + ? { fill: baseColor(node.entity.band.color), text: onFillText(node.entity.band.color) } + : { fill: 'var(--cd-track)', text: 'theme' }, + ); + continue; + } + const strength = levelStrength(level); + painted.set(node, { + fill: shade(baseColor(name), strength), + text: strength >= 100 ? onFillText(name) : 'theme', + }); + } + return painted; +} + +function totalText(value: number, ctx: ChartContext, unit: string): string { + const decimals = ctx.items.find((e) => e.available)?.decimals ?? 1; + return withUnit(formatNumber(value, decimals, ctx.hass), unit); +} + +/** A node's text for screen readers and tooltips: "Kitchen, 1.8 kW" or the entity's own. */ +function nodeLabel(node: TreeNode, ctx: ChartContext, unit: string | undefined): string { + if (node.entity) return node.entity.label; + return unit === undefined ? node.name : `${node.name}, ${totalText(node.value, ctx, unit)}`; +} + +/** Entities with no number, listed under the chart so they can still be tapped. */ +function noReading(ctx: ChartContext): TemplateResult | typeof nothing { + const list = ctx.items.map((e, index) => ({ e, index })).filter(({ e }) => !e.available); + if (!list.length) return nothing; + return html`
+ ${localize(ctx.hass, 'hierarchy_no_reading')} + ${list.map( + ({ e, index }) => + html`${e.name} (${e.display})`, + )} +
`; +} + +const FIELDS: FieldBinding[] = [ + { name: 'hierarchy_color_by', path: ['color_by'], default: 'group' }, + ...GROUP_FIELDS, + { name: 'drill_down', path: ['drill_down'], default: true }, +]; +const SUNBURST_FIELDS: FieldBinding[] = [{ name: 'sunburst_shape', path: ['shape'], default: 'full' }, ...FIELDS]; + +export function sunburstEditor(ctx: ChartEditorContext): TemplateResult { + const schema = cachedSchema('sunburst', ctx.hass, () => [ + { + type: 'grid', + name: '', + schema: [ + { name: 'sunburst_shape', selector: choice(ctx.hass, 'sunburst_shape', SUNBURST_SHAPES) }, + { name: 'hierarchy_color_by', selector: choice(ctx.hass, 'hierarchy_color_by', HIERARCHY_COLOR_BY) }, + ], + }, + groupBySchema(ctx.hass), + { name: 'drill_down', selector: { boolean: {} } }, + ]); + return html`${chartForm(withGroupList(ctx), 'chart', schema, SUNBURST_FIELDS, HIERARCHY_LABELS)} + ${entityGroupsForm(ctx)}`; +} + +export function icicleEditor(ctx: ChartEditorContext): TemplateResult { + const schema = cachedSchema('icicle', ctx.hass, () => [ + { name: 'hierarchy_color_by', selector: choice(ctx.hass, 'hierarchy_color_by', HIERARCHY_COLOR_BY) }, + groupBySchema(ctx.hass), + { name: 'drill_down', selector: { boolean: {} } }, + ]); + return html`${chartForm(withGroupList(ctx), 'chart', schema, FIELDS, HIERARCHY_LABELS)} ${entityGroupsForm(ctx)}`; +} + +function validate(raw: CardConfig, shapes: boolean): void { + validateGroups(raw); + validateDrill(raw); + if (raw.color_by !== undefined && !HIERARCHY_COLOR_BY.includes(raw.color_by as HierarchyColorBy)) { + throw new Error(`"color_by" must be one of: ${HIERARCHY_COLOR_BY.join(', ')}`); + } + if (shapes && raw.shape !== undefined && !SUNBURST_SHAPES.includes(raw.shape as SunburstShape)) { + throw new Error(`"shape" must be one of: ${SUNBURST_SHAPES.join(', ')}`); + } +} + +/** The pieces of one sunburst: where each ring starts and how far round it may go. */ +export function sunburstGeometry(shape: SunburstShape, levels: number) { + const ring = (RADIUS - HOLE) / Math.max(1, levels); + return { + start: shape === 'half' ? -Math.PI / 2 : 0, + sweep: shape === 'half' ? Math.PI : Math.PI * 2, + radii: (level: number): [number, number] => [HOLE + (level - 1) * ring, HOLE + level * ring], + viewBox: + shape === 'half' + ? `${-RADIUS - 4} ${-RADIUS - 4} ${RADIUS * 2 + 8} ${RADIUS + 12}` + : `${-RADIUS - 4} ${-RADIUS - 4} ${RADIUS * 2 + 8} ${RADIUS * 2 + 8}`, + }; +} + +/** A group's ring segment: tappable to drill into it when drilling is on. */ +function groupArc(ctx: ChartContext, node: TreeNode, label: string, d: string, fill: string) { + const target = drillTarget(ctx, node, label); + return svg`${label}`; +} + +/** The drawing for one tree root: a sunburst with the total in its middle, or a message. */ +export function drawSunburst(ctx: ChartContext, root: TreeNode, tree: TreeNode = root) { + if (root.value <= 0) return html`
${localize(ctx.hass, 'hierarchy_empty')}
`; + const shape = (ctx.raw.shape as SunburstShape | undefined) ?? 'full'; + const colorBy = (ctx.raw.color_by as HierarchyColorBy | undefined) ?? 'group'; + const unit = sharedUnit(ctx.items); + const spans = partition(root); + // Colours come from the whole tree, so a group keeps its colour when drilled into. + const painted = paintSpans(partition(tree), colorBy); + const geo = sunburstGeometry(shape, treeHeight(root)); + const arcs = spans + .filter((s) => s.level > 0) + .map((s) => { + const [r0, r1] = geo.radii(s.level); + const d = sectorPath(r0, r1, geo.start + s.x0 * geo.sweep, geo.start + s.x1 * geo.sweep); + const label = nodeLabel(s.node, ctx, unit); + const fill = painted.get(s.node)!.fill; + return s.node.entity + ? svg`${label}` + : groupArc(ctx, s.node, label, d, fill); + }); + const total = unit !== undefined && ctx.config.show.value ? totalText(root.value, ctx, unit) : ''; + const names = root.children.filter((c) => c.value > 0).map((c) => c.name); + return html`
+ + ${arcs} + +
${total ? html`${total}` : nothing}
+
`; +} + +/** Top-level names and totals under a sunburst, so the colours mean something. */ +export function sunburstLegend( + ctx: ChartContext, + root: TreeNode, + tree: TreeNode = root, +): TemplateResult | typeof nothing { + const colorBy = (ctx.raw.color_by as HierarchyColorBy | undefined) ?? 'group'; + const unit = sharedUnit(ctx.items); + const painted = paintSpans(partition(tree), colorBy); + const top = root.children.filter((c) => c.value > 0); + return html`
+ ${top.map((node) => { + const swatch = html``; + const value = + ctx.config.show.value && unit !== undefined + ? html`${totalText(node.value, ctx, unit)}` + : nothing; + return node.entity + ? html`${swatch}${node.name} ${value}` + : legendGroup(ctx, node, swatch, value, unit); + })} +
`; +} + +/** A group in the legend: a button that drills into it, or plain text when drilling is off. */ +function legendGroup( + ctx: ChartContext, + node: TreeNode, + swatch: TemplateResult, + value: TemplateResult | typeof nothing, + unit: string | undefined, +) { + const target = drillTarget(ctx, node, nodeLabel(node, ctx, unit)); + return html`${swatch}${node.name} ${value}`; +} + +/** The drawing for one tree root as an icicle: a column per level, the root on the left. */ +export function drawIcicle(ctx: ChartContext, root: TreeNode, tree: TreeNode = root) { + if (root.value <= 0) return html`
${localize(ctx.hass, 'hierarchy_empty')}
`; + const colorBy = (ctx.raw.color_by as HierarchyColorBy | undefined) ?? 'group'; + const unit = sharedUnit(ctx.items); + const spans = partition(root); + const painted = paintSpans(partition(tree), colorBy); + const columns = treeHeight(root) + 1; + const { show } = ctx.config; + const names = root.children.filter((c) => c.value > 0).map((c) => c.name); + return html``; +} + +/** A group's cell: tappable to drill into it (not the left-hand total, which is where you are). */ +function icicleGroup( + ctx: ChartContext, + node: TreeNode, + cls: string, + label: string, + style: string, + inner: TemplateResult, +) { + const target = cls.includes('text-root') ? undefined : drillTarget(ctx, node, label); + return html`
+ ${inner} +
`; +} + +const sharedStyles = css` + ${drillStyles} + .hierarchy-message { + display: flex; + align-items: center; + justify-content: center; + height: ${HEIGHT}px; + color: var(--secondary-text-color); + font-size: 0.875em; + text-align: center; + } + .hierarchy-missing, + .hierarchy-legend { + display: flex; + flex-wrap: wrap; + gap: 4px 14px; + padding-top: 8px; + font-size: 0.875em; + color: var(--secondary-text-color); + } + .hierarchy-legend { + justify-content: center; + } + .hierarchy-legend .legend-item { + display: inline-flex; + align-items: center; + gap: 6px; + } + .hierarchy-legend .legend-value { + color: var(--primary-text-color); + font-weight: 500; + } + .hierarchy-legend .swatch { + width: 10px; + height: 10px; + border-radius: 50%; + } + .hierarchy-missing .entity, + .hierarchy-legend .entity { + cursor: pointer; + border-radius: 4px; + } + .hierarchy-missing .entity:focus-visible, + .hierarchy-legend .entity:focus-visible { + outline: 2px solid var(--primary-color); + } +`; + +export const sunburst: ChartStyleModule = { + layout: 'chart', + group: 'totals', + height: 290, + styles: css` + ${sharedStyles} + .sunburst-wrap { + position: relative; + height: ${HEIGHT}px; + } + .sunburst-wrap.half { + height: ${HEIGHT * 0.6}px; + } + .sunburst { + display: block; + width: 100%; + height: 100%; + overflow: visible; + } + .sunburst .arc { + stroke: var(--card-background-color, #fff); + stroke-width: 1.5; + stroke-linejoin: round; + } + .sunburst .arc.leaf { + cursor: pointer; + outline: none; + } + .sunburst .arc.leaf:focus-visible { + stroke: var(--primary-text-color); + stroke-width: 2.5; + } + .sunburst-grow { + transform: scale(0.6) rotate(-30deg); + opacity: 0; + transition: + transform var(--cd-duration) var(--cd-easing), + opacity var(--cd-duration) var(--cd-easing); + } + :host([ready]) .sunburst-grow { + transform: none; + opacity: 1; + } + :host([static]) .sunburst-grow { + transition: none; + } + .sunburst-centre { + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + display: flex; + flex-direction: column; + align-items: center; + max-width: 72px; + text-align: center; + line-height: 1.2; + pointer-events: none; + } + .sunburst-wrap.half .sunburst-centre { + top: auto; + bottom: 4px; + transform: translateX(-50%); + } + .sunburst-centre > * { + pointer-events: auto; + } + .centre-value { + font-size: 1.15em; + font-weight: 500; + /* A long total ("339.4 kWh") wraps its unit under the number rather than over the rings. */ + overflow-wrap: normal; + } + `, + thumbnail: () => svg` + + + + + + `, + validate: (raw) => validate(raw, true), + editor: sunburstEditor, + render(ctx) { + const tree = hierarchyTree(ctx); + const view = drillView(ctx, tree); + return html`${drillBar(ctx, tree, view, '')} ${drawSunburst(ctx, view.node, tree)} + ${view.node.value > 0 ? sunburstLegend(ctx, view.node, tree) : nothing} ${noReading(ctx)}`; + }, +}; + +export const icicle: ChartStyleModule = { + layout: 'chart', + group: 'totals', + height: 280, + styles: css` + ${sharedStyles} + .icicle { + position: relative; + height: ${HEIGHT}px; + } + .icicle .cell { + position: absolute; + box-sizing: border-box; + padding: 1.5px; + } + .icicle .cell-fill { + box-sizing: border-box; + width: 100%; + height: 100%; + border-radius: 5px; + padding: 3px 8px; + overflow: hidden; + display: flex; + flex-direction: column; + line-height: 1.25; + opacity: 0; + transform: translateX(-8px); + transition: + opacity var(--cd-duration) var(--cd-easing), + transform var(--cd-duration) var(--cd-easing); + } + :host([ready]) .icicle .cell-fill { + opacity: 1; + transform: none; + } + :host([static]) .icicle .cell-fill { + transition: none; + } + .icicle .text-root .cell-fill { + background: color-mix(in srgb, var(--primary-text-color, #212121) 78%, var(--card-background-color, #fff)); + color: var(--card-background-color, #fff); + } + .icicle .text-light { + color: #fff; + } + .icicle .text-dark { + color: rgba(0, 0, 0, 0.87); + } + .icicle .text-theme { + color: var(--primary-text-color); + } + .icicle .cell-name, + .icicle .cell-value { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .icicle .cell-name { + font-size: 13px; + font-weight: 500; + } + .icicle .cell-value { + font-size: 12px; + opacity: 0.9; + } + .icicle .leaf { + cursor: pointer; + outline: none; + } + .icicle .leaf:focus-visible .cell-fill { + box-shadow: inset 0 0 0 2px var(--primary-text-color); + } + `, + thumbnail: () => svg` + + + + + + `, + validate: (raw) => validate(raw, false), + editor: icicleEditor, + render(ctx) { + const tree = hierarchyTree(ctx); + const view = drillView(ctx, tree); + return html`${drillBar(ctx, tree, view, '')} ${drawIcicle(ctx, view.node, tree)} ${noReading(ctx)}`; + }, +}; diff --git a/src/styles/timeline.ts b/src/styles/timeline.ts new file mode 100644 index 0000000..4f86cad --- /dev/null +++ b/src/styles/timeline.ts @@ -0,0 +1,572 @@ +import { css, html, nothing, svg } from 'lit'; +import type { TemplateResult } from 'lit'; +import { formatDateTime, timeLabel, timeTicks, timeZoneOf, zoneOffset } from '../chart/axes'; +import { disconnectHistory, frameMessage, frameStyles, historyFrame, periodNav } from '../chart/frame'; +import type { ChartSeries, HistoryFrame } from '../chart/frame'; +import { chartWidth, gridStyles, stopChartWidth } from '../chart/grid'; +import { chartLayout, layoutStyles, validateLayout } from '../chart/layout'; +import { linear } from '../chart/scale'; +import { + formatDuration, + isGap, + isMeasurement, + labelWidth, + spanAt, + spanBox, + stateColor, + stateSpans, + timelineRows, +} from '../chart/timeline'; +import type { StateColor, StateSpan, TimelineRow } from '../chart/timeline'; +import { + hoverHandlers, + hoverTime, + stopHover, + tooltipEnabled, + tooltipSide, + tooltipStyles, + validateTooltipOptions, +} from '../chart/tooltip'; +import { timelineEditor, timelineEntityFields } from '../editor/timeline-editor'; +import { parsePeriod } from '../history/period'; +import { localize, registerStrings } from '../localize'; +import type { HassEntity, CardConfig, HomeAssistant } from '../types'; +import type { ChartContext, ChartStyleModule } from './index'; + +registerStrings('en', { + editor_style_timeline: 'State timeline', + timeline_no_states: 'No states recorded in this period.', + timeline_for: 'for {duration}', + timeline_now: 'Now', + timeline_measurement: 'Readings, not states: a line chart suits this one', +}); + +/** What each bar says, if it has room. */ +export const BAR_LABELS = ['state', 'duration', 'both', 'none'] as const; +export type BarLabels = (typeof BAR_LABELS)[number]; + +/** Row heights in px: an entity's row, and a group's heading (normal, compact). */ +const ROW_PX = { normal: 30, compact: 22 }; +const HEADING_PX = { normal: 24, compact: 18 }; +/** The names column takes at most this share of the card. */ +const NAMES_SHARE = 0.35; +/** Evenly spaced times the hover guide can rest on, besides the state changes. */ +const HOVER_STEPS = 240; +/** Room each time label needs, in px. */ +const TIME_LABEL_PX = 70; + +type Own = { group?: unknown; state_colors?: unknown }; + +const groupOf = (s: ChartSeries): string | undefined => { + const g = (s.entity.config as Own).group; + return typeof g === 'string' && g.trim() !== '' ? g.trim() : undefined; +}; + +/** Throw a readable error for a bad option. */ +function validate(raw: CardConfig): void { + parsePeriod(raw.period as CardConfig['period']); + if (raw.source !== undefined && raw.source !== 'history') { + throw new Error('"source" must be history: a state timeline reads states, which statistics don\'t keep'); + } + if (raw.group_by !== undefined) throw new Error('"group_by" does not apply to a state timeline'); + if (raw.bar_labels !== undefined && !BAR_LABELS.includes(raw.bar_labels as BarLabels)) { + throw new Error(`"bar_labels" must be one of: ${BAR_LABELS.join(', ')}`); + } + if (raw.x_axis !== undefined && typeof raw.x_axis !== 'boolean') throw new Error('"x_axis" must be true or false'); + const grid = raw.gridlines as Record | undefined; + if (grid !== undefined && (typeof grid !== 'object' || grid === null || Array.isArray(grid))) { + throw new Error('"gridlines" must have "vertical" (true or false)'); + } + if (grid?.vertical !== undefined && typeof grid.vertical !== 'boolean') { + throw new Error('"gridlines.vertical" must be true or false'); + } + checkColors(raw.state_colors, '"state_colors"'); + for (const e of raw.entities ?? []) { + if (typeof e !== 'object') continue; + const own = e as Own; + if (own.group !== undefined && typeof own.group !== 'string') + throw new Error(`"group" of ${e.entity} must be text`); + } + validateTooltipOptions(raw); + validateLayout(raw); +} + +function checkColors(v: unknown, where: string): void { + if (v === undefined) return; + if (typeof v !== 'object' || v === null || Array.isArray(v)) { + throw new Error(`${where} must map states to colours, like "on: amber"`); + } + for (const [state, color] of Object.entries(v)) { + if (typeof color !== 'string' || color.trim() === '') + throw new Error(`${where}: the colour for "${state}" must be text`); + } +} + +/** The card's state colours: its own `state_colors`, plus the editor's list form. */ +export function ownColors(raw: CardConfig): Record | undefined { + const v = raw.state_colors; + return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record) : undefined; +} + +/** A state as Home Assistant words it ("Heat", "Away"), or as recorded when it can't say. */ +function stateText(hass: HomeAssistant, s: ChartSeries, state: string): string { + const stateObj: HassEntity = s.entity.stateObj ?? { entity_id: s.entity.entityId, state, attributes: {} }; + const attribute = s.entity.config.attribute; + try { + if (attribute) return hass.formatEntityAttributeValue?.(stateObj, attribute, state) ?? state; + return hass.formatEntityState?.({ ...stateObj, state }, state) ?? state; + } catch { + return state; + } +} + +interface Row { + series: ChartSeries; + spans: StateSpan[]; + domain: string; + /** All numbers, too many to be states: shown as a hint, not bars. */ + measurement: boolean; +} + +function colorOf(row: Row, state: string, raw: CardConfig): StateColor { + return stateColor(state, row.domain, ownColors(raw)); +} + +/** The states drawn in the period, once each, in the order they first appear, for the legend. */ +function legend(rows: Row[], ctx: ChartContext): TemplateResult | typeof nothing { + // One key per word as shown, first colour wins; quiet states ("Off", "Idle") go last. + const seen = new Map(); + for (const row of rows) { + if (row.measurement) continue; + for (const span of row.spans) { + if (isGap(span.state)) continue; + const label = stateText(ctx.hass, row.series, span.state as string); + if (!seen.has(label)) seen.set(label, { label, color: colorOf(row, span.state as string, ctx.raw) }); + } + } + if (seen.size === 0) return nothing; + const keys = [...seen.values()]; + return html`
+ ${[...keys.filter((k) => !k.color.quiet), ...keys.filter((k) => k.color.quiet)].map( + ({ label, color }) => + html`${label}`, + )} +
`; +} + +function barLabel(mode: BarLabels, text: string, span: StateSpan): string { + const duration = formatDuration(span.end - span.start); + if (mode === 'state') return text; + if (mode === 'duration') return duration; + if (mode === 'both') return `${text} · ${duration}`; + return ''; +} + +const pct = (n: number) => `${Math.round(n * 10000) / 100}%`; + +function bars(row: Row, frame: HistoryFrame, ctx: ChartContext, plotPx: number) { + const mode = (ctx.raw.bar_labels as BarLabels | undefined) ?? 'state'; + return row.spans.map((span) => { + if (isGap(span.state)) return nothing; + const { left, width } = spanBox(span, frame.range); + if (width <= 0) return nothing; + const state = span.state as string; + const color = colorOf(row, state, ctx.raw); + const text = barLabel(mode, stateText(ctx.hass, row.series, state), span); + const fits = text !== '' && labelWidth(text) <= width * plotPx; + return html`
+ ${fits ? html`${text}` : nothing} +
`; + }); +} + +/** The guide line and a box naming each row's state at the hovered time, and for how long. */ +function hoverLayer(rows: Row[], frame: HistoryFrame, ctx: ChartContext, plotPx: number) { + const t = hoverTime(ctx.host); + if (t === undefined) return nothing; + const x = (t - frame.range.start) / (frame.range.end - frame.range.start); + if (!(x >= 0 && x <= 1)) return nothing; + const side = tooltipSide(x, plotPx); + return html``; +} + +function timeAxis(frame: HistoryFrame, ctx: ChartContext, plotPx: number) { + const count = Math.max(2, Math.min(8, Math.round(plotPx / TIME_LABEL_PX))); + const offset = zoneOffset(timeZoneOf(ctx.hass)); + const { unit, ticks } = timeTicks(frame.range, count, offset); + const x = linear([frame.range.start, frame.range.end], [0, 1]); + return { unit, ticks, x, offset }; +} + +export const timeline: ChartStyleModule = { + layout: 'chart', + group: 'history', + height: 200, + styles: css` + ${frameStyles} + ${gridStyles} + ${tooltipStyles} + ${layoutStyles} + .tl-legend { + display: flex; + flex-wrap: wrap; + gap: 4px 14px; + padding: 0 0 8px; + font-size: 0.875em; + color: var(--secondary-text-color); + } + .tl-key { + display: inline-flex; + align-items: center; + gap: 6px; + white-space: nowrap; + } + .swatch.quiet { + opacity: 0.45; + } + .tl-wrap { + display: grid; + grid-template-columns: fit-content(35%) minmax(0, 1fr); + grid-template-rows: auto auto; + } + .tl-wrap.no-names { + grid-template-columns: 0 minmax(0, 1fr); + } + .tl-names { + grid-column: 1; + grid-row: 1; + display: flex; + flex-direction: column; + min-width: 0; + padding-right: 10px; + } + .tl-name, + .tl-heading { + display: flex; + align-items: center; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + height: var(--tl-row); + } + .tl-name { + font-size: 0.875em; + cursor: pointer; + border-radius: 4px; + } + .tl-name span { + overflow: hidden; + text-overflow: ellipsis; + } + .tl-name:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: -2px; + } + .tl-heading { + height: var(--tl-heading); + font-size: 0.75em; + font-weight: 500; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--secondary-text-color); + } + .tl-plot { + position: relative; + grid-column: 2; + grid-row: 1; + display: flex; + flex-direction: column; + min-width: 0; + } + .tl-gap { + height: var(--tl-heading); + position: relative; + } + .tl-gap::after { + content: ''; + position: absolute; + left: 0; + right: 0; + top: 50%; + border-top: 1px solid var(--divider-color, rgba(127, 127, 127, 0.25)); + } + .tl-row { + position: relative; + height: var(--tl-row); + cursor: pointer; + } + .tl-track, + .tl-bar { + position: absolute; + top: 4px; + bottom: 4px; + border-radius: 6px; + } + .tl-track { + left: 0; + right: 0; + background: color-mix(in srgb, var(--primary-text-color) 5%, transparent); + } + .tl-bars { + position: absolute; + inset: 0; + clip-path: inset(0 100% 0 0); + transition: clip-path var(--cd-duration) var(--cd-easing); + } + :host([ready]) .tl-bars { + clip-path: inset(0 0 0 0); + } + .tl-bar { + min-width: 2px; + background: var(--tl-color); + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + box-sizing: border-box; + } + .tl-bar.quiet { + background: color-mix(in srgb, var(--tl-color) 30%, transparent); + } + .tl-label { + font-size: 11px; + font-weight: 500; + line-height: 1; + padding: 0 5px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .tl-bar.text-dark .tl-label { + color: #1c1c1c; + } + .tl-bar.text-light .tl-label { + color: #fff; + } + .tl-bar.text-theme .tl-label { + color: var(--primary-text-color); + } + .tl-grid { + position: absolute; + top: 0; + bottom: 0; + border-left: 1px dashed var(--divider-color, rgba(127, 127, 127, 0.25)); + pointer-events: none; + } + .tl-now { + position: absolute; + top: 0; + bottom: 0; + border-left: 1.5px dashed var(--secondary-text-color); + opacity: 0.7; + pointer-events: none; + } + .tl-hint { + position: absolute; + inset: 0; + display: flex; + align-items: center; + padding: 0 8px; + font-size: 0.75em; + color: var(--secondary-text-color); + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + .tl-for { + font-weight: 400; + color: var(--secondary-text-color); + } + .tl-wrap .x-axis { + position: relative; + grid-column: 2; + grid-row: 2; + height: 18px; + } + .tl-wrap .x-axis .axis-label { + top: 5px; + } + .frame.compact .tl-legend { + padding-bottom: 4px; + font-size: 0.8em; + } + .frame.compact .tl-wrap .x-axis { + height: 14px; + } + .frame.compact .tl-wrap .x-axis .axis-label { + top: 3px; + } + .frame.compact .tl-track, + .frame.compact .tl-bar { + top: 3px; + bottom: 3px; + border-radius: 4px; + } + `, + thumbnail: () => svg` + + + + + + + + + `, + validate, + editor: timelineEditor, + entityFields: timelineEntityFields, + disconnect(host) { + disconnectHistory(host); + stopChartWidth(host); + stopHover(host); + }, + render(ctx) { + const now = Date.now(); + const frame = historyFrame(ctx, now, { text: true }); + const layout = chartLayout(ctx.raw); + const compact = layout.compact ? 'compact' : 'normal'; + const nav = periodNav(frame, ctx); + const until = Math.min(frame.range.end, now); + const rows: Row[] = frame.series.map((series) => ({ + series, + spans: stateSpans(series.points, until), + domain: series.entity.entityId.split('.')[0], + measurement: false, + })); + for (const row of rows) row.measurement = isMeasurement(row.spans); + const showLegend = layout.header !== 'hidden'; + const frameBox = (header: unknown, body: TemplateResult) => + html`
${header}${body}
`; + const message = + frame.status !== 'ready' + ? frameMessage(frame, ctx) + : rows.every((r) => r.spans.every((s) => isGap(s.state))) + ? html`
${localize(ctx.hass, 'timeline_no_states')}
` + : undefined; + if (message) return frameBox(nav, message); + + const showNames = ctx.config.show.name; + const width = chartWidth(ctx); + const longest = Math.max(0, ...rows.map((r) => r.series.entity.name.length)); + const namesPx = showNames ? Math.min(width * NAMES_SHARE, longest * 7 + 10) : 0; + const plotPx = Math.max(40, width - 32 - namesPx); + const axis = timeAxis(frame, ctx, plotPx); + const xAxis = ctx.raw.x_axis !== false; + const vertical = (ctx.raw.gridlines as { vertical?: boolean } | undefined)?.vertical ?? true; + const tooltip = tooltipEnabled(ctx.raw); + const byIndex = new Map(rows.map((r) => [r.series.index, r])); + const ordered: TimelineRow[] = timelineRows( + frame.series.map((s) => byIndex.get(s.index) as Row), + (r) => groupOf(r.series), + ); + const span = frame.range.end - frame.range.start; + const showNow = frame.live && frame.range.end > now && now > frame.range.start; + const label = localize(ctx.hass, 'chart_aria', { + style: localize(ctx.hass, 'editor_style_timeline'), + names: rows.map((r) => r.series.entity.name).join(', '), + }); + const hover = tooltip + ? hoverHandlers(ctx, frame.range, [ + Array.from({ length: HOVER_STEPS + 1 }, (_, i) => ({ + t: frame.range.start + (span * i) / HOVER_STEPS, + v: null, + })), + ...rows.map((r) => r.spans.map((s) => ({ t: s.start, v: null }))), + ]) + : undefined; + const vars = `--tl-row:${ROW_PX[compact]}px;--tl-heading:${HEADING_PX[compact]}px`; + return frameBox( + html`${nav}${showLegend ? legend(rows, ctx) : nothing}`, + html`
+ ${ + showNames + ? html`
+ ${ordered.map((r) => + 'heading' in r + ? html`
${r.heading}
` + : html`
+ ${r.item.series.entity.name} +
`, + )} +
` + : nothing + } + + ${ + xAxis + ? html`
+ ${axis.ticks.map((t) => { + const left = axis.x(t); + const edge = left < 0.05 ? 'start' : left > 0.95 ? 'end' : ''; + return html`${timeLabel(t, axis.unit, span, ctx.hass, axis.offset)}`; + })} +
` + : nothing + } +
`, + ); + }, +}; diff --git a/src/styles/treemap.ts b/src/styles/treemap.ts new file mode 100644 index 0000000..f5fc519 --- /dev/null +++ b/src/styles/treemap.ts @@ -0,0 +1,407 @@ +import { css, html, nothing, svg } from 'lit'; +import type { TemplateResult } from 'lit'; +import { baseColor, onFillText } from '../bands'; +import { SERIES_COLORS, shade } from '../chart/colors'; +import { buildTree, groupPath, parseGroupBy, validateGroups } from '../chart/hierarchy'; +import type { TreeNode } from '../chart/hierarchy'; +import { drillBar, drillStyles, drillTarget, drillView, validateDrill } from '../chart/drill'; +import { inset, squarify } from '../chart/treemap-layout'; +import type { Rect } from '../chart/treemap-layout'; +import { cachedSchema, chartForm, choice } from '../editor/chart-editors'; +import { GROUP_FIELDS, HIERARCHY_LABELS, entityGroupsForm, groupBySchema, withGroupList } from '../editor/group-fields'; +import type { FieldBinding } from '../editor/schemas'; +import { formatNumber, withUnit } from '../format'; +import { localize, registerStrings } from '../localize'; +import type { ResolvedEntity } from '../resolve'; +import type { CardConfig } from '../types'; +import type { ChartContext, ChartEditorContext, ChartStyleModule } from './index'; + +registerStrings('en', { + editor_style_treemap: 'Treemap', + editor_treemap_color_by: 'Colour', + editor_treemap_color_by_group: 'One colour per group', + editor_treemap_color_by_bands: 'By colour band', + treemap_aria: 'Treemap of {names}', + treemap_total: '{value} in all', + treemap_empty: 'Nothing to show: no entity has a value above zero.', + treemap_no_reading: 'No reading:', + treemap_other: 'Other', +}); + +export const TREEMAP_COLOR_BY = ['group', 'bands'] as const; +export type TreemapColorBy = (typeof TREEMAP_COLOR_BY)[number]; + +/** The drawing's height in px; its width follows the card. */ +const HEIGHT = 240; +/** Width to lay out for until the card has been measured. */ +const DEFAULT_WIDTH = 400; +/** A group's name strip, and the padding round its contents, in px. */ +const HEAD = 22; +const PAD = 3; +/** Half the gap between neighbouring tiles. */ +const GAP = 1.5; + +export interface TreemapBox { + node: TreeNode; + rect: Rect; + /** Colour key: the top-level group (or entity) it belongs to, as a series index. */ + series: number; + /** Groups only: whether there was room for the name strip. */ + head?: boolean; + /** Leaves only: its place among its group's leaves, largest first. */ + rank?: number; +} + +/** + * Lay out a tree in a `width` × `height` box: each group gets a rectangle sized by its total + * (with a name strip on top when there is room), and its entities share what is left of it. + * Nodes with nothing to show are left out. Each top-level node takes the next series colour, or + * all of them `family` (drilled into a group, its parts keep its colour). + */ +export function treemapLayout( + root: TreeNode, + width: number, + height: number, + family?: number, +): { groups: TreemapBox[]; leaves: TreemapBox[] } { + const groups: TreemapBox[] = []; + const leaves: TreemapBox[] = []; + const place = (node: TreeNode, rect: Rect, series: number, top: boolean) => { + const shown = node.children.filter((c) => c.value > 0); + const rects = squarify( + shown.map((c) => c.value), + rect, + ); + shown.forEach((child, i) => { + const r = rects[i]; + const s = top && family === undefined ? i : series; + if (child.entity) { + leaves.push({ node: child, rect: inset(r, GAP), series: s, rank: i }); + return; + } + const box = inset(r, GAP); + const head = box.h >= HEAD * 2 && box.w >= 56; + groups.push({ node: child, rect: box, series: s, head }); + const inner = inset(head ? { ...box, y: box.y + HEAD - PAD, h: box.h - HEAD + PAD } : box, PAD); + place(child, inner, s, false); + }); + }; + place(root, { x: 0, y: 0, w: width, h: height }, family ?? 0, true); + return { groups, leaves }; +} + +/** The unit every item with a value shares, or undefined when they differ (a total would mean nothing). */ +export function sharedUnit(items: ResolvedEntity[]): string | undefined { + const units = new Set(items.filter((e) => e.available).map((e) => e.unit)); + return units.size === 1 ? [...units][0] : undefined; +} + +/** A total in the unit the items share, with the decimals of the first one that has a value. */ +export function totalText(value: number, ctx: ChartContext, unit: string): string { + const decimals = ctx.items.find((e) => e.available)?.decimals ?? 1; + return withUnit(formatNumber(value, decimals, ctx.hass), unit); +} + +/** Each card's last measured drawing width, and the observers that keep it current. */ +const widths = new WeakMap(); +const observers = new WeakMap(); + +/** The width to lay out for, watching the card so a resize lays it out again. */ +function measure(ctx: ChartContext): number { + const host = ctx.host; + if (!observers.has(host) && typeof ResizeObserver === 'function') { + const observer = new ResizeObserver(() => { + const el = host.shadowRoot?.querySelector('.treemap'); + const w = el?.clientWidth ?? 0; + if (w > 0 && Math.abs(w - (widths.get(host) ?? 0)) >= 1) { + widths.set(host, w); + ctx.requestUpdate(); + } + }); + observer.observe(host); + observers.set(host, observer); + } + return widths.get(host) ?? DEFAULT_WIDTH; +} + +function forget(host: HTMLElement): void { + observers.get(host)?.disconnect(); + observers.delete(host); +} + +/** A colour name as given (palette name or CSS), for picking readable text on top. */ +function leafColor(box: TreemapBox, colorBy: TreemapColorBy): string { + const e = box.node.entity!; + if (colorBy === 'bands') return e.band.color; + const own = (e.config as { color?: unknown }).color; + return typeof own === 'string' && own ? own : SERIES_COLORS[box.series % SERIES_COLORS.length]; +} + +const pct = (v: number, of: number) => `${(v / of) * 100}%`; + +function position(rect: Rect, width: number): string { + return `left:${pct(rect.x, width)};top:${pct(rect.y, HEIGHT)};width:${pct(rect.w, width)};height:${pct(rect.h, HEIGHT)}`; +} + +function leaf( + box: TreemapBox, + ctx: ChartContext, + width: number, + colorBy: TreemapColorBy, + total: number, +): TemplateResult { + const e = box.node.entity!; + const name = leafColor(box, colorBy); + // Later tiles in a group fade a little, so neighbours read apart; never so far that the + // text colour chosen for the full colour stops being readable. + const fill = + colorBy === 'group' && box.node.path.length + ? shade(baseColor(name), Math.max(70, 100 - 12 * (box.rank ?? 0))) + : baseColor(name); + const text = onFillText(name); + const { w, h } = box.rect; + const { show } = ctx.config; + const big = w >= 120 && h >= 70 && e.value !== null && e.value >= total * 0.25; + const labels = h >= 20 && w >= 44; + const value = show.value && h >= 36; + return html``; +} + +function group(box: TreemapBox, ctx: ChartContext, width: number, colorBy: TreemapColorBy, unit: string | undefined) { + const color = baseColor(SERIES_COLORS[box.series % SERIES_COLORS.length]); + const total = unit === undefined ? '' : totalText(box.node.value, ctx, unit); + const target = drillTarget(ctx, box.node, total ? `${box.node.name}, ${total}` : box.node.name); + return html`
+ ${ + box.head + ? html`
+ ${box.node.name} + ${ctx.config.show.value && total && box.rect.w >= 110 ? html`${total}` : nothing} +
` + : nothing + } +
`; +} + +const FIELDS: FieldBinding[] = [ + { name: 'treemap_color_by', path: ['color_by'], default: 'group' }, + ...GROUP_FIELDS, + { name: 'drill_down', path: ['drill_down'], default: true }, +]; + +export function treemapEditor(ctx: ChartEditorContext): TemplateResult { + const schema = cachedSchema('treemap', ctx.hass, () => [ + { name: 'treemap_color_by', selector: choice(ctx.hass, 'treemap_color_by', TREEMAP_COLOR_BY) }, + groupBySchema(ctx.hass), + { name: 'drill_down', selector: { boolean: {} } }, + ]); + return html`${chartForm(withGroupList(ctx), 'chart', schema, FIELDS, HIERARCHY_LABELS)} ${entityGroupsForm(ctx)}`; +} + +/** The tree the card's entities make, with groups from `group_by` and their own `group`. */ +export function treeFor(ctx: ChartContext): TreeNode { + const levels = parseGroupBy(ctx.raw.group_by); + const other = localize(ctx.hass, 'treemap_other'); + const paths = ctx.items.map((e) => groupPath(e, levels, ctx.hass, other)); + return buildTree(ctx.items, paths, ''); +} + +export const treemap: ChartStyleModule = { + layout: 'chart', + group: 'totals', + height: 280, + styles: css` + ${drillStyles} + .treemap-head { + display: flex; + justify-content: flex-end; + align-items: baseline; + gap: 8px; + min-height: 20px; + padding-bottom: 6px; + color: var(--secondary-text-color); + font-size: 0.875em; + } + .treemap { + position: relative; + height: ${HEIGHT}px; + } + .treemap .group, + .treemap .tile { + position: absolute; + box-sizing: border-box; + border-radius: 6px; + overflow: hidden; + } + .treemap .group-head { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 6px; + height: ${HEAD}px; + padding: 3px 8px 0; + box-sizing: border-box; + font-size: 12px; + white-space: nowrap; + } + .treemap .group-name { + font-weight: 500; + color: var(--primary-text-color); + overflow: hidden; + text-overflow: ellipsis; + } + .treemap .group-total { + color: var(--secondary-text-color); + } + .treemap .tile { + display: flex; + flex-direction: column; + padding: 5px 8px; + gap: 1px; + cursor: pointer; + outline: none; + opacity: 0; + transform: scale(0.96); + transition: + opacity var(--cd-duration) var(--cd-easing), + transform var(--cd-duration) var(--cd-easing); + } + :host([ready]) .treemap .tile { + opacity: 1; + transform: none; + } + :host([static]) .treemap .tile { + transition: none; + } + .treemap .tile:focus-visible { + box-shadow: inset 0 0 0 2px var(--primary-text-color); + } + .treemap .tile.text-light { + color: #fff; + } + .treemap .tile.text-dark { + color: rgba(0, 0, 0, 0.87); + } + .treemap .tile.text-theme { + color: var(--primary-text-color); + } + .treemap .tile-name, + .treemap .tile-value { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + line-height: 1.25; + } + .treemap .tile-name { + font-size: 13px; + font-weight: 500; + } + .treemap .tile-value { + font-size: 12px; + opacity: 0.9; + } + .treemap .tile.big .tile-value { + font-size: 22px; + font-weight: 500; + opacity: 1; + } + .treemap-message { + display: flex; + align-items: center; + justify-content: center; + height: ${HEIGHT}px; + color: var(--secondary-text-color); + font-size: 0.875em; + text-align: center; + } + .treemap-missing { + display: flex; + flex-wrap: wrap; + gap: 4px 12px; + padding-top: 8px; + font-size: 0.875em; + color: var(--secondary-text-color); + } + .treemap-missing .entity { + cursor: pointer; + border-radius: 4px; + } + .treemap-missing .entity:focus-visible { + outline: 2px solid var(--primary-color); + } + `, + thumbnail: () => svg` + + + + `, + validate(raw: CardConfig) { + validateGroups(raw); + validateDrill(raw); + if (raw.color_by !== undefined && !TREEMAP_COLOR_BY.includes(raw.color_by as TreemapColorBy)) { + throw new Error(`"color_by" must be one of: ${TREEMAP_COLOR_BY.join(', ')}`); + } + }, + editor: treemapEditor, + disconnect: forget, + render(ctx) { + const width = measure(ctx); + const tree = treeFor(ctx); + const view = drillView(ctx, tree); + const root = view.node; + const colorBy = (ctx.raw.color_by as TreemapColorBy | undefined) ?? 'group'; + const unit = sharedUnit(ctx.items); + // Drilled in, the parts keep the colour their top-level group has on the whole map. + const family = view.path.length + ? tree.children.filter((c) => c.value > 0).findIndex((c) => c.name === view.path[0]) + : undefined; + const { groups, leaves } = treemapLayout(root, width, HEIGHT, family); + const noReading = ctx.items.map((e, index) => ({ e, index })).filter(({ e }) => !e.available); + const sum = unit !== undefined && ctx.config.show.value && root.value > 0 ? totalText(root.value, ctx, unit) : ''; + const total = sum && !view.path.length ? localize(ctx.hass, 'treemap_total', { value: sum }) : ''; + const label = localize(ctx.hass, 'treemap_aria', { + names: (root.children.length ? root.children : []).map((c) => c.name).join(', '), + }); + const body = leaves.length + ? html`` + : html`
${localize(ctx.hass, 'treemap_empty')}
`; + return html`${drillBar(ctx, tree, view, sum)} + ${total ? html`
${total}
` : nothing} ${body} + ${ + noReading.length + ? html`
+ ${localize(ctx.hass, 'treemap_no_reading')} + ${noReading.map( + ({ e, index }) => + html`${e.name} (${e.display})`, + )} +
` + : nothing + }`; + }, +}; diff --git a/src/styles/trend.ts b/src/styles/trend.ts new file mode 100644 index 0000000..c417ac7 --- /dev/null +++ b/src/styles/trend.ts @@ -0,0 +1,1048 @@ +import { css, html, nothing, svg } from 'lit'; +import type { SVGTemplateResult, TemplateResult } from 'lit'; +import { bandFor, baseColor } from '../bands'; +import { DEFAULT_BANDS } from '../config'; +import { formatTime } from '../chart/axes'; +import { + VIEW_H, + VIEW_W, + disconnectHistory, + frameStyles, + historyFrame, + periodNav, + plot, + validateHistoryOptions, +} from '../chart/frame'; +import type { ChartSeries, HistoryFrame } from '../chart/frame'; +import { stopChartWidth } from '../chart/grid'; +import { linear } from '../chart/scale'; +import { runPath, runs } from '../chart/paths'; +import type { Curve } from '../chart/paths'; +import { DEFAULT_LINE_WIDTH, MAX_LINE_WIDTH, MIN_LINE_WIDTH, dotSize, dotsPath } from '../chart/series-style'; +import { + hoverHandlers, + hoverTime, + nearestIndex, + stopHover, + tooltipEnabled, + tooltipStyles, + validateTooltipOptions, +} from '../chart/tooltip'; +import { + DEFAULT_POINTS_PER_HOUR, + MAX_BARS, + MAX_BUCKETS, + bandStops, + bucketBounds, + bucketSeries, + checkAggregate, + extrema, + parseBound, + placeBuckets, + trendDomain, +} from '../chart/trend-data'; +import type { Bound, TrendPoint } from '../chart/trend-data'; +import { trendEditor, trendEntityFields } from '../editor/trend-editor'; +import { formatNumber, withUnit } from '../format'; +import type { Aggregate } from '../history/group'; +import { localize, registerStrings } from '../localize'; +import { iconOverride } from '../resolve'; +import type { ResolvedEntity } from '../resolve'; +import type { CardConfig } from '../types'; +import type { ChartContext, ChartStyleModule } from './index'; + +/** + * A compact card for one or a few sensors, after mini-graph-card: the name and icon, the current + * value in large type, and a graph of its history filling the bottom of the card edge to edge. + * Hovering or dragging along the graph shows the value at that moment in place of the current one. + */ + +registerStrings('en', { + editor_style_trend: 'Trend', + trend_min: 'Low', + trend_avg: 'Average', + trend_max: 'High', +}); + +export const GRAPH_KINDS = ['line', 'bar', 'none'] as const; +export const FILL_KINDS = ['solid', 'fade', 'none'] as const; +export const SHOW_MODES = ['hover', 'always', 'never'] as const; +export const HEADER_ALIGN = ['default', 'left', 'right', 'center'] as const; +export const ICON_ALIGN = ['right', 'left', 'state'] as const; +export const STATE_ALIGN = ['left', 'right', 'center'] as const; +export const TRANSITIONS = ['smooth', 'hard'] as const; + +type GraphKind = (typeof GRAPH_KINDS)[number]; +type FillKind = (typeof FILL_KINDS)[number]; +type ShowMode = (typeof SHOW_MODES)[number]; + +/** Graph height per 500 px of width, as mini-graph-card's `height`: the graph keeps its shape as the card resizes. */ +export const DEFAULT_HEIGHT = 150; +export const DEFAULT_TREND_LINE_WIDTH = DEFAULT_LINE_WIDTH; +export const DEFAULT_BAR_SPACING = 4; + +/** The trend options from YAML, with defaults filled in. */ +export interface TrendOptions { + graph: GraphKind; + fill: FillKind; + points: ShowMode; + labels: ShowMode; + pointsPerHour: number; + height: number; + lineWidth: number; + barSpacing: number; + transition: 'smooth' | 'hard'; + extrema: boolean; + average: boolean; + legend: boolean; + factor: number; + alignHeader: (typeof HEADER_ALIGN)[number]; + alignIcon: (typeof ICON_ALIGN)[number]; + alignState: (typeof STATE_ALIGN)[number]; + fontSize: number; + fontSizeHeader?: number; + colorName: boolean; + colorValue: boolean; + colorIcon: boolean; + group: boolean; + iconImage?: string; + primary: { min?: Bound; max?: Bound; minRange?: number }; + secondary: { min?: Bound; max?: Bound; minRange?: number }; +} + +/** Per-entity trend settings (entity configs keep keys the card doesn't know). */ +interface TrendEntity { + color?: string; + line_width?: number; + aggregate?: Aggregate; + y_axis?: 'primary' | 'secondary'; + fixed_value?: boolean; + show_state?: boolean; + show_indicator?: boolean; + show_graph?: boolean; + show_line?: boolean; + show_fill?: boolean; + show_points?: boolean; + show_legend?: boolean; + show_legend_state?: boolean; +} + +/** mini-graph-card's true / false / hover, or always / never / hover. */ +function showMode(raw: unknown, fallback: ShowMode): ShowMode { + if (raw === undefined) return fallback; + if (raw === true) return 'always'; + if (raw === false) return 'never'; + return raw as ShowMode; +} + +/** `fill: true` (mini-graph-card's solid fill) is the faded one here, as on the area chart. */ +function fillKind(raw: unknown): FillKind { + if (raw === undefined || raw === true) return 'fade'; + if (raw === false) return 'none'; + return raw as FillKind; +} + +const num = (v: unknown): number | undefined => (typeof v === 'number' && Number.isFinite(v) ? v : undefined); + +export function trendOptions(raw: CardConfig): TrendOptions { + return { + graph: (raw.graph as GraphKind | undefined) ?? 'line', + fill: fillKind(raw.fill), + points: showMode(raw.points, 'hover'), + labels: showMode(raw.labels, 'hover'), + pointsPerHour: num(raw.points_per_hour) ?? DEFAULT_POINTS_PER_HOUR, + height: num(raw.height) ?? DEFAULT_HEIGHT, + lineWidth: num(raw.line_width) ?? DEFAULT_TREND_LINE_WIDTH, + barSpacing: num(raw.bar_spacing) ?? DEFAULT_BAR_SPACING, + transition: (raw.color_transition as 'smooth' | 'hard' | undefined) ?? 'smooth', + extrema: raw.extrema === true, + average: raw.average === true, + legend: raw.legend !== false, + factor: Math.pow(10, num(raw.value_factor) ?? 0), + alignHeader: (raw.align_header as TrendOptions['alignHeader'] | undefined) ?? 'default', + alignIcon: (raw.align_icon as TrendOptions['alignIcon'] | undefined) ?? 'right', + alignState: (raw.align_state as TrendOptions['alignState'] | undefined) ?? 'left', + fontSize: num(raw.font_size) ?? 100, + fontSizeHeader: num(raw.font_size_header), + colorName: raw.color_name === true, + colorValue: raw.color_value === true, + colorIcon: raw.color_icon === true, + group: raw.group === true, + iconImage: typeof raw.icon_image === 'string' && raw.icon_image !== '' ? raw.icon_image : undefined, + primary: { + min: parseBound(raw.y_min, 'y_min'), + max: parseBound(raw.y_max, 'y_max'), + minRange: num(raw.min_range), + }, + secondary: { + min: parseBound(raw.y_min_secondary, 'y_min_secondary'), + max: parseBound(raw.y_max_secondary, 'y_max_secondary'), + minRange: num(raw.min_range_secondary), + }, + }; +} + +function oneOf(raw: unknown, values: readonly unknown[], name: string): void { + if (raw !== undefined && !values.includes(raw)) { + throw new Error(`"${name}" must be one of: ${values.join(', ')}`); + } +} + +function checkNumber(raw: unknown, name: string, min: number, max = Infinity, integer = false): void { + if (raw === undefined) return; + if ( + typeof raw !== 'number' || + !Number.isFinite(raw) || + raw < min || + raw > max || + (integer && !Number.isInteger(raw)) + ) { + const range = max === Infinity ? `${min} or more` : `from ${min} to ${max}`; + throw new Error(`"${name}" must be ${integer ? 'a whole number' : 'a number'} ${range}`); + } +} + +function checkBool(raw: unknown, name: string): void { + if (raw !== undefined && typeof raw !== 'boolean') throw new Error(`"${name}" must be true or false`); +} + +/** Throw a readable error for any bad trend option, on the card or an entity. */ +export function validateTrend(raw: CardConfig): void { + validateHistoryOptions(raw); + validateTooltipOptions(raw); + oneOf(raw.graph, GRAPH_KINDS, 'graph'); + oneOf(raw.fill, [...FILL_KINDS, true, false], 'fill'); + oneOf(raw.points, [...SHOW_MODES, true, false], 'points'); + oneOf(raw.labels, [...SHOW_MODES, true, false], 'labels'); + oneOf(raw.color_transition, TRANSITIONS, 'color_transition'); + oneOf(raw.align_header, HEADER_ALIGN, 'align_header'); + oneOf(raw.align_icon, ICON_ALIGN, 'align_icon'); + oneOf(raw.align_state, STATE_ALIGN, 'align_state'); + checkNumber(raw.points_per_hour, 'points_per_hour', 0, 60); + checkNumber(raw.height, 'height', 20, 1000); + checkNumber(raw.line_width, 'line_width', MIN_LINE_WIDTH, MAX_LINE_WIDTH); + checkNumber(raw.bar_spacing, 'bar_spacing', 0, 50); + checkNumber(raw.value_factor, 'value_factor', -9, 9, true); + checkNumber(raw.font_size, 'font_size', 25, 400); + checkNumber(raw.font_size_header, 'font_size_header', 6, 60); + checkNumber(raw.min_range, 'min_range', 0); + checkNumber(raw.min_range_secondary, 'min_range_secondary', 0); + for (const k of ['extrema', 'average', 'legend', 'color_name', 'color_value', 'group']) checkBool(raw[k], k); + if (raw.icon_image !== undefined && typeof raw.icon_image !== 'string') throw new Error('"icon_image" must be a URL'); + trendOptions(raw); + for (const e of raw.entities ?? []) { + if (typeof e !== 'object') continue; + const own = e as TrendEntity & { entity: string }; + const where = (k: string) => `"${k}" of ${own.entity}`; + checkAggregate(own.aggregate, where('aggregate')); + oneOf(own.y_axis, ['primary', 'secondary'], `y_axis of ${own.entity}`); + checkNumber(own.line_width, `line_width of ${own.entity}`, MIN_LINE_WIDTH, MAX_LINE_WIDTH); + for (const k of [ + 'fixed_value', + 'show_state', + 'show_indicator', + 'show_graph', + 'show_line', + 'show_fill', + 'show_points', + 'show_legend', + 'show_legend_state', + ] as const) { + if (own[k] !== undefined && typeof own[k] !== 'boolean') throw new Error(`${where(k)} must be true or false`); + } + } +} + +/** One series ready to draw: its points in buckets, its colour and where it shows. */ +interface TrendSeries { + index: number; + entity: ResolvedEntity; + own: TrendEntity; + points: TrendPoint[]; + /** The series' own colour, or undefined when it is coloured by bands. */ + color?: string; + /** Its colour now: its own, the current value's band, or its palette colour. */ + current: string; + curve: Curve; + secondary: boolean; + graphed: boolean; +} + +/** Bands someone set (on the card or the entity), rather than the single default one. */ +const hasBands = (e: ResolvedEntity) => e.config.severity !== DEFAULT_BANDS; + +function trendSeries(frame: HistoryFrame, ctx: ChartContext, options: TrendOptions): TrendSeries[] { + const until = Math.min(frame.range.end, Date.now()); + const bars = options.graph === 'bar'; + // Bars always need buckets; a line with points_per_hour 0 draws every reading. + const perHour = bars && options.pointsPerHour === 0 ? DEFAULT_POINTS_PER_HOUR : options.pointsPerHour; + const bounds = frame.group ? [] : bucketBounds(frame.range, perHour, bars ? MAX_BARS : MAX_BUCKETS); + const cardAggregate = (ctx.raw.aggregate as Aggregate | undefined) ?? 'mean'; + return frame.series.map((s: ChartSeries) => { + const own = s.entity.config as unknown as TrendEntity; + let points: TrendPoint[] = s.points; + if (own.fixed_value && s.entity.value !== null) { + points = [ + { t: frame.range.start, v: s.entity.value }, + { t: until, v: s.entity.value }, + ]; + } else if (frame.group) { + // Calendar groups (`group_by`): drawn at their middles like buckets. + points = placeBuckets(s.points, frame.range, until); + } else if (bounds.length) { + points = placeBuckets( + bucketSeries(s.points, bounds, own.aggregate ?? cardAggregate, frame.source, until), + frame.range, + until, + ); + } + if (options.factor !== 1) points = points.map((p) => (p.v === null ? p : { ...p, v: p.v * options.factor })); + const ownColor = own.color !== undefined ? baseColor(own.color) : undefined; + const banded = !ownColor && hasBands(s.entity); + const value = s.entity.value === null ? null : s.entity.value * options.factor; + const current = ownColor ?? (banded && value !== null ? baseColor(bandFor(value, s.entity.bands).color) : s.color); + return { + index: s.index, + entity: s.entity, + own, + points, + color: banded ? undefined : (ownColor ?? s.color), + current, + curve: s.curve, + secondary: own.y_axis === 'secondary', + graphed: own.show_graph !== false, + }; + }); +} + +/** Lowest and highest reading across these series. */ +function dataExtent(series: TrendSeries[]): [number, number] | undefined { + let lo = Infinity; + let hi = -Infinity; + for (const s of series) { + for (const p of s.points) { + if (p.v === null) continue; + if (p.v < lo) lo = p.v; + if (p.v > hi) hi = p.v; + } + } + return lo === Infinity ? undefined : [lo, hi]; +} + +/** The value as text, scaled by `value_factor`, with the entity's unit kept apart for styling. */ +function valueParts(v: number | null, e: ResolvedEntity, factor: number, hass: ChartContext['hass']) { + if (v === null) return { number: e.display, unit: '' }; + return { number: formatNumber(v * factor, e.decimals, hass), unit: e.unit }; +} + +const pct = (n: number) => `${Math.round(n * 10000) / 100}%`; + +interface Scaled { + series: TrendSeries; + y: (v: number) => number; +} + +function gradientDefs(scaled: Scaled[], domains: Map, options: TrendOptions) { + return scaled + .filter(({ series: s }) => s.color === undefined) + .map(({ series: s }) => { + const stops = bandStops(s.entity.bands, domains.get(s.secondary)!, options.transition); + return svg` + ${stops.map((stop) => svg``)} + `; + }); +} + +const paint = (s: TrendSeries) => s.color ?? `url(#cd-trend-${s.index})`; + +/** A filled line: the fill closes down to the bottom of the graph, under each unbroken run. */ +function lineSeries({ series: s, y }: Scaled, x: (t: number) => number, options: TrendOptions): SVGTemplateResult { + const parts = runs(s.points).map((run) => { + const pts = run.map((p): [number, number] => [x(p.t), y(p.v as number)]); + return { pts, d: runPath(pts, s.curve) }; + }); + const line = s.own.show_line !== false; + const fill = options.fill !== 'none' && s.own.show_fill !== false; + const width = s.own.line_width ?? options.lineWidth; + const dots = s.own.show_points ?? options.points === 'always'; + const real = s.points.filter((p) => p.v !== null && !p.carried); + return svg` + ${ + fill + ? svg` `${d}L${pts[pts.length - 1][0]},${VIEW_H}L${pts[0][0]},${VIEW_H}Z`).join('')} + fill=${paint(s)} + mask=${options.fill === 'fade' ? 'url(#cd-trend-fade)' : nothing} + >` + : nothing + } + ${ + line + ? svg` p.d).join('')} fill="none" stroke=${paint(s)} + style="stroke-width:${width}px">` + : nothing + } + ${ + dots && real.length <= 200 + ? svg` [x(p.t), y(p.v as number)]))} fill="none" + stroke=${paint(s)} style="stroke-width:${dotSize(width)}px">` + : nothing + } + `; +} + +/** + * Columns, one per bucket, side by side when there are several series, drawn in HTML as on the + * column chart so their rounded ends stay round. A column coloured by bands takes its own value's + * band. `spacing` is the gap between buckets, in px. + */ +function columns(scaled: Scaled[], x: (t: number) => number, spacing: number) { + const n = scaled.length; + const cols = scaled.flatMap(({ series: s, y }, k) => { + const zero = Math.min(VIEW_H, Math.max(0, y(0))); + return s.points + .filter((p) => p.v !== null && !p.carried && p.start !== undefined && p.end !== undefined) + .map((p) => { + const left = x(p.start!) / VIEW_W; + const slot = (x(p.end!) - x(p.start!)) / VIEW_W; + const top = Math.min(VIEW_H, Math.max(0, y(p.v as number))); + const down = top > zero; + const color = s.color ?? baseColor(bandFor(p.v as number, s.entity.bands).color); + const width = `(${pct(slot)} - ${spacing}px) / ${n}`; + return html`
`; + }); + }); + return html`
${cols}
`; +} + +/** + * A series' name in the legend. The card's `name` is the header's title, and Chartdeck hands it + * down to every entity, so a series that only has the card's name uses its friendly name instead. + */ +function seriesName(s: TrendSeries, ctx: ChartContext): string { + const cardName = ctx.raw.name; + if (cardName === undefined || s.entity.name !== cardName) return s.entity.name; + return (s.entity.stateObj?.attributes.friendly_name as string | undefined) ?? s.entity.entityId; +} + +/** The value at the hovered moment for each series, or nothing when not hovering. */ +function hovered(series: TrendSeries[], t: number | undefined) { + if (t === undefined) return undefined; + return series.map((s) => { + const real = s.points.filter((p) => !p.carried); + const i = nearestIndex(real, t); + return i < 0 ? undefined : real[i]; + }); +} + +/** "14:00 – 16:00" for a bucket, the time alone for a reading. */ +function whenText(p: TrendPoint, ctx: ChartContext): string { + if (p.start !== undefined && p.end !== undefined && p.end > p.start) { + return `${formatTime(p.start, ctx.hass)} – ${formatTime(p.end, ctx.hass)}`; + } + return formatTime(p.t, ctx.hass); +} + +function icon(e: ResolvedEntity, ctx: ChartContext, options: TrendOptions, color: string) { + const style = options.colorIcon ? `color:${color}` : ''; + if (options.iconImage) { + return html``; + } + return html` + + `; +} + +/** Low, average and high as chips tinted with the series colour, as on the line chart's highs and lows. */ +function infoRow(s: TrendSeries, options: TrendOptions, ctx: ChartContext, weighted: boolean) { + if (!options.extrema && !options.average) return nothing; + const x = extrema(s.points, weighted); + if (!x) return nothing; + const text = (v: number) => withUnit(formatNumber(v, s.entity.decimals, ctx.hass), s.entity.unit); + const chip = (key: string, v: number, t?: number) => + html`${localize(ctx.hass, key)} ${text(v)}${ + t !== undefined ? html` · ${formatTime(t, ctx.hass)}` : nothing + }`; + return html`
+ ${options.extrema ? chip('trend_min', x.min.v, x.min.t) : nothing} + ${options.average ? chip('trend_avg', x.mean) : nothing} + ${options.extrema ? chip('trend_max', x.max.v, x.max.t) : nothing} +
`; +} + +function render(ctx: ChartContext): TemplateResult { + const options = trendOptions(ctx.raw); + const { show } = ctx.config; + const frame = historyFrame(ctx); + const series = trendSeries(frame, ctx, options); + const primary = series[0]; + const e = primary.entity; + const graphedSeries = series.filter((s) => s.graphed); + const t = tooltipEnabled(ctx.raw) && frame.status === 'ready' ? hoverTime(ctx.host) : undefined; + const at = hovered(series, t); + + // Header: name and icon. + const nameStyle = options.colorName ? `color:${primary.current}` : ''; + const headerIcon = show.icon && options.alignIcon !== 'state' ? icon(e, ctx, options, primary.current) : nothing; + const header = + show.name || headerIcon !== nothing + ? html`
+ ${show.name ? html`${e.name}` : nothing} ${headerIcon} +
` + : nothing; + + // States: the primary value large, any others set to show small beside it. + const stateOf = (s: TrendSeries, i: number) => { + const p = at?.[i]; + const v = p ? p.v : s.entity.value; + const parts = p + ? { number: p.v === null ? '—' : formatNumber(p.v, s.entity.decimals, ctx.hass), unit: s.entity.unit } + : valueParts(v, s.entity, options.factor, ctx.hass); + // The colour follows the value shown: the hovered reading's band while hovering. + const color = + p && p.v !== null && s.color === undefined ? baseColor(bandFor(p.v, s.entity.bands).color) : s.current; + return { parts, color, when: p ? whenText(p, ctx) : undefined }; + }; + const big = stateOf(primary, 0); + const showPrimary = show.value; + const secondaries = series.slice(1).filter((s) => s.own.show_state === true); + const states = + showPrimary || secondaries.length || options.alignIcon === 'state' + ? html`
+ ${show.icon && options.alignIcon === 'state' ? icon(e, ctx, options, primary.current) : nothing} + ${ + showPrimary + ? html`
+ ${primary.own.show_indicator ? html`` : nothing} + ${big.parts.number}${big.parts.unit ? html`${big.parts.unit}` : nothing} + ${big.when ? html`${big.when}` : nothing} +
` + : nothing + } + ${ + secondaries.length + ? html`
+ ${secondaries.map((s) => { + const st = stateOf(s, s.index); + return html`
+ ${s.own.show_indicator !== false ? html`` : nothing} + ${st.parts.number}${st.parts.unit ? html`${st.parts.unit}` : nothing} +
`; + })} +
` + : nothing + } +
` + : nothing; + + const legendItems = graphedSeries.filter((s) => s.own.show_legend !== false); + // Two or more lines: the row of swatches and names every history chart has. + const legend = + options.legend && graphedSeries.length > 1 && legendItems.length + ? html`
+ ${legendItems.map( + (s) => + html`
+ + ${seriesName(s, ctx)} + ${s.own.show_legend_state ? html`${s.entity.display}` : nothing} +
`, + )} +
` + : nothing; + + const nav = periodNav(frame, ctx); + const graph = options.graph === 'none' ? nothing : graphArea(ctx, frame, graphedSeries, options, at); + return html`
+
+ ${header} ${nav === nothing ? nothing : html`
${nav}
`} ${states} + ${infoRow(primary, options, ctx, frame.source === 'history' && !frame.group && options.pointsPerHour === 0)} + ${legend} +
+ ${graph} +
`; +} + +function graphArea( + ctx: ChartContext, + frame: HistoryFrame, + series: TrendSeries[], + options: TrendOptions, + at: (TrendPoint | undefined)[] | undefined, +) { + const ratio = `aspect-ratio:500 / ${options.height}`; + if (frame.status !== 'ready' || series.every((s) => s.points.every((p) => p.v === null))) { + const key = + frame.status === 'loading' ? 'chart_loading' : frame.status === 'error' ? 'chart_error' : 'chart_no_data'; + return html`
+ ${localize(ctx.hass, key, { error: frame.error ?? '' })} +
`; + } + const bars = options.graph === 'bar'; + const domains = new Map(); + for (const secondary of [false, true]) { + const group = series.filter((s) => s.secondary === secondary); + if (!group.length) continue; + domains.set( + secondary, + trendDomain(dataExtent(group), { ...(secondary ? options.secondary : options.primary), fromZero: bars }), + ); + } + const x = linear([frame.range.start, frame.range.end], [0, VIEW_W]); + const scaled: Scaled[] = series.map((s) => ({ series: s, y: linear(domains.get(s.secondary)!, [VIEW_H, 0]) })); + const content = bars ? svg`` : svg`${scaled.map((sc) => lineSeries(sc, x, options))}`; + const defs = svg`${gradientDefs(scaled, domains, options)} + + + + + + + `; + const label = localize(ctx.hass, 'chart_aria', { + style: localize(ctx.hass, 'editor_style_trend'), + names: series.map((s) => s.entity.name).join(', '), + }); + + // Value labels: the top and bottom of each axis, as pills over the graph's corners. + const bound = (secondary: boolean) => { + const group = series.filter((s) => s.secondary === secondary); + const data = dataExtent(group); + if (!data) return nothing; + // The readings' own lowest and highest, not the padded ends; a hard bound shows itself. + const set = secondary ? options.secondary : options.primary; + const lo = set.min && !set.min.soft ? set.min.value : data[0]; + const hi = set.max && !set.max.soft ? set.max.value : data[1]; + const e = group[0].entity; + const f = (v: number) => withUnit(formatNumber(v, e.decimals, ctx.hass), e.unit); + return html`
+ ${f(hi)}${f(lo)} +
`; + }; + const labels = + options.labels === 'never' + ? nothing + : html`
${bound(false)}${bound(true)}
`; + + // Hover: the guide line and a dot on each line at the hovered reading, as on the line chart + // (bars light up their slot instead, as on the column chart). + const guide = + at && !bars && at[series[0].index] + ? html`
` + : nothing; + const dots = + at && !bars + ? scaled.map(({ series: s, y }) => { + const p = at[s.index]; + if (!p || p.v === null || s.own.show_points === false) return nothing; + const top = y(p.v) / VIEW_H; + if (!(top >= 0 && top <= 1)) return nothing; + const color = s.color ?? baseColor(bandFor(p.v, s.entity.bands).color); + return html``; + }) + : nothing; + const marker = + at && bars && at[series[0].index] + ? (() => { + const p = at[series[0].index]!; + if (p.start === undefined || p.end === undefined) return nothing; + return html`
`; + })() + : nothing; + + const hover = tooltipEnabled(ctx.raw) + ? hoverHandlers( + ctx, + frame.range, + series.map((s) => s.points.filter((p) => !p.carried)), + ) + : undefined; + return html`
+ ${plot(content, label, defs)} ${bars ? columns(scaled, x, options.barSpacing) : nothing} ${labels} ${marker} + ${guide} ${options.points === 'never' ? nothing : dots} +
`; +} + +export const trend: ChartStyleModule = { + layout: 'chart', + group: 'history', + height: 170, + gridOptions: { columns: 6, min_columns: 3, min_rows: 2 }, + styles: css` + ${frameStyles} + ${tooltipStyles} + .content.style-trend { + padding: 0; + flex: 1; + display: flex; + flex-direction: column; + } + .content.style-trend > .chart { + flex: 1; + display: flex; + flex-direction: column; + } + /* The graph sits on the card's bottom edge, its corners cut by the card's rounding. */ + ha-card:has(> .content.style-trend) { + overflow: hidden; + display: flex; + flex-direction: column; + height: 100%; + } + ha-card:has(> .content.style-trend .trend.group) { + box-shadow: none; + border: none; + background: none; + } + .trend { + flex: 1; + display: flex; + flex-direction: column; + } + .trend-top { + padding: 16px 16px 0; + } + .trend.group .trend-top { + padding: 0 0 0; + } + .trend.no-graph .trend-top { + padding-bottom: 16px; + } + .trend-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + min-width: 0; + cursor: pointer; + border-radius: 4px; + font-size: 14px; + } + .trend-header.icon-left { + flex-direction: row-reverse; + justify-content: flex-end; + } + .trend-header.align-left { + justify-content: flex-start; + } + .trend-header.align-right { + justify-content: flex-end; + } + .trend-header.align-center { + justify-content: center; + } + .trend-name { + font-size: 1.15em; + font-weight: 500; + line-height: 1.4em; + color: var(--primary-text-color); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + } + .trend-icon { + flex: none; + display: inline-flex; + color: var(--state-icon-color, #44739e); + } + .trend-icon ha-state-icon { + --mdc-icon-size: 1.7em; + width: 1.7em; + height: 1.7em; + } + .trend-icon img { + width: 1.7em; + height: 1.7em; + object-fit: contain; + } + .trend-states { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0 16px; + min-width: 0; + padding-bottom: 4px; + } + .trend-states.align-right { + flex-direction: row-reverse; + } + .trend-states.align-center { + justify-content: space-evenly; + } + .trend-states .trend-icon { + align-self: center; + font-size: 1.15em; + } + .trend-state { + position: relative; + display: flex; + align-items: baseline; + gap: 4px; + min-width: 0; + cursor: pointer; + border-radius: 4px; + } + .trend-state:not(.small) { + margin-top: 4px; + } + .trend-value { + font-size: 2.25em; + font-weight: 400; + line-height: 1.15; + letter-spacing: -0.01em; + white-space: nowrap; + font-variant-numeric: tabular-nums; + } + .trend-unit { + font-size: 1.15em; + font-weight: 400; + color: var(--secondary-text-color); + white-space: nowrap; + } + /* While hovering, the reading's time sits after the value. */ + .trend-when { + align-self: center; + margin-left: 6px; + font-size: 0.8125em; + color: var(--secondary-text-color); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; + } + .trend-secondary { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 2px; + min-width: 0; + padding-top: 8px; + } + .trend-states.align-right .trend-secondary { + align-items: flex-start; + } + .trend-state.small { + align-items: center; + gap: 6px; + } + .trend-state.small .trend-value { + font-size: 1em; + font-weight: 500; + letter-spacing: 0; + } + .trend-state.small .trend-unit { + font-size: 0.875em; + } + .trend-state .swatch { + align-self: center; + } + /* Low, average and high, as the line chart's highs and lows. */ + .trend-info { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 2px 0 8px; + } + .trend-chip { + height: 20px; + box-sizing: border-box; + padding: 0 8px; + border-radius: 10px; + font-size: 11px; + line-height: 20px; + white-space: nowrap; + font-variant-numeric: tabular-nums; + color: var(--primary-text-color); + background: color-mix(in srgb, var(--series) 16%, var(--card-background-color, #fff)); + } + .trend-chip.trend_avg { + color: var(--secondary-text-color); + background: color-mix(in srgb, var(--primary-text-color) 6%, var(--card-background-color, #fff)); + } + .trend-chip-type { + font-weight: 500; + } + .trend-chip-time { + color: var(--secondary-text-color); + } + .trend-legend { + padding: 0 0 6px; + } + .trend .entity:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: 2px; + } + .trend-graph { + position: relative; + margin-top: auto; + width: 100%; + max-height: 400px; + } + .trend-graph.hoverable { + touch-action: pan-y; + cursor: crosshair; + } + .trend-graph.message { + display: flex; + align-items: center; + justify-content: center; + color: var(--secondary-text-color); + font-size: 0.875em; + min-height: 48px; + } + .trend-graph.message.error { + color: var(--error-color, #db4437); + } + .trend-graph .plot { + overflow: hidden; + } + .style-trend .line { + stroke-linecap: round; + stroke-linejoin: round; + } + .style-trend .dots { + stroke-linecap: round; + } + .style-trend .fill.solid { + fill-opacity: 0.2; + } + /* Columns as on the column chart: rounded at the outer end, growing in from the base. */ + .trend-cols { + position: absolute; + inset: 0; + pointer-events: none; + } + .trend-col { + position: absolute; + box-sizing: border-box; + border-radius: 4px 4px 0 0; + transform-origin: 50% 100%; + animation: cd-trend-grow var(--cd-duration) var(--cd-easing); + } + .trend-col.down { + border-radius: 0 0 4px 4px; + transform-origin: 50% 0; + } + :host([static]) .trend-col { + animation: none; + } + @keyframes cd-trend-grow { + from { + transform: scaleY(0); + } + } + .trend-labels-wrap { + position: absolute; + inset: 0; + pointer-events: none; + transition: opacity 0.2s ease; + z-index: 1; + } + .trend-labels-wrap.labels-hover { + opacity: 0; + } + .trend-graph:hover .trend-labels-wrap.labels-hover, + .trend-graph:active .trend-labels-wrap.labels-hover { + opacity: 1; + } + .trend-labels { + position: absolute; + top: 0; + bottom: 0; + display: flex; + flex-direction: column; + justify-content: space-between; + align-items: flex-start; + padding: 6px 8px; + box-sizing: border-box; + } + .trend-labels.primary { + left: 0; + } + .trend-labels.secondary { + right: 0; + align-items: flex-end; + } + /* Tinted with the series colour, as the line chart's high and low labels. */ + .trend-labels span { + height: 18px; + box-sizing: border-box; + padding: 0 6px; + border-radius: 9px; + font-size: 11px; + line-height: 18px; + white-space: nowrap; + font-variant-numeric: tabular-nums; + color: var(--primary-text-color); + background: color-mix(in srgb, var(--series) 16%, var(--card-background-color, #fff)); + } + .trend-graph .crosshair { + z-index: 1; + pointer-events: none; + } + .trend-graph .hover-dot { + z-index: 2; + pointer-events: none; + } + .bar-hover { + position: absolute; + top: 0; + bottom: 0; + background: color-mix(in srgb, var(--primary-text-color) 7%, transparent); + pointer-events: none; + } + `, + thumbnail: () => svg` + + + + + `, + validate: validateTrend, + editor: trendEditor, + entityFields: trendEntityFields, + disconnect(host) { + disconnectHistory(host); + stopChartWidth(host); + stopHover(host); + }, + render, +}; diff --git a/src/styles/violin.ts b/src/styles/violin.ts new file mode 100644 index 0000000..1d4df19 --- /dev/null +++ b/src/styles/violin.ts @@ -0,0 +1,448 @@ +import { css, nothing, svg } from 'lit'; +import type { SVGTemplateResult, TemplateResult } from 'lit'; +import { + CATEGORY_FIELDS, + bandTooltip, + categoryGeometry, + categoryOptions, + categoryPlot, + categorySchema, + categoryStyles, + hoveredBand, + labelCount, + numberScale, + stopCategoryHover, + validateCategoryOptions, +} from '../chart/category-plot'; +import type { Category, CategoryGeometry } from '../chart/category-plot'; +import { + bandwidth, + densityCurve, + jitter, + readingsOf, + sampleReadings, + splitReadings, + summarize, +} from '../chart/density'; +import type { Reading, Summary } from '../chart/density'; +import { + disconnectHistory, + frameMessage, + frameStyles, + historyFrame, + periodNav, + validateHistoryOptions, +} from '../chart/frame'; +import type { ChartSeries } from '../chart/frame'; +import { stopChartWidth } from '../chart/grid'; +import { chartFrame, chartLayout, layoutStyles } from '../chart/layout'; +import type { XY } from '../chart/paths'; +import { dotsPath } from '../chart/series-style'; +import { tooltipEnabled, tooltipStyles, validateTooltipOptions } from '../chart/tooltip'; +import { + HISTORY_FIELDS, + cachedSchema, + chartForm, + choice, + historySchema, + historyVariant, +} from '../editor/chart-editors'; +import type { FieldBinding } from '../editor/schemas'; +import { formatNumber, withUnit } from '../format'; +import { boundaries } from '../history/timezone'; +import type { Zone } from '../history/timezone'; +import { localize, registerStrings } from '../localize'; +import type { CardConfig, HomeAssistant } from '../types'; +import type { ChartContext, ChartEditorContext, ChartStyleModule } from './index'; + +export const SHAPES = ['violin', 'raincloud'] as const; +export type Shape = (typeof SHAPES)[number]; + +export const SPLITS = ['none', 'day', 'week', 'month'] as const; +export type Split = (typeof SPLITS)[number]; + +/** Half a violin's widest point, as a share of its band. */ +const HALF = 0.42; +/** At most this many readings are drawn as dots per shape; the shape itself uses them all. */ +const MAX_DOTS = 300; +/** A band wider than this (px) draws its shape as if it were this wide. */ +const MAX_BAND_PX = 110; + +registerStrings('en', { + editor_style_violin: 'Violin', + editor_shape: 'Shape', + editor_shape_violin: 'Violin', + editor_shape_raincloud: 'Raincloud (half violin, box and readings)', + editor_box: 'Box with the median and middle half', + editor_readings: 'Show each reading as a dot', + editor_bandwidth: 'Smoothing (in the entity’s unit, empty for automatic)', + editor_split_by: 'One shape per', + editor_split_by_none: 'Entity', + editor_split_by_day: 'Entity and day', + editor_split_by_week: 'Entity and week', + editor_split_by_month: 'Entity and month', + violin_median: 'median {value}', + violin_none: 'No readings', + violin_raincloud: 'Raincloud', +}); + +/** One shape to draw: an entity, or an entity's day / week / month. */ +interface Group { + series: ChartSeries; + readings: Reading[]; + summary?: Summary; + name: string; + detail: string; +} + +export interface ViolinOptions { + shape: Shape; + box: boolean; + readings: boolean; + bandwidth?: number; + split: Split; +} + +export function violinOptions(raw: CardConfig): ViolinOptions { + const shape = (raw.shape as Shape | undefined) ?? 'violin'; + return { + shape, + box: raw.box !== false, + readings: (raw.readings as boolean | undefined) ?? shape === 'raincloud', + bandwidth: typeof raw.bandwidth === 'number' ? raw.bandwidth : undefined, + split: (raw.split_by as Split | undefined) ?? 'none', + }; +} + +function dateName(t: number, split: Split, zone: Zone, hass: HomeAssistant | undefined): string { + const options: Intl.DateTimeFormatOptions = + split === 'month' + ? { month: 'short', year: 'numeric', timeZone: zone.timeZone } + : { weekday: split === 'day' ? 'short' : undefined, day: 'numeric', month: 'short', timeZone: zone.timeZone }; + try { + return new Intl.DateTimeFormat(hass?.locale?.language ?? hass?.language ?? 'en', options).format(t); + } catch { + return new Intl.DateTimeFormat('en', options).format(t); + } +} + +const median = (s: Summary | undefined, series: ChartSeries, hass: HomeAssistant | undefined) => + s ? withUnit(formatNumber(s.median, series.entity.decimals, hass), series.entity.unit) : ''; + +/** The shapes to draw: one per entity, or per entity and period (empty periods left out). */ +function groupsOf( + series: ChartSeries[], + readings: Reading[][], + o: ViolinOptions, + bounds: number[], + weighted: boolean, + zone: Zone, + hass: HomeAssistant | undefined, +): Group[] { + const detail = (s: Summary | undefined, ser: ChartSeries) => + s ? localize(hass, 'violin_median', { value: median(s, ser, hass) }) : localize(hass, 'violin_none'); + if (o.split === 'none') { + return series.map((ser, i) => { + const summary = summarize(readings[i]); + return { series: ser, readings: readings[i], summary, name: ser.entity.name, detail: detail(summary, ser) }; + }); + } + const parts = series.map((_, i) => splitReadings(readings[i], bounds, weighted)); + const out: Group[] = []; + for (let p = 0; p + 1 < bounds.length; p++) { + for (let i = 0; i < series.length; i++) { + const r = parts[i][p]; + if (r.length === 0) continue; + const summary = summarize(r); + const date = dateName(bounds[p], o.split, zone, hass); + out.push({ + series: series[i], + readings: r, + summary, + name: series.length > 1 ? `${date} · ${series[i].entity.name}` : date, + detail: detail(summary, series[i]), + }); + } + } + return out; +} + +/** A closed outline through `left` and back along `right`. */ +function outline(left: XY[], right: XY[]): string { + const f = (n: number) => Math.round(n * 100) / 100; + const pts = [...left, ...[...right].reverse()]; + return `${pts.map(([x, y], i) => `${i ? 'L' : 'M'}${f(x)},${f(y)}`).join('')}Z`; +} + +/** A rectangle between two corners, whichever way round the plot is. */ +function rectBetween([x0, y0]: XY, [x1, y1]: XY) { + return { x: Math.min(x0, x1), y: Math.min(y0, y1), width: Math.abs(x1 - x0), height: Math.abs(y1 - y0) }; +} + +/** The violin (or a raincloud's half) for one group. */ +export function shapePath( + curve: { x: number; d: number }[], + g: CategoryGeometry, + i: number, + half: boolean, + base = 0, + size = HALF, +): string { + if (curve.length === 0) return ''; + const left = curve.map((p) => g.at(p.x, i, base - p.d * size)); + const right = curve.map((p) => g.at(p.x, i, half ? base : base + p.d * size)); + return outline(left, right); +} + +/** Where a reading's dot sits across its band: scattered inside the violin, or in the rain. */ +function dotOffset(k: number, d: number, raincloud: boolean): number { + return raincloud ? 0.3 + jitter(k) * 0.22 : jitter(k) * 1.6 * HALF * d; +} + +function densityAt(curve: { x: number; d: number }[], v: number): number { + if (curve.length === 0) return 0; + let j = 0; + while (j < curve.length - 1 && curve[j + 1].x < v) j++; + const a = curve[j]; + const b = curve[Math.min(curve.length - 1, j + 1)]; + if (b.x === a.x) return a.d; + return a.d + ((b.d - a.d) * (v - a.x)) / (b.x - a.x); +} + +function drawGroup( + group: Group, + i: number, + g: CategoryGeometry, + o: ViolinOptions, + domain: [number, number], +): SVGTemplateResult { + const { summary: s, series } = group; + if (!s) return svg``; + const h = o.bandwidth ?? bandwidth(group.readings); + const curve = densityCurve(group.readings, h, domain); + const rain = o.shape === 'raincloud'; + // Wide columns (few entities standing up) keep a violin's width, rather than growing fat. + const k = Math.min(1, MAX_BAND_PX / g.bandPx); + // A raincloud: the cloud above its line, the box just under it, the readings falling below. + const shape = svg``; + const boxAt = rain ? 0.08 * k : 0; + const iqr = rectBetween(g.at(s.q1, i, boxAt - 0.045 * k), g.at(s.q3, i, boxAt + 0.045 * k)); + const [lx, ly] = g.at(s.low, i, boxAt); + const [hx, hy] = g.at(s.high, i, boxAt); + const box = o.box + ? svg` + + + + ` + : nothing; + const dots = o.readings + ? svg` + g.at(r.v, i, k * dotOffset(n, densityAt(curve, r.v), rain)), + ), + )}>` + : nothing; + return svg`${shape}${box}${dots}`; +} + +const pctRow = (s: Summary, ser: ChartSeries, hass: HomeAssistant | undefined): [string, string][] => { + const f = (v: number) => formatNumber(v, ser.entity.decimals, hass); + const u = (t: string) => withUnit(t, ser.entity.unit); + return [ + [localize(hass, 'dist_median'), u(f(s.median))], + [localize(hass, 'dist_middle'), u(`${f(s.q1)} – ${f(s.q3)}`)], + [localize(hass, 'dist_range'), u(`${f(s.min)} – ${f(s.max)}`)], + [localize(hass, 'dist_count'), formatNumber(s.n, 0, hass)], + ]; +}; + +const VIOLIN_FIELDS_BASE: FieldBinding[] = [ + ...HISTORY_FIELDS, + { name: 'shape', path: ['shape'], default: 'violin' }, + { name: 'box', path: ['box'], default: true }, + { name: 'split_by', path: ['split_by'], default: 'none' }, + { name: 'bandwidth', path: ['bandwidth'] }, + ...CATEGORY_FIELDS, +]; + +const fieldsByShape = new Map(); + +/** The fields, with "every reading as a dot" defaulting on for a raincloud and off for a violin. */ +function violinFields(shape: Shape): FieldBinding[] { + let fields = fieldsByShape.get(shape); + if (!fields) { + fields = [...VIOLIN_FIELDS_BASE, { name: 'readings', path: ['readings'], default: shape === 'raincloud' }]; + fieldsByShape.set(shape, fields); + } + return fields; +} + +export function violinEditor(ctx: ChartEditorContext): TemplateResult { + const o = violinOptions(ctx.config); + const schema = cachedSchema(`violin|${historyVariant(ctx.config)}`, ctx.hass, () => [ + ...historySchema(ctx.hass, ctx.config), + { + type: 'grid', + name: '', + schema: [ + { name: 'shape', selector: choice(ctx.hass, 'shape', SHAPES) }, + { name: 'split_by', selector: choice(ctx.hass, 'split_by', SPLITS) }, + ], + }, + { + type: 'grid', + name: '', + schema: [ + { name: 'box', selector: { boolean: {} } }, + { name: 'readings', selector: { boolean: {} } }, + ], + }, + { name: 'bandwidth', selector: { number: { min: 0, mode: 'box', step: 'any' } } }, + ...categorySchema(ctx.hass), + ]); + return chartForm(ctx, 'chart', schema, violinFields(o.shape)); +} + +const ENTITY_FIELDS = { + schema: [{ name: 'fill_color', selector: { text: {} } }], + fields: [{ name: 'fill_color', path: ['color'] }], +}; + +registerStrings('en', { editor_fill_color: 'Colour' }); + +export const violin: ChartStyleModule = { + layout: 'chart', + group: 'distribution', + height: 240, + styles: css` + ${frameStyles} + ${tooltipStyles} + ${layoutStyles} + ${categoryStyles} + .style-violin .shape { + fill-opacity: 0.35; + stroke-width: 1.5px; + stroke-linejoin: round; + } + .style-violin .whisker { + stroke-width: 1.5px; + } + .style-violin .median { + stroke: var(--card-background-color, #fff); + stroke-width: 6px; + stroke-linecap: round; + } + .style-violin .dots { + fill: none; + stroke-width: 4px; + stroke-linecap: round; + stroke-opacity: 0.7; + } + `, + thumbnail: () => svg` + + + + + + `, + validate(raw: CardConfig) { + validateHistoryOptions(raw); + validateTooltipOptions(raw); + validateCategoryOptions(raw); + if (raw.shape !== undefined && !SHAPES.includes(raw.shape as Shape)) { + throw new Error(`"shape" must be one of: ${SHAPES.join(', ')}`); + } + if (raw.split_by !== undefined && !SPLITS.includes(raw.split_by as Split)) { + throw new Error(`"split_by" must be one of: ${SPLITS.join(', ')}`); + } + for (const k of ['box', 'readings']) { + if (raw[k] !== undefined && typeof raw[k] !== 'boolean') throw new Error(`"${k}" must be true or false`); + } + const bw = raw.bandwidth; + if (bw !== undefined && !(typeof bw === 'number' && Number.isFinite(bw) && bw > 0)) { + throw new Error('"bandwidth" must be a number above 0'); + } + }, + editor: violinEditor, + entityFields: () => ENTITY_FIELDS, + disconnect(host) { + disconnectHistory(host); + stopChartWidth(host); + stopCategoryHover(host); + }, + render(ctx: ChartContext) { + const now = Date.now(); + const frame = historyFrame(ctx, now); + const nav = periodNav(frame, ctx); + const layout = { ...chartLayout(ctx.raw), header: 'above' as const }; + const message = frameMessage(frame, ctx); + if (message) return chartFrame(layout, nav, message); + const o = violinOptions(ctx.raw); + const until = Math.min(frame.range.end, now); + const weighted = frame.source === 'history' && !frame.group; + const readings = frame.series.map((s) => readingsOf(s.points, weighted, until)); + const bounds = o.split === 'none' ? [] : boundaries(o.split, frame.range.start, until, frame.zone); + const groups = groupsOf(frame.series, readings, o, bounds, weighted, frame.zone, ctx.hass); + let lo = Infinity; + let hi = -Infinity; + for (const g of groups) { + if (!g.summary) continue; + // Room for the tails, which run a little past the lowest and highest readings. + const h = o.bandwidth ?? bandwidth(g.readings); + lo = Math.min(lo, g.summary.min - h); + hi = Math.max(hi, g.summary.max + h); + } + const options = categoryOptions(ctx.raw); + const probe = categoryGeometry(ctx, groups.length, { domain: [0, 1] }); + const scale = numberScale( + lo === Infinity ? undefined : [lo, hi], + ctx.raw, + labelCount(probe.valuePx, options.orientation), + ctx.hass, + ); + const geometry = categoryGeometry(ctx, groups.length, scale); + const content = svg`${groups.map((g, i) => drawGroup(g, i, geometry, o, scale.domain))}`; + const categories: Category[] = groups.map((g) => ({ + index: g.series.index, + name: g.name, + detail: g.detail, + color: g.series.color, + label: `${g.name}, ${g.detail}`, + })); + const tooltip = tooltipEnabled(ctx.raw); + const at = tooltip ? hoveredBand(ctx.host) : undefined; + const hovered = at === undefined ? undefined : groups[at]; + const label = localize(ctx.hass, 'chart_aria', { + style: localize(ctx.hass, o.shape === 'raincloud' ? 'violin_raincloud' : 'editor_style_violin'), + names: frame.series.map((s) => s.entity.name).join(', '), + }); + return chartFrame( + layout, + nav, + categoryPlot({ + ctx, + geometry, + categories, + scale, + content, + label, + hover: tooltip, + overlay: + hovered?.summary && at !== undefined + ? bandTooltip( + geometry, + at, + hovered.name, + pctRow(hovered.summary, hovered.series, ctx.hass), + hovered.series.color, + ) + : nothing, + }), + ); + }, +}; diff --git a/src/styles/waterfall.ts b/src/styles/waterfall.ts new file mode 100644 index 0000000..e24658c --- /dev/null +++ b/src/styles/waterfall.ts @@ -0,0 +1,452 @@ +import { css, html, nothing, svg } from 'lit'; +import type { TemplateResult } from 'lit'; +import { baseColor } from '../bands'; +import { disconnectHistory, frameMessage, frameStyles, historyFrame, validateHistoryOptions } from '../chart/frame'; +import type { HistoryFrame } from '../chart/frame'; +import { ROLLING_HISTORY_FIELDS, cachedSchema, chartForm, choice, rollingHistorySchema } from '../editor/chart-editors'; +import { entityOptionsForm } from '../editor/entity-options'; +import type { FieldBinding } from '../editor/schemas'; +import { formatNumber, withUnit } from '../format'; +import type { Point } from '../history/types'; +import { localize, registerStrings } from '../localize'; +import type { ResolvedEntity } from '../resolve'; +import type { CardConfig, HomeAssistant } from '../types'; +import type { ChartContext, ChartEditorContext, ChartStyleModule } from './index'; + +export const STEP_MODES = ['change', 'current'] as const; +export const ORIENTATIONS = ['vertical', 'horizontal'] as const; +type StepMode = (typeof STEP_MODES)[number]; + +registerStrings('en', { + editor_style_waterfall: 'Waterfall', + editor_steps: 'Each step is', + editor_steps_change: 'How much it changed in the period', + editor_steps_current: 'Its current value', + editor_orientation: 'Direction', + editor_orientation_vertical: 'Upright', + editor_orientation_horizontal: 'Across', + editor_start_from_first: "Start from the first entity's reading at the start of the period", + editor_total: 'Show the total at the end', + editor_waterfall_invert: 'Counts down (a rise in these is a drop in the total)', + waterfall_start: 'Start', + waterfall_end: 'End', + waterfall_total: 'Total', + waterfall_no_readings: 'No readings', + waterfall_aria: 'Waterfall chart: {bars}', +}); + +/** How much a series changed: its last reading less its first, or null with no readings. */ +export function change(points: Point[]): number | null { + const readings = points.filter((p) => p.v !== null); + if (readings.length === 0) return null; + return (readings[readings.length - 1].v as number) - (readings[0].v as number); +} + +/** The first reading in a series (at the start of the period), or null with none. */ +export function firstReading(points: Point[]): number | null { + const p = points.find((x) => x.v !== null); + return p ? (p.v as number) : null; +} + +export interface WaterfallBar { + kind: 'start' | 'step' | 'total'; + /** Level the bar starts at and ends at; a step goes from the running total before it to after. */ + from: number; + to: number; + /** How much the bar adds (the value itself for start and total bars). */ + delta: number; + /** Index into the chart's items; none for the total bar. */ + index?: number; + /** A step with no number: drawn as an empty outline that adds nothing. */ + missing: boolean; +} + +export interface StepInput { + value: number | null; + index: number; +} + +/** + * The bars of a waterfall: an optional start bar from zero, a step per input carrying the running + * total along, and an optional total bar from zero to where the steps ended. The domain covers + * every bar and zero, so bars always stand on a visible baseline. + */ +export function waterfallBars( + start: StepInput | null, + steps: StepInput[], + total: boolean, +): { bars: WaterfallBar[]; domain: [number, number] } { + const bars: WaterfallBar[] = []; + let level = 0; + if (start) { + const v = start.value ?? 0; + bars.push({ kind: 'start', from: 0, to: v, delta: v, index: start.index, missing: start.value === null }); + level = v; + } + for (const s of steps) { + const delta = s.value ?? 0; + bars.push({ kind: 'step', from: level, to: level + delta, delta, index: s.index, missing: s.value === null }); + level += delta; + } + if (total) bars.push({ kind: 'total', from: 0, to: level, delta: level, missing: false }); + let lo = 0; + let hi = 0; + for (const b of bars) { + lo = Math.min(lo, b.from, b.to); + hi = Math.max(hi, b.from, b.to); + } + if (lo === hi) hi = lo + 1; + return { bars, domain: [lo, hi] }; +} + +/** "+6.1" or "−3.4" (a real minus sign); no sign for zero. */ +export function signed(value: number, decimals: number, hass?: HomeAssistant): string { + const text = formatNumber(Math.abs(value), decimals, hass); + if (Number(value.toFixed(decimals)) === 0) return formatNumber(0, decimals, hass); + return `${value > 0 ? '+' : '−'}${text}`; +} + +function inverted(e: ResolvedEntity): boolean { + return (e.config as { invert?: unknown }).invert === true; +} + +function needsHistory(raw: CardConfig): boolean { + return (raw.steps ?? 'change') === 'change' || raw.start_from_first === true; +} + +/** Room left beyond the highest (and lowest, if below zero) bar for its label, as a share. */ +const HEADROOM = { vertical: 0.14, horizontal: 0.24 }; + +interface Placed extends WaterfallBar { + name: string; + label: string; + entity?: ResolvedEntity; +} + +function placeBars(ctx: ChartContext, frame: HistoryFrame | undefined): Placed[] { + const { items, raw, hass } = ctx; + const mode = (raw.steps as StepMode | undefined) ?? 'change'; + const fromFirst = raw.start_from_first === true; + const points = (i: number) => frame?.series[i]?.points ?? []; + const stepValue = (e: ResolvedEntity, i: number): number | null => { + const v = mode === 'change' ? change(points(i)) : e.available ? e.value : null; + return v === null ? null : inverted(e) ? -v : v; + }; + const start = fromFirst && items.length ? { value: firstReading(points(0)), index: 0 } : null; + const steps = items.map((e, index) => ({ value: stepValue(e, index), index })).slice(fromFirst ? 1 : 0); + const { bars } = waterfallBars(start, steps, raw.total !== false); + const decimals = items[0]?.decimals ?? 1; + return bars.map((b) => { + const entity = b.index === undefined ? undefined : items[b.index]; + const places = entity?.decimals ?? decimals; + let name: string; + let label: string; + if (b.kind === 'start') { + name = localize(hass, 'waterfall_start'); + label = b.missing ? localize(hass, 'waterfall_no_readings') : formatNumber(b.to, places, hass); + } else if (b.kind === 'total') { + name = localize(hass, start ? 'waterfall_end' : 'waterfall_total'); + label = formatNumber(b.to, decimals, hass); + } else { + name = entity!.name; + if (!b.missing) label = signed(b.delta, places, hass); + else label = entity!.available ? localize(hass, 'waterfall_no_readings') : entity!.display; + } + return { ...b, name, label, entity }; + }); +} + +function colorOf(b: WaterfallBar): string { + if (b.kind !== 'step') return baseColor('primary'); + return baseColor(b.delta < 0 ? 'red' : 'green'); +} + +function drawing(ctx: ChartContext, bars: Placed[]): TemplateResult { + const horizontal = ctx.raw.orientation === 'horizontal'; + const values = bars.flatMap((b) => [b.from, b.to]); + let lo = Math.min(0, ...values); + let hi = Math.max(0, ...values); + if (lo === hi) hi = lo + 1; + const room = (hi - lo) * HEADROOM[horizontal ? 'horizontal' : 'vertical']; + hi += room; + if (lo < 0) lo -= room; + const at = (v: number) => ((v - lo) / (hi - lo)) * 100; + const n = bars.length; + const slot = 100 / n; + // The bar fills the middle of its slot; the rest is the gap the connectors cross. + const fill = horizontal ? 0.64 : 0.6; + const c0 = (i: number) => slot * i + (slot * (1 - fill)) / 2; + const c1 = (i: number) => c0(i) + slot * fill; + const unit = ctx.items[0]?.unit ?? ''; + const aria = localize(ctx.hass, 'waterfall_aria', { + bars: bars.map((b) => `${b.name} ${b.missing ? b.label : withUnit(b.label, b.entity?.unit ?? unit)}`).join(', '), + }); + const entityAttrs = (b: Placed) => (b.index === undefined ? '' : 'entity'); + const marks = bars.map((b, i) => { + const a0 = at(Math.min(b.from, b.to)); + const a1 = at(Math.max(b.from, b.to)); + const vars = `--a0:${a0}%;--a1:${a1}%;--c0:${c0(i)}%;--c1:${c1(i)}%;--cm:${(c0(i) + c1(i)) / 2}%;--color:${colorOf(b)}`; + const next = i + 1 < n ? `--a:${at(b.to)}%;--c0:${c1(i)}%;--c1:${c0(i + 1)}%` : ''; + return html`
+ ${b.missing && !horizontal ? '\u2013' : b.label} + ${next ? html`
` : nothing}`; + }); + const names = bars.map( + (b) => + html`
+ ${b.name} +
`, + ); + return html`
+
${names}
+ +
`; +} + +const FIELDS: FieldBinding[] = [ + ...ROLLING_HISTORY_FIELDS, + { name: 'steps', path: ['steps'], default: 'change' }, + { name: 'orientation', path: ['orientation'], default: 'vertical' }, + { name: 'start_from_first', path: ['start_from_first'], default: false }, + { name: 'total', path: ['total'], default: true }, +]; + +export function waterfallEditor(ctx: ChartEditorContext): TemplateResult { + const schema = cachedSchema('waterfall', ctx.hass, () => [ + ...rollingHistorySchema(ctx.hass), + { + type: 'grid', + name: '', + schema: [ + { name: 'steps', selector: choice(ctx.hass, 'steps', STEP_MODES) }, + { name: 'orientation', selector: choice(ctx.hass, 'orientation', ORIENTATIONS) }, + ], + }, + { name: 'start_from_first', selector: { boolean: {} } }, + { name: 'total', selector: { boolean: {} } }, + ]); + return html`${chartForm(ctx, 'chart', schema, FIELDS)} + ${entityOptionsForm(ctx, 'waterfall_invert', 'invert', { boolean: {} }, false)}`; +} + +function checkBoolean(value: unknown, field: string): void { + if (value !== undefined && typeof value !== 'boolean') throw new Error(`"${field}" must be true or false`); +} + +export const waterfall: ChartStyleModule = { + layout: 'chart', + group: 'totals', + height: 240, + styles: css` + ${frameStyles} + .wf { + display: grid; + gap: 6px; + } + .wf.vertical { + grid-template-rows: 180px auto; + } + .wf.vertical .names { + grid-row: 2; + display: grid; + grid-template-columns: repeat(var(--n), minmax(0, 1fr)); + } + .wf.vertical .wf-plot { + grid-row: 1; + } + .wf.horizontal { + grid-template-columns: minmax(0, max-content) minmax(0, 1fr); + max-width: 100%; + } + .wf.horizontal .names { + display: grid; + grid-template-rows: repeat(var(--n), 30px); + max-width: 140px; + } + .wf.horizontal .wf-plot { + height: calc(var(--n) * 30px); + } + .wf-plot { + position: relative; + min-width: 0; + } + .bar-name { + min-width: 0; + display: flex; + align-items: center; + font-size: 0.8125em; + color: var(--secondary-text-color); + } + .bar-name span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .wf.vertical .bar-name { + justify-content: center; + padding: 0 2px; + } + .bar-name.entity { + cursor: pointer; + border-radius: 4px; + } + .bar { + position: absolute; + box-sizing: border-box; + background: linear-gradient(var(--color), color-mix(in srgb, var(--color) 80%, #fff)); + border-radius: 4px; + transition: transform var(--cd-duration) var(--cd-easing); + } + .bar.entity { + cursor: pointer; + } + .bar.missing { + background: none; + border: 1.5px dashed var(--secondary-text-color); + opacity: 0.6; + } + .wf.vertical .bar { + left: var(--c0); + width: calc(var(--c1) - var(--c0)); + bottom: var(--a0); + height: max(calc(var(--a1) - var(--a0)), 2px); + transform-origin: 50% 100%; + transform: scaleY(0); + } + .wf.horizontal .bar { + top: var(--c0); + height: calc(var(--c1) - var(--c0)); + left: var(--a0); + width: max(calc(var(--a1) - var(--a0)), 2px); + transform-origin: 0 50%; + transform: scaleX(0); + } + .wf.vertical .bar.missing { + height: 16px; + bottom: calc(var(--a0) - 8px); + } + .wf.horizontal .bar.missing { + width: 16px; + left: calc(var(--a0) - 8px); + } + :host([ready]) .wf .bar { + transform: none; + } + .bar-label { + position: absolute; + font-size: 0.8125em; + font-weight: 500; + line-height: 1; + white-space: nowrap; + pointer-events: none; + color: var(--primary-text-color); + } + .bar-label:not(.start):not(.total):not(.missing) { + color: var(--color); + } + .bar-label.missing { + color: var(--secondary-text-color); + font-weight: 400; + } + .wf.horizontal .bar-label.missing { + margin-left: 10px; + } + .wf.vertical .bar-label { + left: var(--cm); + bottom: calc(var(--a1) + 4px); + transform: translateX(-50%); + } + .wf.horizontal .bar-label { + left: calc(var(--a1) + 6px); + top: var(--cm); + transform: translateY(-50%); + } + .connector { + position: absolute; + pointer-events: none; + border: 0 dashed var(--secondary-text-color); + opacity: 0.6; + } + .wf.vertical .connector { + left: var(--c0); + width: calc(var(--c1) - var(--c0)); + bottom: var(--a); + border-top-width: 1px; + } + .wf.horizontal .connector { + top: var(--c0); + height: calc(var(--c1) - var(--c0)); + left: var(--a); + border-left-width: 1px; + } + /* Narrow cards: smaller text so neighbouring columns' labels don't run into each other. */ + @container (max-width: 400px) { + .wf .bar-label, + .wf .bar-name { + font-size: 0.6875em; + } + .wf.horizontal .names { + max-width: 96px; + } + } + .zero { + position: absolute; + pointer-events: none; + background: var(--divider-color, rgba(127, 127, 127, 0.3)); + } + .wf.vertical .zero { + left: 0; + right: 0; + bottom: var(--a); + height: 1px; + } + .wf.horizontal .zero { + top: 0; + bottom: 0; + left: var(--a); + width: 1px; + } + `, + thumbnail: () => svg` + + + + + `, + validate(raw: CardConfig) { + validateHistoryOptions(raw); + if (raw.steps !== undefined && !STEP_MODES.includes(raw.steps as StepMode)) { + throw new Error(`"steps" must be one of: ${STEP_MODES.join(', ')}`); + } + if (raw.orientation !== undefined && !ORIENTATIONS.includes(raw.orientation as (typeof ORIENTATIONS)[number])) { + throw new Error(`"orientation" must be one of: ${ORIENTATIONS.join(', ')}`); + } + checkBoolean(raw.start_from_first, 'start_from_first'); + checkBoolean(raw.total, 'total'); + for (const e of raw.entities ?? []) { + if (typeof e === 'object') checkBoolean((e as { invert?: unknown }).invert, 'invert'); + } + }, + editor: waterfallEditor, + disconnect: disconnectHistory, + render(ctx) { + let frame: HistoryFrame | undefined; + if (needsHistory(ctx.raw)) { + frame = historyFrame(ctx); + const message = frameMessage(frame, ctx); + if (message) return message; + } + return drawing(ctx, placeBars(ctx, frame)); + }, +}; diff --git a/src/styles/wind-rose.ts b/src/styles/wind-rose.ts new file mode 100644 index 0000000..69a6252 --- /dev/null +++ b/src/styles/wind-rose.ts @@ -0,0 +1,646 @@ +import { css, html, nothing, svg } from 'lit'; +import type { TemplateResult } from 'lit'; +import { baseColor } from '../bands'; +import { DEFAULT_BANDS } from '../config'; +import { disconnectHistory, frameMessage, frameStyles, periodNav, validateHistoryOptions } from '../chart/frame'; +import type { HistoryFrame } from '../chart/frame'; +import { chartFrame, chartLayout, layoutStyles, validateLayout } from '../chart/layout'; +import { tooltipEnabled, tooltipStyles, validateTooltipOptions } from '../chart/tooltip'; +import { + SECTOR_COUNTS, + pairWind, + petalPath, + polarPoint, + ringSteps, + sectorName, + speedDefaults, + strongest, + windRose, +} from '../chart/wind'; +import type { Rose, SectorCount } from '../chart/wind'; +import { windRoseEditor } from '../editor/wind-rose-editor'; +import { key } from '../history/fetch'; +import type { StatisticsQuery } from '../history/fetch'; +import { PERIOD_MS } from '../history/group'; +import { parsePeriod, periodRange, stepsFor } from '../history/period'; +import type { Period } from '../history/period'; +import { storeFor } from '../history/store'; +import { zoneFor } from '../history/timezone'; +import type { Point, Range, SeriesRequest, Source } from '../history/types'; +import { formatNumber, withUnit } from '../format'; +import { localize, registerStrings } from '../localize'; +import { resolveBands } from '../resolve'; +import type { CardConfig, MergedEntity, ResolvedBand } from '../types'; +import type { ChartContext, ChartStyleModule } from './index'; + +registerStrings('en', { + editor_style_wind_rose: 'Wind rose', + wind_prevailing: 'Prevailing', + wind_mean_speed: 'Mean speed', + wind_strongest: 'Strongest', + wind_calm: 'Calm', + wind_speed: 'Speed', + wind_speed_unit: 'Speed ({unit})', + wind_share: 'Of the time', + wind_aria: 'Wind rose, prevailing {direction} {share}', + wind_aria_calm: 'Wind rose, prevailing {direction} {share}, calm {calm}', + wind_aria_still: 'Wind rose, calm {calm}', +}); + +/** A rolling month: long enough for the pattern to show, short enough to load quickly. */ +export const DEFAULT_WIND_PERIOD = { last: 30, unit: 'day' }; +const DEFAULT_SECTORS: SectorCount = 16; + +/** The card's period, with any key it leaves out taken from the wind rose's month. */ +export function windPeriod(raw: CardConfig): Period { + const own = raw.period; + if (own === undefined || own === null) return parsePeriod(DEFAULT_WIND_PERIOD as CardConfig['period']); + if (typeof own !== 'object' || Array.isArray(own)) return parsePeriod(own as CardConfig['period']); + return parsePeriod({ ...DEFAULT_WIND_PERIOD, ...(own as object) } as CardConfig['period']); +} + +/** `sectors` from YAML (or the editor, which hands back text): 4, 8 or 16. */ +export function windSectors(raw: CardConfig): SectorCount { + const v = raw.sectors; + if (v === undefined || v === null || v === '') return DEFAULT_SECTORS; + const n = typeof v === 'string' ? Number(v) : v; + if (!SECTOR_COUNTS.includes(n as SectorCount)) { + throw new Error(`"sectors" must be one of: ${SECTOR_COUNTS.join(', ')}`); + } + return n as SectorCount; +} + +function calmOption(raw: CardConfig): number | undefined { + const v = raw.calm; + if (v === undefined || v === null || v === '') return undefined; + const n = typeof v === 'string' ? Number(v) : v; + if (typeof n !== 'number' || !Number.isFinite(n) || n < 0) throw new Error('"calm" must be a number of 0 or more'); + return n; +} + +/** One of the two sensors a wind rose reads, and where it sits in the drawn entities (or -1). */ +interface Role { + id: string; + entity: MergedEntity; + index: number; +} + +/** + * Which entity is which: the first listed is the direction, the second the speed. Taken from the + * listed order, not the drawn one, so sorting or hiding unavailable entities can't swap them (a + * direction reported as compass points reads as unavailable to the rest of the card). + */ +export function windRoles(ctx: Pick): { direction?: Role; speed?: Role } { + const listed = ctx.config.entities.length ? ctx.config.entities : ctx.items.map((e) => e.config); + const role = (entity: MergedEntity | undefined): Role | undefined => + entity && { id: entity.entity, entity, index: ctx.items.findIndex((e) => e.entityId === entity.entity) }; + return { direction: role(listed[0]), speed: role(listed[1]) }; +} + +/** Everything the rose is drawn from. */ +interface WindFrame { + frame: HistoryFrame; + direction: Point[]; + speed?: Point[]; + until: number; + maxHold: number; +} + +/** Statistics rows to ask for: 5-minute ones for two days or less, else hourly. */ +function statisticsQuery(range: Range): StatisticsQuery { + const period = range.end - range.start <= 2 * 86_400_000 ? '5minute' : 'hour'; + return { period, types: ['mean', 'max'], value: 'mean' }; +} + +function loadWind(ctx: ChartContext, direction: Role, speed: Role | undefined, now = Date.now()): WindFrame { + const period = windPeriod(ctx.raw); + 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 range: Range = { start: span.start, end: span.end }; + const requests: SeriesRequest[] = [ + { entity: direction.id, attribute: direction.entity.attribute, bearing: true }, + ...(speed ? [{ entity: speed.id, attribute: speed.entity.attribute }] : []), + ]; + const statistics = source === 'statistics' ? statisticsQuery(range) : undefined; + const rowMs = statistics ? PERIOD_MS[statistics.period] : Infinity; + const load = statistics ? { start: range.start - rowMs, end: range.end } : range; + const loaded = storeFor(ctx.host, ctx.requestUpdate).get(ctx.hass, requests, load, source, { + live: span.live, + statistics, + }); + const points = (r: SeriesRequest) => loaded.data.get(key(r)) ?? []; + const dirPoints = points(requests[0]); + const speedPoints = speed ? points(requests[1]) : undefined; + const frame: HistoryFrame = { + status: loaded.status, + error: loaded.error, + range, + period, + source, + series: [], + live: span.live, + back: span.back, + zone, + }; + return { + frame, + direction: dirPoints, + // A speed sensor with no readings at all (missing, unavailable all along) leaves a rose of + // directions only, rather than nothing. + speed: speedPoints?.some((p) => p.v !== null) ? speedPoints : undefined, + until: Math.min(range.end, now), + maxHold: rowMs, + }; +} + +/** A speed band as drawn: where it starts, its colour and its legend text. */ +interface SpeedBand { + from: number; + color: string; + label: string; +} + +/** + * Sequential colours for the default bands, from the theme's primary colour: mixed towards the + * card's background for light winds and towards the text colour for strong ones, the strongest + * turning purple. In a dark theme the same mixes run dark to light, so strong winds still stand out. + */ +const RAMP = [ + 'color-mix(in srgb, var(--primary-color, #2f6fe4) 35%, var(--card-background-color, #fff))', + 'color-mix(in srgb, var(--primary-color, #2f6fe4) 62%, var(--card-background-color, #fff))', + 'var(--primary-color, #2f6fe4)', + 'color-mix(in srgb, var(--primary-color, #2f6fe4) 68%, var(--primary-text-color, #212121))', + 'color-mix(in srgb, var(--purple-color, #8b6cff) 80%, var(--primary-text-color, #212121))', +]; + +const numberText = (n: number, ctx: ChartContext) => formatNumber(n, Number.isInteger(n) ? 0 : 1, ctx.hass); + +function rangeLabel(from: number, next: number | undefined, ctx: ChartContext): string { + if (!Number.isFinite(from)) return next === undefined ? '' : `< ${numberText(next, ctx)}`; + return next === undefined ? `${numberText(from, ctx)}+` : `${numberText(from, ctx)}–${numberText(next, ctx)}`; +} + +/** The card's colour bands when it has any (on the speed entity or the card), else the defaults. */ +export function speedBands(bands: ResolvedBand[], defaults: number[], ctx: ChartContext): SpeedBand[] { + if (bands !== DEFAULT_BANDS && bands.length > 0) { + return bands.map((b, i) => ({ + from: b.from, + color: baseColor(b.color), + label: b.label ?? rangeLabel(b.from, bands[i + 1]?.from, ctx), + })); + } + return defaults.map((from, i) => ({ + from, + color: RAMP[i % RAMP.length], + label: rangeLabel(from, defaults[i + 1], ctx), + })); +} + +/** A share (0–1) as a percentage: whole numbers, with one decimal under 1 % (or with `fine`). */ +function shareText(share: number, ctx: ChartContext, fine = false): string { + const pct = share * 100; + if (pct === 0) return `${formatNumber(0, 0, ctx.hass)}%`; + return `${formatNumber(pct, fine || pct < 1 ? 1 : 0, ctx.hass)}%`; +} + +/** Which sector is under the pointer or focus, per card. */ +const hovered = new WeakMap(); + +function setHover(ctx: ChartContext, sector: number | undefined) { + if (hovered.get(ctx.host) === sector) return; + if (sector === undefined) hovered.delete(ctx.host); + else hovered.set(ctx.host, sector); + ctx.requestUpdate(); +} + +const R = 100; +const R0 = 10; +const BOX = 124; + +interface Drawn { + rose: Rose; + n: number; + bands: SpeedBand[]; + rings: number[]; + /** Radius for a share (0–1). */ + radius(share: number): number; +} + +/** + * Where to write the ring values: along the edge between two sectors, choosing the edge whose + * neighbouring petals are shortest, so the numbers sit in a gap rather than on a petal. + */ +function ringLabelAngle(totals: number[]): number { + const n = totals.length; + let best = 0; + let bestLength = Infinity; + // Start at the north-east and go round, so a tie keeps the labels top right. + for (let k = 0; k < n; k++) { + const edge = (Math.floor(n / 8) + k) % n; + const length = Math.max(totals[edge], totals[(edge + 1) % n]); + if (length < bestLength - 1e-9) { + best = edge; + bestLength = length; + } + } + return ((best + 0.5) * 2 * Math.PI) / n; +} + +function roseSvg(d: Drawn, direction: Role, label: string, ctx: ChartContext): TemplateResult { + const { rose, n, bands, rings } = d; + const step = (2 * Math.PI) / n; + const pad = step * 0.09; + const tooltip = tooltipEnabled(ctx.raw); + const petals = rose.sectors.map((row, i) => { + if (rose.totals[i] <= 0) return nothing; + const a0 = i * step - step / 2 + pad; + const a1 = i * step + step / 2 - pad; + let acc = 0; + const parts = row.map((share, b) => { + if (share <= 0) return nothing; + const path = petalPath(d.radius(acc), d.radius(acc + share), a0, a1); + acc += share; + return svg``; + }); + const name = sectorName(i, n); + const interactive = direction.index >= 0; + return svg` setHover(ctx, i) : nothing} + @pointerleave=${tooltip ? () => setHover(ctx, undefined) : nothing} + @focus=${tooltip ? () => setHover(ctx, i) : nothing} + @blur=${tooltip ? () => setHover(ctx, undefined) : nothing} + >${parts}`; + }); + const ringAngle = ringLabelAngle(rose.totals); + const compass = (['N', 'E', 'S', 'W'] as const).map((name, q) => { + const [x, y] = polarPoint(R + 13, (q * Math.PI) / 2); + return svg`${name}`; + }); + return html` + ${rings.map((p) => svg``)} + + + + ${petals} + + ${compass} + ${rings.map((p) => { + const [x, y] = polarPoint(d.radius(p / 100), ringAngle); + const anchor = Math.sin(ringAngle) >= 0 ? 'start' : 'end'; + return svg`${formatNumber( + p, + Number.isInteger(p) ? 0 : 1, + ctx.hass, + )}%`; + })} + `; +} + +/** The hovered sector's share and its split by speed band, beside the petal's tip. */ +function roseTooltip(d: Drawn, speedUnit: string | undefined, ctx: ChartContext): TemplateResult | typeof nothing { + const i = hovered.get(ctx.host); + if (i === undefined || !tooltipEnabled(ctx.raw) || !(d.rose.totals[i] > 0)) return nothing; + const angle = (i * 2 * Math.PI) / d.n; + const [x, y] = polarPoint(d.radius(d.rose.totals[i]), angle); + // The box goes towards the middle, so it stays over the rose rather than off the card. + const side = x > 0 ? 'left' : 'right'; + const rows = + speedUnit === undefined + ? [{ color: d.bands[0].color, name: localize(ctx.hass, 'wind_share'), share: d.rose.totals[i] }] + : d.rose.sectors[i] + .map((share, b) => ({ color: d.bands[b].color, name: withUnit(d.bands[b].label, speedUnit), share })) + .filter((r) => r.share > 0); + return html``; +} + +function stat(label: string, value: string, role: Role | undefined, kind: string): TemplateResult { + const interactive = role !== undefined && role.index >= 0; + return html`
+ ${label} + ${value} +
`; +} + +function readout( + d: Drawn, + roles: { direction: Role; speed?: Role }, + speed: { unit: string; decimals: number; points: Point[] } | undefined, + ctx: ChartContext, +): TemplateResult { + const t = (k: string, vars?: Record) => localize(ctx.hass, k, vars); + const { rose } = d; + const prevailing = + rose.prevailing < 0 ? '—' : `${sectorName(rose.prevailing, d.n)} · ${shareText(rose.totals[rose.prevailing], ctx)}`; + const speedText = (v: number | null) => + v === null ? '—' : withUnit(formatNumber(v, speed?.decimals ?? 1, ctx.hass), speed?.unit ?? ''); + return html`
+
+ ${stat(t('wind_prevailing'), prevailing, roles.direction, 'prevailing')} + ${ + speed + ? html`${stat(t('wind_mean_speed'), speedText(rose.meanSpeed), roles.speed, 'mean')} + ${stat(t('wind_strongest'), speedText(strongest(speed.points)), roles.speed, 'strongest')} + ${stat(t('wind_calm'), shareText(rose.calm, ctx), roles.speed, 'calm')}` + : nothing + } +
+ ${ + speed + ? html`
+
${speed.unit ? t('wind_speed_unit', { unit: speed.unit }) : t('wind_speed')}
+
+ ${d.bands.map( + (b) => + html`
+ + ${b.label} +
`, + )} +
+
` + : nothing + } +
`; +} + +export const windRoseStyle: ChartStyleModule = { + layout: 'chart', + group: 'distribution', + height: 280, + styles: css` + ${frameStyles} + ${tooltipStyles} + ${layoutStyles} + .wind { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: 8px 20px; + } + .rose-wrap { + position: relative; + flex: 1 1 200px; + max-width: 270px; + aspect-ratio: 1; + } + .rose { + display: block; + width: 100%; + height: 100%; + overflow: visible; + } + .rose .ring { + fill: none; + stroke: var(--divider-color, rgba(127, 127, 127, 0.3)); + stroke-dasharray: 3 3; + } + .rose .rim, + .rose .spoke { + fill: none; + stroke: var(--divider-color, rgba(127, 127, 127, 0.3)); + } + .rose .calm-circle { + fill: var(--card-background-color, #fff); + stroke: var(--divider-color, rgba(127, 127, 127, 0.3)); + } + .rose text { + font-family: inherit; + fill: var(--secondary-text-color); + text-anchor: middle; + font-size: 12px; + } + .rose .compass.north { + fill: var(--primary-text-color); + font-weight: 500; + } + .rose .ring-label { + font-size: 10px; + paint-order: stroke; + stroke: var(--card-background-color, #fff); + stroke-width: 3px; + stroke-linejoin: round; + } + .rose .petal { + outline: none; + transform-box: view-box; + transform-origin: 0 0; + transform: scale(0); + transition: transform var(--cd-duration) var(--cd-easing); + } + :host([ready]) .rose .petal { + transform: scale(1); + } + .rose .petal.entity { + cursor: pointer; + } + .rose .petal:focus-visible path { + stroke: var(--primary-text-color); + stroke-width: 1.5px; + } + .rose-tip { + top: 0; + white-space: nowrap; + } + .tooltip.rose-tip.right { + transform: translate(10px, -50%); + } + .tooltip.rose-tip.left { + transform: translate(calc(-100% - 10px), -50%); + } + .readout { + flex: 1 1 150px; + min-width: 0; + display: flex; + flex-direction: column; + gap: 14px; + } + .stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); + gap: 8px 16px; + } + .stat { + display: flex; + flex-direction: column; + min-width: 0; + border-radius: 4px; + } + .stat.entity { + cursor: pointer; + } + .stat:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: 2px; + } + .stat-label { + color: var(--secondary-text-color); + font-size: 0.875em; + } + .stat-value { + font-size: 1.25em; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-variant-numeric: tabular-nums; + } + .keys { + display: flex; + gap: 4px; + margin-top: 6px; + } + .key { + flex: 1 1 0; + min-width: 0; + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + } + .key-swatch { + width: 100%; + height: 10px; + border-radius: 2px; + } + .key-label { + font-size: 11px; + color: var(--secondary-text-color); + white-space: nowrap; + font-variant-numeric: tabular-nums; + } + .frame.compact .rose-wrap { + max-width: 170px; + flex-basis: 150px; + } + .frame.compact .wind { + gap: 4px 12px; + } + .frame.compact .readout { + gap: 8px; + } + .frame.compact .stats { + gap: 4px 12px; + } + .frame.compact .stat-value { + font-size: 1.05em; + } + `, + thumbnail: () => svg` + + + + + + `, + validate(raw: CardConfig) { + validateHistoryOptions(raw); + windPeriod(raw); + validateTooltipOptions(raw); + validateLayout(raw); + windSectors(raw); + calmOption(raw); + }, + editor: windRoseEditor, + disconnect(host) { + disconnectHistory(host); + hovered.delete(host); + }, + render(ctx) { + const layout = { ...chartLayout(ctx.raw), header: 'above' as const }; + const roles = windRoles(ctx); + const direction = roles.direction; + if (!direction) return html``; + const loaded = loadWind(ctx, direction, roles.speed); + const { frame } = loaded; + const nav = periodNav(frame, ctx); + const noData = html`
${localize(ctx.hass, 'chart_no_data')}
`; + const message = + frame.status !== 'ready' + ? frameMessage(frame, ctx) + : loaded.direction.every((p) => p.v === null) + ? noData + : undefined; + if (message) return chartFrame(layout, nav, message); + + const n = windSectors(ctx.raw); + const speedRole = loaded.speed ? roles.speed : undefined; + const unit = speedRole + ? (speedRole.entity.unit ?? + (speedRole.entity.attribute + ? '' + : String(ctx.hass.states[speedRole.id]?.attributes?.unit_of_measurement ?? ''))) + : undefined; + const defaults = speedDefaults(unit); + const bands: SpeedBand[] = speedRole + ? speedBands(resolveBands(speedRole.entity.severity, ctx.hass), defaults.from, ctx) + : [{ from: 0, color: 'var(--primary-color, #2f6fe4)', label: '' }]; + const calm = speedRole ? (calmOption(ctx.raw) ?? defaults.calm) : 0; + const samples = pairWind(loaded.direction, loaded.speed, loaded.until, loaded.maxHold); + const rose = windRose( + samples, + n, + bands.map((b) => b.from), + calm, + ); + if (rose.total <= 0) return chartFrame(layout, nav, noData); + const longest = Math.max(0, ...rose.totals) * 100; + const rings = ringSteps(longest); + const top = (rings[rings.length - 1] ?? 100) / 100; + const drawn: Drawn = { rose, n, bands, rings, radius: (share) => R0 + (share / top) * (R - R0) }; + + const pct = (s: number) => shareText(s, ctx); + const vars = { + direction: rose.prevailing < 0 ? '' : sectorName(rose.prevailing, n), + share: rose.prevailing < 0 ? '' : pct(rose.totals[rose.prevailing]), + calm: pct(rose.calm), + }; + const label = localize( + ctx.hass, + rose.prevailing < 0 ? 'wind_aria_still' : speedRole ? 'wind_aria_calm' : 'wind_aria', + vars, + ); + const speedInfo = speedRole + ? { + unit: unit ?? '', + decimals: speedRole.index >= 0 ? ctx.items[speedRole.index].decimals : 1, + points: loaded.speed ?? [], + } + : undefined; + return chartFrame( + layout, + nav, + html`
+
+ ${roseSvg(drawn, direction, label, ctx)} ${roseTooltip(drawn, speedInfo ? speedInfo.unit : undefined, ctx)} +
+ ${readout(drawn, { direction, speed: speedRole }, speedInfo, ctx)} +
`, + ); + }, +}; diff --git a/src/types.ts b/src/types.ts index 6c7cbbe..6b41d11 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,3 +1,4 @@ +import type { GaugeOptions } from './gauge'; import type { Rule } from './fill'; export type StyleName = | 'bar' @@ -19,7 +20,31 @@ export type StyleName = | 'scatter' | 'bubble' | 'heatmap' - | 'box'; + | 'box' + | 'funnel' + | 'radar' + | 'polar_area' + | 'treemap' + | 'sunburst' + | 'icicle' + | 'grouped_bars' + | 'bullet' + | 'lollipop' + | 'waterfall' + | 'slope' + | 'dumbbell' + | 'range_area' + | 'sparkline' + | 'streamgraph' + | 'candlestick' + | 'violin' + | 'beeswarm' + | 'sankey' + | 'timeline' + | 'climate' + | 'wind_rose' + | 'day_clock' + | 'trend'; export const STYLE_NAMES: StyleName[] = [ 'bar', 'thin', @@ -41,6 +66,30 @@ export const STYLE_NAMES: StyleName[] = [ 'bubble', 'heatmap', 'box', + 'funnel', + 'radar', + 'polar_area', + 'treemap', + 'sunburst', + 'icicle', + 'grouped_bars', + 'bullet', + 'lollipop', + 'waterfall', + 'slope', + 'dumbbell', + 'range_area', + 'sparkline', + 'streamgraph', + 'candlestick', + 'violin', + 'beeswarm', + 'sankey', + 'timeline', + 'climate', + 'wind_rose', + 'day_clock', + 'trend', ]; export type SortMode = 'none' | 'ascending' | 'descending'; /** A number, or a string naming an entity whose state supplies the number. */ @@ -128,6 +177,8 @@ export interface MergedEntity extends EntityOptions { tap_action: ActionConfig; hold_action: ActionConfig; double_tap_action: ActionConfig; + /** Ring, arc and needle: angles, centre text and tick labels (the card's). */ + gauge?: GaugeOptions; } export interface NormalizedConfig { title?: string; diff --git a/test/annotations.test.ts b/test/annotations.test.ts new file mode 100644 index 0000000..1b347bb --- /dev/null +++ b/test/annotations.test.ts @@ -0,0 +1,457 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import '../src/chartdeck-card'; +import { + annotationEntities, + annotationValues, + isTimeText, + labelWidth, + markTimes, + placeMarkLabels, + pointFor, + resolveValue, + stateSpans, + timeList, + validateAnnotations, +} from '../src/chart/annotations'; +import type { ChartSeries } from '../src/chart/frame'; +import { + annotationData, + annotationFromData, + annotationSchema, + withAnnotations, +} from '../src/editor/annotations-editor'; +import type { CardConfig, HomeAssistant } from '../src/types'; +import { makeHass } from './helpers/hass'; + +const check = + (annotations: unknown, extra: Record = {}) => + () => + validateAnnotations({ type: 'x', annotations, ...extra } as CardConfig); + +describe('annotation options', () => { + it('accepts every kind, and explains a bad one', () => { + expect( + check([ + { type: 'line', value: 21, label: 'Target', color: 'red', dash: false }, + { type: 'line', value: 'input_number.target' }, + { type: 'line', value: '21.5' }, + { type: 'band', from: 18, to: 'sensor.high' }, + { type: 'band', to: 5 }, + { type: 'time', times: ['07:00', '2026-10-28 22:30', '2026-10-28T08:00:00Z'] }, + { type: 'time', times: '07:00, 19:00:30' }, + { type: 'state', entity: 'switch.heater', state: 'on', shade: true }, + { type: 'point', at: 'min', entity: 'sensor.in' }, + ]), + ).not.toThrow(); + expect(check(undefined)).not.toThrow(); + expect(check('x')).toThrow('"annotations" must be a list'); + expect(check([7])).toThrow('"annotations[0]" must have a "type"'); + expect(check([{ type: 'arrow' }])).toThrow('"annotations[0].type" must be one of: line, band, time, state, point'); + expect(check([{ type: 'line' }])).toThrow('"annotations[0].value" is needed for a line'); + expect(check([{ type: 'line', value: 'twenty' }])).toThrow( + '"annotations[0].value" must be a number or an entity id', + ); + expect(check([{ type: 'band' }])).toThrow('"annotations[0].from" or "annotations[0].to" is needed for a band'); + expect(check([{ type: 'line', value: 1, label: 2 }])).toThrow('"annotations[0].label" must be text'); + expect(check([{ type: 'line', value: 1, dash: 'no' }])).toThrow('"annotations[0].dash" must be true or false'); + expect(check([{ type: 'time', times: 7 }])).toThrow('"annotations[0].times" must be a time or a list of times'); + expect(check([{ type: 'time', times: ['25:00'] }])).toThrow('has "25:00": use 07:00 or 2026-10-28 07:00'); + expect(check([{ type: 'state' }])).toThrow('"annotations[0].entity" is needed for a state mark'); + expect(check([{ type: 'state', entity: 'Heater' }])).toThrow('"annotations[0].entity" must be an entity id'); + expect(check([{ type: 'point', at: 'middle' }])).toThrow( + '"annotations[0].at" must be one of: max, min, last, first', + ); + expect(check(undefined, { zones: 'yes' })).toThrow('"zones" must be true or false'); + }); + + it('reads times', () => { + expect(isTimeText('7:05')).toBe(true); + expect(isTimeText('24:00')).toBe(false); + expect(isTimeText('2026-10-28 07:00')).toBe(true); + expect(isTimeText('2026-10-28T07:00:00+01:00')).toBe(true); + expect(isTimeText('tomorrow')).toBe(false); + expect(timeList(' 07:00 ,, 08:00')).toEqual(['07:00', '08:00']); + expect(timeList(undefined)).toEqual([]); + }); + + it('resolves values from numbers and entities, and lists the entities to watch', () => { + const hass = makeHass({ 'input_number.t': '21.5', 'sensor.off': 'unavailable' }); + expect(resolveValue(3, hass)).toBe(3); + expect(resolveValue('4', hass)).toBe(4); + expect(resolveValue('input_number.t', hass)).toBe(21.5); + expect(resolveValue('sensor.off', hass)).toBeUndefined(); + expect(resolveValue('sensor.gone', hass)).toBeUndefined(); + expect(resolveValue(undefined, hass)).toBeUndefined(); + const raw = { + type: 'x', + annotations: [ + { type: 'line', value: 'input_number.t' }, + { type: 'line', value: 30 }, + { type: 'line', value: 'sensor.off' }, + { type: 'band', from: 'sensor.low', to: 9 }, + { type: 'state', entity: 'switch.heater' }, + { type: 'point', entity: 'sensor.in' }, + ], + } as CardConfig; + expect(annotationEntities(raw)).toEqual(['input_number.t', 'sensor.off', 'sensor.low', 'switch.heater']); + expect(annotationValues(raw, hass)).toEqual([21.5, 30]); + expect(annotationEntities({ type: 'x' } as CardConfig)).toEqual([]); + }); +}); + +describe('marks in time', () => { + const london = { timeZone: 'Europe/London', firstDay: 1 }; + + it('puts a daily time on every day in the range, in the zone, and single times once', () => { + const start = Date.UTC(2026, 9, 24, 12); + const end = Date.UTC(2026, 9, 26, 12); + // British Summer Time ends on 25 October: 07:00 is 06:00 UTC before and 07:00 UTC after. + expect(markTimes(['07:00'], { start, end }, london)).toEqual([Date.UTC(2026, 9, 25, 7), Date.UTC(2026, 9, 26, 7)]); + expect(markTimes(['2026-10-24 18:30', '2026-10-24T20:00:00Z', '2026-12-01 00:00'], { start, end }, london)).toEqual( + [Date.UTC(2026, 9, 24, 17, 30), Date.UTC(2026, 9, 24, 20)], + ); + }); + + it('finds the stretches an entity spent in a state', () => { + const changes = [ + { t: 0, s: 'off' }, + { t: 10, s: 'on' }, + { t: 15, s: 'on' }, + { t: 20, s: 'off' }, + { t: 30, s: 'ON' }, + ]; + expect(stateSpans(changes, 'on', { start: 5, end: 40 }, 35)).toEqual([ + { start: 10, end: 20, s: 'on' }, + { start: 30, end: 35, s: 'ON' }, + ]); + // No state: every change, the first clipped to the range. + expect(stateSpans(changes, undefined, { start: 5, end: 25 }, 25).map((s) => s.start)).toEqual([5, 10, 20]); + expect(stateSpans(changes, 'on', { start: 21, end: 25 }, 25)).toEqual([]); + }); + + it('keeps mark labels apart in two rows, flipping them at the right edge', () => { + expect(labelWidth('Alarm')).toBe(39); + const rows = placeMarkLabels( + [ + { x: 50, width: 50 }, + { x: 10, width: 50 }, + { x: 40, width: 50 }, + { x: 70, width: 50 }, + { x: 290, width: 50 }, + ], + 300, + ); + expect(rows).toEqual([undefined, 0, 1, 0, 0]); + }); + + it('picks the reading a point label sits on', () => { + const s = { + points: [ + { t: 0, v: 5, carried: true }, + { t: 1, v: 3 }, + { t: 2, v: null }, + { t: 3, v: 9 }, + { t: 10, v: 9 }, + ], + } as unknown as ChartSeries; + const range = { start: 0, end: 10 }; + expect(pointFor(s, 'max', range, false)!.t).toBe(3); + expect(pointFor(s, 'min', range, false)!.t).toBe(1); + expect(pointFor(s, 'first', range, false)!.t).toBe(1); + expect(pointFor(s, 'last', range, false)!.t).toBe(10); + expect(pointFor(s, 'first', range, true)!.t).toBe(0); + expect(pointFor({ points: [] } as unknown as ChartSeries, 'max', range, false)).toBeUndefined(); + }); +}); + +type Card = HTMLElement & { setConfig(c: CardConfig): void; hass?: HomeAssistant; updateComplete: Promise }; +const now = Date.now(); +const H = 3_600_000; +const sec = (ago: number) => (now - ago) / 1000; + +function hass(failStates = false) { + const h = makeHass({ + 'sensor.in': ['21', { friendly_name: 'Inside', unit_of_measurement: '°C' }], + 'sensor.out': ['9', { friendly_name: 'Outside', unit_of_measurement: '°C' }], + 'input_number.target': '30', + 'switch.heater': 'on', + }); + h.states['switch.heater'].last_changed = new Date(now - H).toISOString(); + h.callWS = vi.fn(async (msg: Record) => { + const ids = msg.entity_ids as string[]; + if (ids.includes('switch.heater')) { + if (failStates) throw new Error('nope'); + return { + 'switch.heater': [ + { s: 'off', lu: sec(30 * H) }, + { s: 'on', lu: sec(10 * H) }, + { s: 'off', lu: sec(8 * H) }, + { s: 'on', lu: sec(4 * H) }, + { s: 'off', lu: sec(3 * H) }, + ], + }; + } + return { + 'sensor.in': [ + { s: '19', lu: sec(20 * H) }, + { s: '23', lu: sec(12 * H) }, + { s: '21', lu: sec(2 * H) }, + ], + 'sensor.out': [{ s: '5', lu: sec(20 * H) }], + }; + }) as HomeAssistant['callWS']; + return h; +} + +async function mount(extra: Record, h = hass()) { + const card = document.createElement('chartdeck-card') as Card; + card.setConfig({ + type: 'custom:chartdeck-card', + style: 'line', + entities: ['sensor.in', 'sensor.out'], + ...extra, + } as CardConfig); + card.hass = h; + document.body.appendChild(card); + for (let i = 0; i < 3; i++) { + 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)] as HTMLElement[]; +const text = (card: Card, sel: string) => qa(card, sel).map((e) => e.textContent!.replace(/\s+/g, ' ').trim()); + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('annotations on the line chart', () => { + it('draws threshold lines (from a number or an entity) and widens the axis to show them', async () => { + const card = await mount({ + annotations: [ + { type: 'line', value: 'input_number.target', label: 'Target' }, + { type: 'line', value: 2, color: 'blue', dash: false }, + { type: 'line', value: 'sensor.gone', label: 'Gone' }, + ], + }); + const lines = qa(card, 'line.threshold'); + expect(lines).toHaveLength(2); + expect(lines[0].classList.contains('dashed')).toBe(true); + expect(lines[0].getAttribute('stroke')).toContain('--error-color'); + expect(lines[1].classList.contains('dashed')).toBe(false); + expect(text(card, '.annotation-label')).toEqual(['Target']); + // The axis now reaches 30 and 2, past the readings (5 to 23). + const values = text(card, '.y-axis .axis-label').map(Number); + expect(Math.max(...values)).toBeGreaterThanOrEqual(30); + expect(Math.min(...values)).toBeLessThanOrEqual(2); + expect(card.hass!.callWS).toHaveBeenCalledTimes(1); + }); + + it('shades bands and the severity zones behind the lines, skipping what it cannot place', async () => { + const card = await mount({ + zones: true, + severity: [ + { from: 0, color: 'blue' }, + { from: 20, color: 'red' }, + { from: 500, color: 'green' }, + ], + annotations: [ + { type: 'band', from: 18, to: 21, label: 'Comfort' }, + { type: 'band', to: 10 }, + { type: 'band', from: 'sensor.gone' }, + { type: 'band', from: 100, to: 200 }, + ], + }); + expect(qa(card, 'rect.zone')).toHaveLength(2); + expect(qa(card, 'rect.band')).toHaveLength(2); + expect(text(card, '.annotation-label.band')).toEqual(['Comfort']); + // Behind: the shading comes before the data in the drawing. + const group = card.shadowRoot!.querySelector('.annotations-behind')!; + expect(group.compareDocumentPosition(card.shadowRoot!.querySelector('path.line')!)).toBe( + Node.DOCUMENT_POSITION_FOLLOWING, + ); + }); + + it('marks set times and an entity turning on, with labels that do not overlap', async () => { + const card = await mount({ + annotations: [ + { type: 'time', times: [new Date(now - 6 * H).toISOString().replace(/\.\d+Z$/, 'Z')], label: 'Alarm' }, + { type: 'state', entity: 'switch.heater', state: 'on', label: 'Heating on', shade: true }, + { type: 'state', entity: 'switch.heater', color: 'grey' }, + ], + }); + expect(qa(card, 'line.mark-line.time')).toHaveLength(1); + // On at 10 h and 4 h ago, then on again an hour ago (from the live state). + expect(qa(card, 'line.mark-line.state')).toHaveLength(3 + 5); + expect(qa(card, 'rect.state-shade')).toHaveLength(3); + const labels = text(card, '.annotation-label.mark'); + // On a narrow card not every label fits; those that do never share a row with a neighbour. + expect(labels).toContain('Heating on'); + expect(labels).toContain('Alarm'); + expect(labels.length).toBeLessThan(4); + expect(qa(card, '.annotation-label.mark')[0].getAttribute('title')).toMatch(/ at /); + }); + + it('draws the chart without state marks while they load or if they fail', async () => { + const card = await mount({ annotations: [{ type: 'state', entity: 'switch.heater' }] }, hass(true)); + expect(qa(card, 'path.line')).toHaveLength(2); + expect(qa(card, 'line.mark-line')).toHaveLength(0); + }); + + it('labels the highest, lowest and latest readings of a series', async () => { + const card = await mount({ + annotations: [ + { type: 'point', label: 'Peak' }, + { type: 'point', at: 'min', entity: 'sensor.in', color: 'green' }, + { type: 'point', at: 'last', entity: 'sensor.out' }, + { type: 'point', at: 'max', entity: 'sensor.missing' }, + ], + }); + expect(text(card, '.annotation-label.point')).toEqual(['Peak 23.0 °C', '19.0 °C', '5.0 °C', '23.0 °C']); + expect(qa(card, '.annotation-dot')).toHaveLength(4); + }); + + it('redraws when an entity it reads changes', async () => { + const card = await mount({ annotations: [{ type: 'line', value: 'input_number.target' }] }); + const before = qa(card, 'line.threshold')[0].getAttribute('y1'); + const h = card.hass!; + card.hass = { + ...h, + states: { ...h.states, 'input_number.target': { ...h.states['input_number.target'], state: '25' } }, + }; + await card.updateComplete; + expect(qa(card, 'line.threshold')[0].getAttribute('y1')).not.toBe(before); + card.remove(); + }); +}); + +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 {}); +} + +describe('annotations in the editor', () => { + it('turns annotations into form data and back', () => { + expect(annotationData({ type: 'line', value: 21 })).toEqual({ + type: 'line', + value: '21', + dash: true, + label: undefined, + color: undefined, + }); + expect(annotationData({ type: 'time', times: ['07:00', '08:00'] }).times).toBe('07:00, 08:00'); + expect(annotationData({ type: 'point', entity: 'sensor.in' })).toMatchObject({ at: 'max', series: 'sensor.in' }); + const line = { type: 'line' as const, value: 21 }; + expect(annotationFromData({ type: 'line', value: ' 22.5 ', dash: true, label: '', color: 'red' }, line)).toEqual({ + type: 'line', + value: 22.5, + color: 'red', + }); + expect(annotationFromData({ type: 'line', value: 'input_number.t', dash: false }, line)).toEqual({ + type: 'line', + value: 'input_number.t', + dash: false, + }); + expect(annotationFromData({ type: 'time', times: '07:00, 19:00' }, { type: 'time' })).toEqual({ + type: 'time', + times: ['07:00', '19:00'], + }); + expect(annotationFromData({ type: 'point', series: 'sensor.out', at: 'min' }, { type: 'point' })).toEqual({ + type: 'point', + entity: 'sensor.out', + at: 'min', + }); + // A new kind keeps the label and colour only. + expect(annotationFromData({ type: 'band', value: '3', label: 'L', color: 'red' }, line)).toEqual({ + type: 'band', + label: 'L', + color: 'red', + }); + expect(annotationFromData({}, line)).toEqual({ type: 'line' }); + expect(withAnnotations({ type: 'x', annotations: [line] } as CardConfig, [])).toEqual({ type: 'x' }); + expect(annotationSchema('line', undefined, [])).toBe(annotationSchema('line', undefined, [])); + }); + + it('adds, edits and removes annotations, and shades the zones', async () => { + await import('../src/editor/chartdeck-card-editor'); + 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) => { + const config = (e as CustomEvent).detail.config as CardConfig; + events.push(config); + ed.setConfig(config); + }); + ed.hass = makeHass({}); + const raw = { type: 'custom:chartdeck-card', style: 'line', entities: ['sensor.in'] } as CardConfig; + ed.setConfig(raw); + document.body.appendChild(ed); + await ed.updateComplete; + const root = ed.shadowRoot!; + const chart = root.querySelector('.section-chart ha-form') as Stub; + expect((chart.data as Record).zones).toBe(false); + chart.dispatchEvent( + new CustomEvent('value-changed', { detail: { value: { ...(chart.data as object), zones: true } } }), + ); + expect(events.at(-1)!.zones).toBe(true); + const add = (type: string) => + (root.querySelector(`.annotation-add button[data-type="${type}"]`) as HTMLElement).click(); + for (const type of ['line', 'band', 'time', 'state', 'point']) { + add(type); + await ed.updateComplete; + } + expect(events.at(-1)!.annotations).toEqual([ + { type: 'line', value: 0 }, + { type: 'band', from: 0, to: 1 }, + { type: 'time', times: ['07:00'] }, + { type: 'state', entity: 'sensor.in', state: 'on' }, + { type: 'point', at: 'max' }, + ]); + const forms = [...root.querySelectorAll('.annotation ha-form')] as Stub[]; + expect(forms).toHaveLength(5); + forms[0].dispatchEvent( + new CustomEvent('value-changed', { + detail: { value: { ...(forms[0].data as object), value: '21', label: 'T' } }, + }), + ); + expect((events.at(-1)!.annotations as unknown[])[0]).toEqual({ type: 'line', value: 21, label: 'T' }); + await ed.updateComplete; + for (let i = 0; i < 5; i++) { + (root.querySelector('.annotation .remove') as HTMLElement).click(); + await ed.updateComplete; + } + expect(events.at(-1)!.annotations).toBeUndefined(); + }); + + it('starts a state mark on a placeholder when the card has no entities', 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({}); + ed.setConfig({ type: 'custom:chartdeck-card', style: 'line', entities: [{ entity: 'sensor.a' }] } as CardConfig); + document.body.appendChild(ed); + await ed.updateComplete; + (ed.shadowRoot!.querySelector('.annotation-add button[data-type="state"]') as HTMLElement).click(); + expect((events.at(-1)!.annotations as { entity: string }[])[0].entity).toBe('sensor.a'); + }); +}); diff --git a/test/axes.test.ts b/test/axes.test.ts index 8303b07..0b7533e 100644 --- a/test/axes.test.ts +++ b/test/axes.test.ts @@ -162,7 +162,16 @@ describe('axis options', () => { gridlines: { vertical: true }, y_min: 0, } as CardConfig), - ).toEqual({ x: false, y: true, labels: 'inside', horizontal: true, vertical: true, min: 0, max: undefined }); + ).toEqual({ + x: false, + y: true, + labels: 'inside', + horizontal: true, + vertical: true, + min: 0, + max: undefined, + scale: 'linear', + }); }); it('explains a bad option', () => { diff --git a/test/beeswarm.test.ts b/test/beeswarm.test.ts new file mode 100644 index 0000000..546f1e8 --- /dev/null +++ b/test/beeswarm.test.ts @@ -0,0 +1,294 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import '../src/chartdeck-card'; +import { fitSwarm, sizeFor, swarm, swarmReach } from '../src/chart/swarm'; +import { hourOfDay, onEvents, swarmOptions } from '../src/styles/beeswarm'; +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 overlaps = (dots: { at: number; r: number }[], ys: number[]) => { + for (let i = 0; i < dots.length; i++) { + for (let j = i + 1; j < dots.length; j++) { + const d = Math.hypot(dots[i].at - dots[j].at, ys[i] - ys[j]); + if (d < dots[i].r + dots[j].r - 1e-6) return true; + } + } + return false; +}; + +describe('swarm packing', () => { + it('leaves dots on the line when they have room', () => { + expect( + swarm([ + { at: 0, r: 3 }, + { at: 10, r: 3 }, + { at: 20, r: 3 }, + ]), + ).toEqual([0, 0, 0]); + }); + + it('nudges crowded dots sideways, either way, never overlapping', () => { + const dots = Array.from({ length: 30 }, (_, i) => ({ at: (i % 5) * 2, r: 3 })); + const ys = swarm(dots); + expect(overlaps(dots, ys)).toBe(false); + expect(ys.some((y) => y > 0)).toBe(true); + expect(ys.some((y) => y < 0)).toBe(true); + // Two dots in the same place: one on the line, the next just touching it. + const [a, b] = swarm([ + { at: 5, r: 2 }, + { at: 5, r: 2 }, + ]); + expect(a).toBe(0); + expect(Math.abs(b)).toBeCloseTo(4.75); + expect( + swarmReach( + [ + { at: 5, r: 2 }, + { at: 5, r: 2 }, + ], + [a, b], + ), + ).toBeCloseTo(6.75); + }); + + it('shrinks a swarm that is too tall for its band, then squeezes it in', () => { + const dots = Array.from({ length: 40 }, () => ({ at: 50, r: 3 })); + const roomy = fitSwarm(dots.slice(0, 2), 20); + expect(roomy.scale).toBe(1); + const shrunk = fitSwarm(dots.slice(0, 6), 10); + expect(shrunk.scale).toBeLessThan(1); + const squeezed = fitSwarm(dots, 10, 1); + expect(Math.max(...squeezed.offsets.map(Math.abs))).toBeLessThanOrEqual(10); + }); + + it('sizes dots by value, by area, across the row', () => { + expect(sizeFor(30, 10, 30, 4)).toBe(4); + expect(sizeFor(10, 10, 30, 4)).toBeCloseTo(4 * Math.sqrt(0.12)); + expect(sizeFor(20, 10, 30, 4)).toBeCloseTo(4 * Math.sqrt(0.56)); + expect(sizeFor(5, 5, 5, 4)).toBe(4); + }); +}); + +describe('beeswarm helpers', () => { + it('reads its options', () => { + expect(swarmOptions({} as CardConfig)).toEqual({ placeBy: 'value', dot: 6, sized: false }); + expect(swarmOptions({ place_by: 'time_of_day', dot_size: 4, size_by_value: true } as CardConfig)).toEqual({ + placeBy: 'time_of_day', + dot: 4, + sized: true, + }); + }); + + it('finds the times something turned on', () => { + const events = onEvents([ + { t: 0, v: 1, carried: true }, + { t: 1, v: 0 }, + { t: 2, v: 1 }, + { t: 3, v: 1 }, + { t: 4, v: null }, + { t: 5, v: 1 }, + ]); + // Back on after unavailable, when it was on before, is a restart, not a new event. + expect(events.map((p) => p.t)).toEqual([2]); + }); + + it('gives the hour of the day in a time zone', () => { + const t = Date.UTC(2026, 6, 1, 6, 30); + expect(hourOfDay(t, 'UTC')).toBe(6.5); + expect(hourOfDay(t, 'Europe/London')).toBe(7.5); + }); +}); + +type Card = HTMLElement & { setConfig(c: CardConfig): void; hass?: HomeAssistant; updateComplete: Promise }; + +const now = Date.now(); +const H = 3_600_000; + +function hassWith(history: Record) { + const hass = makeHass({ + 'sensor.hall': ['20', { friendly_name: 'Hall', unit_of_measurement: '°C' }], + 'binary_sensor.motion': ['off', { friendly_name: 'Motion' }], + 'sensor.gone': ['unavailable', { friendly_name: 'Gone' }], + }); + hass.config = { time_zone: 'UTC' } as HomeAssistant['config']; + hass.callWS = vi.fn(async () => history) as HomeAssistant['callWS']; + return hass; +} + +const hall = Array.from({ length: 40 }, (_, i) => ({ + s: String(18 + (i % 8) / 2), + lu: (now - 20 * H + i * 20 * 60_000) / 1000, +})); +const motion = Array.from({ length: 12 }, (_, i) => ({ s: i % 2 ? 'off' : 'on', lu: (now - 22 * H + i * H) / 1000 })); +const history = { + 'sensor.hall': hall, + 'binary_sensor.motion': motion, + 'sensor.gone': [{ s: 'unavailable', lu: (now - 20 * H) / 1000 }], +}; + +async function mount(config: Partial, hass = hassWith(history)) { + const card = document.createElement('chartdeck-card') as Card; + card.setConfig({ + type: 'custom:chartdeck-card', + style: 'beeswarm', + entities: ['sensor.hall', 'sensor.gone'], + ...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 text = (e: Element) => e.textContent!.replace(/\s+/g, ' ').trim(); +const dotCount = (el: Element) => + [...el.querySelectorAll('path.dots')].reduce((a, p) => a + p.getAttribute('d')!.split('M').length - 1, 0); + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('beeswarm chart', () => { + it('packs every reading of each entity in its own row, counting them', async () => { + const card = await mount({}); + const swarms = qa(card, 'g.swarm'); + expect(swarms).toHaveLength(1); + expect(swarms[0].getAttribute('data-index')).toBe('0'); + expect(swarms[0].getAttribute('stroke')).toContain('--primary-color'); + expect(dotCount(swarms[0])).toBe(40); + expect(qa(card, '.cat-label').map(text)).toEqual(['Hall 40 readings', 'Gone No readings']); + expect(q(card, 'svg.plot')!.getAttribute('aria-label')).toBe('Beeswarm chart of Hall, Gone'); + expect(q(card, 'svg.plot')!.getAttribute('viewBox')).toBe('0 0 1000 200'); + }); + + it('places motion by the time of day it turned on, on a clock axis', async () => { + const card = await mount({ + place_by: 'time_of_day', + entities: ['binary_sensor.motion', 'sensor.hall'], + } as Partial); + expect(qa(card, '.cat-label').map(text)[0]).toBe('Motion 6 times on'); + expect(dotCount(qa(card, 'g.swarm')[0])).toBe(6); + const ticks = qa(card, '.cat-axis .axis-label').map(text); + expect(ticks[0]).toMatch(/^(0?0:00|12:00 AM)$/); + expect(ticks.length).toBeGreaterThan(2); + }); + + it('sizes dots by value, and stands up', async () => { + const card = await mount({ size_by_value: true, orientation: 'vertical', dot_size: 8 } as Partial); + const sizes = qa(card, 'g.swarm path.dots').map((p) => p.getAttribute('style')); + expect(new Set(sizes).size).toBeGreaterThan(1); + expect(q(card, '.cat-plot.vertical')).not.toBeNull(); + }); + + it('shows count, median, middle half and range for the row under the pointer', async () => { + const card = await mount({}); + const area = q(card, '.plot-area')!; + area.getBoundingClientRect = () => + ({ left: 0, top: 0, width: 300, height: 192, right: 300, bottom: 192 }) as DOMRect; + area.dispatchEvent(new MouseEvent('pointermove', { bubbles: true, clientX: 50, clientY: 20 })); + await card.updateComplete; + const rows = qa(card, '.band-tooltip .tooltip-row').map(text); + expect(rows[0]).toBe('Readings 40'); + expect(rows[1]).toMatch(/^Median [\d.]+ °C$/); + const clock = await mount({ place_by: 'time_of_day', entities: ['binary_sensor.motion'] } as Partial); + const plot = q(clock, '.plot-area')!; + plot.getBoundingClientRect = () => ({ left: 0, top: 0, width: 300, height: 96, right: 300, bottom: 96 }) as DOMRect; + plot.dispatchEvent(new MouseEvent('pointermove', { bubbles: true, clientX: 50, clientY: 20 })); + await clock.updateComplete; + expect(qa(clock, '.band-tooltip .tooltip-row').map(text)[1]).toMatch(/^Median \d{1,2}:\d\d/); + }); + + it('says when sampling leaves dots out', async () => { + const many = Array.from({ length: 900 }, (_, i) => ({ s: String(i % 50), lu: (now - 20 * H + i * 60_000) / 1000 })); + const card = await mount({ entities: ['sensor.hall'] }, hassWith({ 'sensor.hall': many })); + expect(dotCount(q(card, 'g.swarm')!)).toBe(400); + const area = q(card, '.plot-area')!; + area.getBoundingClientRect = () => ({ left: 0, top: 0, width: 300, height: 96, right: 300, bottom: 96 }) as DOMRect; + area.dispatchEvent(new MouseEvent('pointermove', { bubbles: true, clientX: 50, clientY: 20 })); + await card.updateComplete; + expect(qa(card, '.band-tooltip .tooltip-row').map(text)[1]).toBe('Dots shown 400'); + }); + + it('opens an entity from its swarm', async () => { + const card = await mount({}); + const fired: string[] = []; + card.addEventListener('hass-action', (e) => fired.push((e as CustomEvent).detail.config.entity)); + const swarmEl = q(card, 'g.swarm')!; + swarmEl.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true })); + swarmEl.dispatchEvent(new MouseEvent('pointerup', { bubbles: true })); + expect(fired).toEqual(['sensor.hall']); + }); + + it('says when there is nothing to draw', async () => { + const card = await mount({}, hassWith({})); + 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: 'beeswarm', entities: ['sensor.hall'], ...extra } as CardConfig); + expect(set({ place_by: 'weekday' })).toThrow(/"place_by" must be one of: value, time_of_day/); + expect(set({ dot_size: 40 })).toThrow(/"dot_size" must be a number from 2 to 16/); + expect(set({ size_by_value: 'yes' })).toThrow(/size_by_value/); + expect(set({ orientation: 'diagonal' })).toThrow(/orientation/); + expect(set({ place_by: 'time_of_day', dot_size: 4, size_by_value: true })).not.toThrow(); + }); +}); + +describe('beeswarm editor', () => { + it('writes placement, dot size and sizing, hiding the value range for time of day', 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: 'beeswarm', entities: ['sensor.hall'] } 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({ + place_by: 'value', + dot_size: 6, + size_by_value: false, + orientation: 'horizontal', + }); + expect(JSON.stringify(form.schema)).toContain('value_min'); + form.dispatchEvent( + new CustomEvent('value-changed', { + detail: { value: { ...(form.data as object), place_by: 'time_of_day', dot_size: 4, size_by_value: true } }, + }), + ); + const next = events.at(-1)!; + expect(next).toEqual({ ...raw, place_by: 'time_of_day', dot_size: 4, size_by_value: true }); + ed.setConfig(next); + await ed.updateComplete; + const clock = ed.shadowRoot!.querySelector('.section-chart ha-form') as Stub; + expect(JSON.stringify(clock.schema)).not.toContain('value_min'); + const list = ed.shadowRoot!.querySelector('cd-entities-editor') as Stub; + expect((list.chartFields as { fields: { name: string }[] }).fields.map((f) => f.name)).toEqual(['fill_color']); + }); +}); diff --git a/test/bullet-lollipop.test.ts b/test/bullet-lollipop.test.ts new file mode 100644 index 0000000..da18fb6 --- /dev/null +++ b/test/bullet-lollipop.test.ts @@ -0,0 +1,251 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import '../src/chartdeck-card'; +import { rangeFill, rangeSegments, targetText } from '../src/chart/marks'; +import { STYLES } from '../src/styles/index'; +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 }; + +function hass() { + return makeHass({ + 'sensor.elec': ['14.2', { friendly_name: 'Electricity', unit_of_measurement: 'kWh' }], + 'sensor.gas': ['22.5', { friendly_name: 'Gas', unit_of_measurement: 'kWh' }], + 'sensor.budget': ['20', { friendly_name: 'Budget' }], + 'sensor.grid': ['-500', { friendly_name: 'Grid', unit_of_measurement: 'W' }], + 'sensor.off': ['unavailable', { friendly_name: 'Off' }], + }); +} + +async function mount(config: Partial) { + const card = document.createElement('chartdeck-card') as Card; + card.setConfig({ type: 'custom:chartdeck-card', ...config } as CardConfig); + card.hass = hass(); + document.body.appendChild(card); + await card.updateComplete; + return card; +} + +const q = (card: Card, sel: string) => card.shadowRoot!.querySelector(sel) as HTMLElement | null; +const qa = (card: Card, sel: string) => [...card.shadowRoot!.querySelectorAll(sel)] as HTMLElement[]; + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('bullet and lollipop maths', () => { + const bands = [ + { from: -Infinity, color: 'red' }, + { from: 10, color: 'amber' }, + { from: 25, color: 'green' }, + ]; + + it('cuts colour bands to the entity limits', () => { + expect(rangeSegments({ bands, min: 0, max: 50 })).toEqual([ + { from: 0, to: 0.2, color: 'red', level: 0 }, + { from: 0.2, to: 0.5, color: 'amber', level: 1 }, + { from: 0.5, to: 1, color: 'green', level: 2 }, + ]); + // Bands below min drop out and the first one left starts at the edge. + expect(rangeSegments({ bands, min: 30, max: 50 })).toEqual([{ from: 0, to: 1, color: 'green', level: 0 }]); + expect(rangeSegments({ bands: [{ from: -Infinity, color: 'primary' }], min: 0, max: 100 })).toHaveLength(1); + }); + + it('shades ranges darker band by band, or tints them with the band colour', () => { + const segs = rangeSegments({ bands, min: 0, max: 50 }); + expect(rangeFill(segs[0], 3, 'shades')).toContain(' 6%'); + expect(rangeFill(segs[2], 3, 'shades')).toContain(' 18%'); + expect(rangeFill(segs[0], 1, 'shades')).toContain(' 8%'); + expect(rangeFill(segs[1], 3, 'colours')).toContain('var(--warning-color'); + }); + + it('writes the target with the unit, or nothing without one', () => { + const h = hass(); + expect(targetText({ target: 18, decimals: 1, unit: 'kWh' } as never, h)).toBe('target 18.0 kWh'); + expect(targetText({ target: null } as never, h)).toBeUndefined(); + }); +}); + +describe('bullet chart', () => { + it('draws one row per entity with ranges, bar, target and value', async () => { + const card = await mount({ + style: 'bullet', + max: 30, + severity: [ + { from: 0, color: 'green' }, + { from: 15, color: 'amber' }, + { from: 22, color: 'red' }, + ], + entities: [ + { entity: 'sensor.elec', target: 18 }, + { entity: 'sensor.gas', target: 'sensor.budget' }, + 'sensor.off', + 'sensor.missing', + ], + }); + const rows = qa(card, '.bullet-row.entity'); + expect(rows.map((r) => r.dataset.index)).toEqual(['0', '1', '2', '3']); + expect(q(card, '.bullets')!.getAttribute('aria-label')).toBe( + 'Bullet chart of Electricity, Gas, Off, sensor.missing', + ); + expect(rows[0].querySelectorAll('.range')).toHaveLength(3); + expect(rows[0].querySelector('.fill')).not.toBeNull(); + expect(rows[0].style.getPropertyValue('--end')).toBe(String(14.2 / 30)); + expect(rows[0].style.getPropertyValue('--target')).toBe('0.6'); + expect(rows[0].querySelector('.sub')!.textContent).toBe('target 18.0 kWh'); + expect(rows[1].querySelector('.sub')!.textContent).toBe('target 20.0 kWh'); + expect(rows[1].querySelector('.value')!.textContent).toBe('22.5 kWh'); + expect(rows[1].style.getPropertyValue('--c-start')).toContain('--error-color'); + // Unavailable and missing: the track, no bar or target, the status in place of the value. + expect(rows[2].classList.contains('unavailable')).toBe(true); + expect(rows[2].querySelector('.fill, .target')).toBeNull(); + expect(rows[2].querySelector('.value')!.textContent).toBe('Unavailable'); + expect(rows[3].querySelector('.value')!.textContent).toBe('Entity not found'); + expect(rows[0].getAttribute('role')).toBe('meter'); + }); + + it('tints ranges with band colours, hides target and names when asked, and draws below zero', async () => { + const card = await mount({ + style: 'bullet', + ranges: 'colours', + min: -1000, + max: 1000, + target: 0, + show: { target: false, name: false, value: false }, + entities: ['sensor.grid'], + }); + const row = q(card, '.entity')!; + expect(q(card, '.bullets.no-name')).not.toBeNull(); + expect(row.querySelector('.range')!.getAttribute('style')).toContain('color-mix(in srgb, var(--primary-color'); + expect(row.querySelector('.target, .name, .value')).toBeNull(); + expect(row.style.getPropertyValue('--start')).toBe('0.25'); + expect(row.style.getPropertyValue('--end')).toBe('0.5'); + }); + + it('opens more-info from a row', async () => { + const card = await mount({ style: 'bullet', entities: ['sensor.elec', 'sensor.gas'] }); + const fired: string[] = []; + card.addEventListener('hass-action', (e) => fired.push((e as CustomEvent).detail.config.entity)); + const row = qa(card, '.entity')[1]; + row.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true })); + row.dispatchEvent(new MouseEvent('pointerup', { bubbles: true })); + expect(fired).toEqual(['sensor.gas']); + }); +}); + +describe('lollipop chart', () => { + it('stands one stem per entity, with value above and name below', async () => { + const card = await mount({ + style: 'lollipop', + target: 50, + entities: ['sensor.elec', 'sensor.off', 'sensor.missing'], + }); + expect(q(card, '.compare.vertical')).not.toBeNull(); + const items = qa(card, '.pop.entity'); + expect(items.map((r) => r.dataset.index)).toEqual(['0', '1', '2']); + expect(items[0].querySelector('.stem')).not.toBeNull(); + expect(items[0].querySelector('.dot')).not.toBeNull(); + expect(items[0].querySelector('.target')).not.toBeNull(); + expect(items[0].querySelector('.tip')!.textContent).toBe('14.2 kWh'); + expect(items[0].querySelector('.name')!.textContent).toBe('Electricity'); + expect(items[0].style.getPropertyValue('--pos')).toBe('0.142'); + expect(items[1].querySelector('.stem, .dot, .target')).toBeNull(); + expect(items[1].querySelector('.value')!.textContent).toBe('Unavailable'); + expect(items[2].querySelector('.value')!.textContent).toBe('Entity not found'); + expect(q(card, '.compare')!.getAttribute('aria-label')).toBe('Lollipop chart of Electricity, Off, sensor.missing'); + }); + + it('lies across with the value on the right, and leaves values out when hidden', async () => { + const card = await mount({ + style: 'lollipop', + orientation: 'horizontal', + show: { value: false }, + entities: ['sensor.elec', 'sensor.off'], + }); + expect(q(card, '.compare.horizontal')).not.toBeNull(); + const [on, off] = qa(card, '.pop.entity'); + expect(on.querySelector('.value')).toBeNull(); + expect(on.querySelector('.target')).toBeNull(); + // An unavailable entity still says so with values hidden. + expect(off.querySelector('.value')!.textContent).toBe('Unavailable'); + }); +}); + +describe('bullet and lollipop options', () => { + it('checks them', () => { + const card = document.createElement('chartdeck-card') as Card; + const set = (extra: Record) => () => + card.setConfig({ type: 'x', entities: ['sensor.elec'], ...extra } as CardConfig); + expect(set({ style: 'bullet', ranges: 'stripes' })).toThrow('"ranges" must be one of: shades, colours'); + expect(set({ style: 'lollipop', orientation: 'diagonal' })).toThrow( + '"orientation" must be one of: vertical, horizontal', + ); + expect(set({ style: 'bullet', ranges: 'colours' })).not.toThrow(); + }); + + it('have thumbnails in the comparisons group', () => { + for (const name of ['bullet', 'lollipop'] as const) { + const style = STYLES[name]; + expect(style.group).toBe('compare'); + expect(style.layout).toBe('chart'); + } + }); + + async function editor(raw: CardConfig) { + 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({}); + ed.setConfig(raw); + document.body.appendChild(ed); + await ed.updateComplete; + const form = ed.shadowRoot!.querySelector('.section-chart ha-form') as Stub; + const change = (value: Record) => + form.dispatchEvent( + new CustomEvent('value-changed', { detail: { value: { ...(form.data as object), ...value } } }), + ); + return { ed, form, events, change }; + } + + it('are set in the editor', async () => { + const raw = { type: 'custom:chartdeck-card', style: 'bullet', entities: ['sensor.elec'] } as CardConfig; + const b = await editor(raw); + expect(b.form.data).toEqual({ ranges: 'shades' }); + const labels = ( + (b.form.schema as { selector: { select: { options: { label: string }[] } } }[])[0].selector.select.options as { + label: string; + }[] + ).map((o) => o.label); + expect(labels).toEqual(['Shades of grey', 'Colour band colours']); + b.change({ ranges: 'colours' }); + expect(b.events.at(-1)).toEqual({ ...raw, ranges: 'colours' }); + document.body.innerHTML = ''; + + const raw2 = { type: 'custom:chartdeck-card', style: 'lollipop', orientation: 'horizontal' } as CardConfig; + const l = await editor(raw2); + expect(l.form.data).toEqual({ orientation: 'horizontal' }); + l.change({ orientation: 'vertical' }); + // Back to the default removes the key. + expect(l.events.at(-1)).toEqual({ type: 'custom:chartdeck-card', style: 'lollipop' }); + }); +}); diff --git a/test/candlestick.test.ts b/test/candlestick.test.ts new file mode 100644 index 0000000..2853c37 --- /dev/null +++ b/test/candlestick.test.ts @@ -0,0 +1,311 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import '../src/chartdeck-card'; +import { candleExtent, historyCandles, statisticsCandles, underValues } from '../src/chart/candles'; +import { candlePeriod, candleQuery, periodTitle } from '../src/styles/candlestick'; +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'); + +describe('candle maths', () => { + const bounds = [0, 100, 200, 300]; + + it('takes open, high, low and close from history, carrying the value in at each start', () => { + const points = [ + { t: -10, v: 5, carried: true }, + { t: 20, v: 9 }, + { t: 60, v: 3 }, + { t: 80, v: 4 }, + { t: 150, v: null }, + ]; + const candles = historyCandles(points, bounds, 250); + expect(candles[0]).toEqual({ t: 0, end: 100, open: 5, high: 9, low: 3, close: 4 }); + // Unavailable from 150: the second period still had 4 until then. + expect(candles[1]).toEqual({ t: 100, end: 200, open: 4, high: 4, low: 4, close: 4 }); + // Nothing known in the third, which also starts after now. + expect(candles[2]).toBeNull(); + }); + + it('takes candles from statistics rows, with their min and max', () => { + const rows = [ + { t: -50, v: 1, carried: true }, + { t: 0, v: 10, min: 8, max: 12 }, + { t: 50, v: 11, min: 7, max: 13 }, + { t: 100, v: 20 }, + { t: 150, v: null }, + { t: 210, v: 5 }, + ]; + const candles = statisticsCandles(rows, bounds, 195); + expect(candles[0]).toEqual({ t: 0, end: 100, open: 10, high: 13, low: 7, close: 11 }); + expect(candles[1]).toEqual({ t: 100, end: 200, open: 20, high: 20, low: 20, close: 20 }); + expect(candles[2]).toBeNull(); + expect(candleExtent(candles)).toEqual([7, 20]); + expect(candleExtent([null])).toBeUndefined(); + }); + + it('groups the series underneath like the line chart, meters by how far they rose', () => { + const meter = [ + { t: 0, v: 100 }, + { t: 50, v: 103 }, + { t: 150, v: 110 }, + ]; + expect(underValues(meter, [0, 100, 200], 'change', 'statistics', 300).map((p) => p.v)).toEqual([3, 7]); + expect(underValues(meter, [0, 100, 200], 'max', 'statistics', 300).map((p) => p.v)).toEqual([103, 110]); + expect(underValues(meter, [0, 100, 200], 'last', 'history', 300).map((p) => p.v)).toEqual([103, 110]); + }); + + it('asks for rows fine enough to have an open and a close', () => { + const zone = { timeZone: 'Europe/London', firstDay: 1 }; + expect(candleQuery('hour', zone, 'Europe/London').period).toBe('5minute'); + expect(candleQuery('day', zone, 'Europe/London').period).toBe('hour'); + expect(candleQuery('week', zone, 'Europe/London').period).toBe('day'); + expect(candleQuery('month', zone, 'America/New_York').period).toBe('hour'); + expect(candleQuery('day', zone, undefined).types).toEqual(['mean', 'min', 'max', 'state']); + }); + + it('defaults to the last 14 days, keeping what the card sets', () => { + expect(candlePeriod({} as CardConfig)).toEqual({ last: 14, unit: 'day' }); + expect(candlePeriod({ period: { last: 30 } } as CardConfig)).toEqual({ last: 30, unit: 'day' }); + expect(candlePeriod({ period: { calendar: 'month' } } as CardConfig)).toMatchObject({ calendar: 'month' }); + }); + + it('names a period for the tooltip', () => { + const zone = { timeZone: 'UTC', firstDay: 1 }; + const t = Date.UTC(2026, 9, 26); + const gb = { locale: { language: 'en-GB' } } as unknown as HomeAssistant; + expect(periodTitle(t, 'day', gb, zone)).toBe('Mon 26 Oct'); + expect(periodTitle(t, 'week', gb, zone)).toBe('Week of 26 Oct'); + expect(periodTitle(t, 'month', gb, zone)).toBe('October 2026'); + expect(periodTitle(t, 'hour', undefined, zone)).toMatch(/26/); + expect(periodTitle(t, 'day', { language: 'not a language!' } as unknown as HomeAssistant, zone)).toMatch(/26/); + }); +}); + +type Card = HTMLElement & { setConfig(c: CardConfig): void; hass?: HomeAssistant; updateComplete: Promise }; + +const H = 3_600_000; +const DAY = 24 * H; + +/** Statistics rows: a price that rises on even days and falls on odd ones, and a usage meter. */ +function hassWith(fail = false) { + const hass = makeHass({ + 'sensor.price': ['25.3', { friendly_name: 'Price', unit_of_measurement: 'p' }], + 'sensor.usage': ['1200', { friendly_name: 'Usage', unit_of_measurement: 'kWh' }], + }); + hass.config = { time_zone: 'UTC' } as HomeAssistant['config']; + hass.callWS = vi.fn(async (msg: { type: string; start_time: string; statistic_ids?: string[] }) => { + if (fail) throw new Error('boom'); + const start = Date.parse(msg.start_time); + const out: Record = {}; + if (msg.type === 'recorder/statistics_during_period') { + const price = []; + const usage = []; + for (let t = start; t < Date.now(); t += H) { + const day = Math.floor(t / DAY); + const hour = (t % DAY) / H; + const v = 20 + (day % 2 === 0 ? hour : 24 - hour) / 4; + price.push({ start: t, mean: v, min: v - 1, max: v + 1 }); + usage.push({ start: t, state: (t - start) / H }); + } + out['sensor.price'] = price; + out['sensor.usage'] = usage; + } else { + out['sensor.price'] = [ + { s: '20', lu: (Date.now() - 3 * DAY) / 1000 }, + { s: '24', lu: (Date.now() - 2 * DAY) / 1000 }, + ]; + } + return out; + }) 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: 'candlestick', + entities: ['sensor.price'], + ...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, frac: number) { + el.getBoundingClientRect = () => ({ left: 0, top: 0, width: 1000, height: 160, right: 1000, bottom: 160 }) as DOMRect; + const ev = new MouseEvent(type, { bubbles: true, clientX: frac * 1000 }); + Object.defineProperty(ev, 'pointerType', { value: 'mouse' }); + el.dispatchEvent(ev); +} + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('candlestick chart', () => { + it('draws a candle per day for the last 14 days from hourly statistics, rising and falling', async () => { + const card = await mount({}); + const call = (card.hass!.callWS as ReturnType).mock.calls[0][0]; + expect(call).toMatchObject({ type: 'recorder/statistics_during_period', period: 'hour' }); + const candles = qa(card, 'g.candle'); + expect(candles.length).toBeGreaterThanOrEqual(14); + expect(candles.length).toBeLessThanOrEqual(15); + const ups = qa(card, 'g.candle.up'); + const downs = qa(card, 'g.candle.down'); + expect(ups.length).toBeGreaterThan(0); + expect(downs.length).toBeGreaterThan(0); + expect(ups[0].querySelector('rect')!.getAttribute('fill')).toContain('--success-color'); + expect(downs[0].querySelector('line')!.getAttribute('stroke')).toContain('--error-color'); + expect(q(card, '.under-columns')).toBeNull(); + expect(q(card, 'svg.plot')!.getAttribute('aria-label')).toBe('Candlestick chart of Price'); + }); + + it('draws the other entities underneath as columns or a line, in their own colours', async () => { + const card = await mount({ + entities: ['sensor.price', 'sensor.usage'], + aggregate: 'change', + up_color: 'blue', + } as Partial); + const columns = q(card, '.under-columns')!; + expect(columns.getAttribute('fill')).toContain('--grey-color'); + expect(columns.getAttribute('data-index')).toBe('1'); + expect(columns.querySelectorAll('rect').length).toBeGreaterThanOrEqual(14); + expect(q(card, 'g.candle.up rect')!.getAttribute('fill')).toContain('--info-color'); + const line = await mount({ entities: ['sensor.price', 'sensor.usage'], underneath: 'line' } as Partial); + expect(q(line, 'path.under-line')!.getAttribute('d')).toMatch(/^M.*L/); + }); + + it('makes hourly candles from history', async () => { + const card = await mount({ + source: 'history', + group_by: 'hour', + period: { last: 4, unit: 'day' }, + } as Partial); + const call = (card.hass!.callWS as ReturnType).mock.calls[0][0]; + expect(call.type).toBe('history/history_during_period'); + // 20 until two days ago, then 24: hours before the first reading have no candle. + expect(qa(card, 'g.candle').length).toBeGreaterThan(40); + expect(qa(card, 'g.candle.up').length).toBe(qa(card, 'g.candle').length); + }); + + it('lists open, high, low, close and the values underneath on hover', async () => { + const card = await mount({ + entities: ['sensor.price', 'sensor.usage'], + aggregate: 'change', + } as Partial); + point(q(card, '.plot-area')!, 'pointermove', 0.5); + await card.updateComplete; + const rows = qa(card, '.tooltip-row').map((r) => r.textContent!.replace(/\s+/g, ' ').trim()); + expect(rows.map((r) => r.split(' ')[0])).toEqual(['Open', 'High', 'Low', 'Close', 'Usage']); + expect(rows[1]).toMatch(/ p$/); + expect(rows[4]).toMatch(/kWh$/); + expect(q(card, '.tooltip-time')!.textContent).toMatch(/\d/); + }); + + it('opens the price entity from a candle', async () => { + const card = await mount({}); + const fired: string[] = []; + card.addEventListener('hass-action', (e) => fired.push((e as CustomEvent).detail.config.entity)); + const candle = q(card, 'g.candle')!; + candle.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true })); + candle.dispatchEvent(new MouseEvent('pointerup', { bubbles: true })); + expect(fired).toEqual(['sensor.price']); + }); + + it('says when history failed or is empty', async () => { + const failed = await mount({}, hassWith(true)); + expect(q(failed, '.chart-message.error')!.textContent!.trim()).toBe("Couldn't load history: boom"); + const hass = hassWith(); + hass.callWS = vi.fn(async () => ({})) as HomeAssistant['callWS']; + const empty = await mount({}, hass); + 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: 'candlestick', entities: ['sensor.price'], ...extra } as CardConfig); + expect(set({ underneath: 'area' })).toThrow(/"underneath" must be one of: columns, line/); + expect(set({ up_color: 3 })).toThrow(/up_color/); + expect(set({ down_color: '' })).toThrow(/down_color/); + expect(set({ group_by: 'year' })).toThrow(/group_by/); + expect(set({ period: { last: 0 } })).toThrow(/period/); + expect(set({ group_by: 'week', underneath: 'line', up_color: '#0f0' })).not.toThrow(); + }); +}); + +describe('candlestick editor', () => { + it('shows candle fields with their own defaults and writes them', 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: 'candlestick', entities: ['sensor.price'] } 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({ + period_last: 14, + period_unit: 'day', + source: 'statistics', + candle_by: 'day', + under_aggregate: 'mean', + up_color: 'green', + down_color: 'red', + underneath: 'columns', + }); + const names = JSON.stringify(form.schema); + expect(names).toContain('candle_by'); + expect(names).not.toContain('"group_by"'); + expect(names).not.toContain('"none"'); + form.dispatchEvent( + new CustomEvent('value-changed', { + detail: { + value: { + ...(form.data as object), + period_last: 30, + candle_by: 'week', + underneath: 'line', + down_color: 'amber', + }, + }, + }), + ); + expect(events.at(-1)).toEqual({ + ...raw, + period: { last: 30 }, + group_by: 'week', + underneath: 'line', + down_color: 'amber', + }); + const list = ed.shadowRoot!.querySelector('cd-entities-editor') as Stub; + expect((list.chartFields as { fields: { name: string }[] }).fields.map((f) => f.name)).toEqual(['fill_color']); + }); +}); diff --git a/test/climate.test.ts b/test/climate.test.ts new file mode 100644 index 0000000..b2f3a55 --- /dev/null +++ b/test/climate.test.ts @@ -0,0 +1,472 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import '../src/chartdeck-card'; +import { + actionKind, + actionSpans, + climateData, + climateRequests, + holdRuns, + rangeBandPath, + rangePoints, + valueAt, +} from '../src/chart/climate-data'; +import { key, loadHistory, parseHistory, toPoint, toText } from '../src/history/fetch'; +import { HistoryStore } from '../src/history/store'; +import type { Point } from '../src/history/types'; +import { actionLabel, climateOptions } from '../src/styles/climate'; +import type { ResolvedEntity } from '../src/resolve'; +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 flush = () => new Promise((r) => setTimeout(r, 0)); +const p = (t: number, v: number | null): Point => ({ t, v }); +const s = (t: number, words?: string): Point => ({ t, v: null, s: words }); + +describe('climate data', () => { + it('asks for the four temperatures and the action as words, all from the one entity', () => { + const r = climateRequests('climate.lounge'); + expect(Object.values(r).map((x) => x.attribute)).toEqual([ + 'current_temperature', + 'temperature', + 'target_temp_low', + 'target_temp_high', + 'hvac_action', + ]); + expect(r.action.text).toBe(true); + expect(r.current.text).toBeUndefined(); + expect(key(r.action)).toBe('climate.lounge|hvac_action|text'); + expect(key(r.current)).toBe('climate.lounge|current_temperature'); + }); + + it('keeps words for a text series and numbers for the rest', () => { + expect(toText('heating')).toBe('heating'); + expect(toText(3)).toBe('3'); + expect(toText(true)).toBe('true'); + expect(toText(null)).toBeUndefined(); + expect(toPoint(5, 'heating', true)).toEqual({ t: 5, v: null, s: 'heating' }); + expect(toPoint(5, '21')).toEqual({ t: 5, v: 21 }); + const rows = [ + { s: 'heat', a: { current_temperature: 19.5, hvac_action: 'heating' }, lu: 1 }, + { s: 'unavailable', a: {}, lu: 2 }, + { s: 'heat', a: { current_temperature: 20, hvac_action: 'idle' }, lu: 3 }, + ]; + expect(parseHistory(rows, 'hvac_action', true)).toEqual([ + { t: 1000, v: null, s: 'heating' }, + { t: 2000, v: null, s: undefined }, + { t: 3000, v: null, s: 'idle' }, + ]); + expect(parseHistory(rows, 'current_temperature').map((x) => x.v)).toEqual([19.5, null, 20]); + expect(parseHistory(rows, undefined, true).map((x) => x.s)).toEqual(['heat', 'unavailable', 'heat']); + }); + + it('loads every attribute in one call, and a text state in the compact one', async () => { + const hass = makeHass({}); + const calls: Record[] = []; + hass.callWS = vi.fn(async (msg: Record) => { + calls.push(msg); + return { + 'climate.a': [{ s: 'heat', a: { current_temperature: 20, hvac_action: 'heating' }, lu: 1 }], + 'switch.b': [{ s: 'on', lu: 1 }], + }; + }) as HomeAssistant['callWS']; + const r = climateRequests('climate.a'); + const out = await loadHistory(hass, [...Object.values(r), { entity: 'switch.b', text: true }], { + start: 0, + end: 10_000, + }); + expect(calls).toHaveLength(2); + expect(calls.find((c) => c.no_attributes === false)!.entity_ids).toEqual(['climate.a']); + expect(out.get(key(r.current))).toEqual([{ t: 1000, v: 20 }]); + expect(out.get(key(r.action))).toEqual([{ t: 1000, v: null, s: 'heating' }]); + expect(out.get(key(r.target))).toEqual([{ t: 1000, v: null }]); + expect(out.get('switch.b|text')).toEqual([{ t: 1000, v: null, s: 'on' }]); + }); + + it('appends live attribute changes, as numbers and as words', async () => { + const hass = makeHass({ 'climate.a': ['heat', { current_temperature: 19, hvac_action: 'idle' }] }); + hass.callWS = vi.fn(async () => ({ + 'climate.a': [{ s: 'heat', a: { current_temperature: 19, hvac_action: 'idle' }, lu: 1 }], + })) as HomeAssistant['callWS']; + const store = new HistoryStore(() => {}); + const r = climateRequests('climate.a'); + const series = Object.values(r); + store.get(hass, series, { start: 0, end: 20_000 }, 'history'); + await flush(); + const next = { + ...hass, + states: { + 'climate.a': { + ...hass.states['climate.a'], + attributes: { current_temperature: 19.5, hvac_action: 'heating' }, + last_updated: new Date(15_000).toISOString(), + }, + }, + }; + const loaded = store.get(next, series, { start: 0, end: 20_000 }, 'history'); + expect(loaded.data.get(key(r.current))!.map((x) => x.v)).toEqual([19, 19.5]); + expect(loaded.data.get(key(r.action))!.map((x) => x.s)).toEqual(['idle', 'heating']); + expect(loaded.data.get(key(r.target))!.map((x) => x.v)).toEqual([null, null]); + }); + + it('carries each part on to the present', () => { + const r = climateRequests('climate.a'); + const loaded = new Map([ + [key(r.current), [p(0, 19), p(10, 20)]], + [key(r.target), [p(0, 21), p(10, null)]], + [key(r.action), [s(0, 'heating')]], + ]); + const d = climateData(loaded, r, 50, key); + expect(d.current.at(-1)).toEqual({ t: 50, v: 20, carried: true }); + expect(d.target).toHaveLength(2); + expect(d.action.at(-1)).toEqual({ t: 50, v: null, s: 'heating', carried: true }); + expect(d.low).toEqual([]); + }); + + it('sorts actions into heating, cooling, drying and fan, and idle into nothing', () => { + expect(actionKind('heating')).toBe('heat'); + expect(actionKind('preheating')).toBe('heat'); + expect(actionKind('cooling')).toBe('cool'); + expect(actionKind('defrosting')).toBe('cool'); + expect(actionKind('drying')).toBe('dry'); + expect(actionKind('fan')).toBe('fan'); + expect(actionKind('idle')).toBeUndefined(); + expect(actionKind('off')).toBeUndefined(); + expect(actionKind(undefined)).toBeUndefined(); + }); + + it('finds the stretches spent running, merging repeats, broken by idle and gaps', () => { + const pts = [ + s(0, 'idle'), + s(10, 'heating'), + s(20, 'heating'), + s(30, 'idle'), + s(40, 'cooling'), + s(50), + s(60, 'fan'), + s(70, 'heating'), + ]; + expect(actionSpans(pts, 100)).toEqual([ + { kind: 'heat', action: 'heating', start: 10, end: 30 }, + { kind: 'cool', action: 'cooling', start: 40, end: 50 }, + { kind: 'fan', action: 'fan', start: 60, end: 70 }, + { kind: 'heat', action: 'heating', start: 70, end: 100 }, + ]); + expect(actionSpans(pts, 100, ['heat']).map((x) => x.start)).toEqual([10, 70]); + expect(actionSpans([s(0, 'heating'), s(0, 'idle')], 10)).toEqual([]); + expect(actionSpans([], 10)).toEqual([]); + }); + + it('reads the value in effect at a time', () => { + const pts = [p(10, 1), p(20, 2), p(30, 3)]; + expect(valueAt(pts, 5)).toBeUndefined(); + expect(valueAt(pts, 10)!.v).toBe(1); + expect(valueAt(pts, 25)!.v).toBe(2); + expect(valueAt(pts, 99)!.v).toBe(3); + }); + + it('holds a set point until it changes or goes away, dropping repeats', () => { + expect(holdRuns([p(0, 21), p(10, 21), p(20, 18), p(30, null), p(40, 19), p(50, 19)])).toEqual([ + [ + { t: 0, v: 21 }, + { t: 20, v: 18 }, + { t: 30, v: 18 }, + ], + [ + { t: 40, v: 19 }, + { t: 50, v: 19 }, + ], + ]); + expect(holdRuns([p(0, null)])).toEqual([]); + expect(holdRuns([p(5, 20)])).toEqual([[{ t: 5, v: 20 }]]); + }); + + it('pairs a heat/cool range and draws the area between its ends', () => { + const range = rangePoints([p(0, 19), p(10, 18), p(20, null), p(30, 19)], [p(0, 24), p(10, 25), p(30, null)]); + expect(range.map((x) => x.v)).toEqual([[19, 24], [18, 25], null, null]); + const d = rangeBandPath( + range, + (t) => t, + (v) => v, + ); + // Along the top (high) left to right, down, then back along the bottom (low), held between readings. + expect(d).toBe('M0,24H10V25H20V25V18V18H10V19H0Z'); + expect( + rangeBandPath( + [], + (t) => t, + (v) => v, + ), + ).toBe(''); + }); +}); + +describe('climate options', () => { + it('fills in defaults from the theme and takes colours and switches', () => { + const o = climateOptions({ type: 'x' }); + expect(o).toMatchObject({ setPoint: true, shading: true, shadeFan: false }); + expect(o.colors.current).toContain('--primary-color'); + expect(o.colors.setPoint).toContain('--error-color'); + expect(o.colors.heat).toContain('--state-climate-heat-color'); + expect(o.colors.cool).toContain('--state-climate-cool-color'); + const set = climateOptions({ type: 'x', set_point: false, heating_color: 'red', cooling_color: '#00f' }); + expect(set.setPoint).toBe(false); + expect(set.colors.heat).toContain('--error-color'); + expect(set.colors.cool).toBe('#00f'); + }); + + it("names actions with Home Assistant's words, else its own, else as written", () => { + const entity = { stateObj: { entity_id: 'climate.a', state: 'heat', attributes: {} } } as ResolvedEntity; + expect(actionLabel(makeHass({}), entity, 'heating')).toBe('Heating'); + expect(actionLabel(makeHass({}), entity, 'boosting')).toBe('boosting'); + const hass = makeHass({}, { formatEntityAttributeValue: (_s, _a, v) => `HA ${String(v)}` }); + expect(actionLabel(hass, entity, 'heating')).toBe('HA heating'); + }); +}); + +type Card = HTMLElement & { setConfig(c: CardConfig): void; hass?: HomeAssistant; updateComplete: Promise }; + +const now = Date.now(); +const sec = (msAgo: number) => (now - msAgo) / 1000; + +const heatRows = [ + { s: 'heat', a: { current_temperature: 17, temperature: 18, hvac_action: 'heating' }, lu: sec(20 * H) }, + { s: 'heat', a: { current_temperature: 18.5, temperature: 18, hvac_action: 'idle' }, lu: sec(16 * H) }, + { s: 'unavailable', a: {}, lu: sec(12 * H) }, + { s: 'heat', a: { current_temperature: 19, temperature: 21, hvac_action: 'heating' }, lu: sec(8 * H) }, + { s: 'heat', a: { current_temperature: 20.6, temperature: 21, hvac_action: 'fan' }, lu: sec(2 * H) }, +]; +const rangeRows = [ + { + s: 'heat_cool', + a: { current_temperature: 25, target_temp_low: 19, target_temp_high: 24, hvac_action: 'cooling' }, + lu: sec(10 * H), + }, + { + s: 'heat_cool', + a: { current_temperature: 22, target_temp_low: 19, target_temp_high: 24, hvac_action: 'idle' }, + lu: sec(5 * H), + }, +]; + +function hassWith(history: Record, fail = false) { + const hass = makeHass({ + 'climate.lounge': [ + 'heat', + { friendly_name: 'Lounge', current_temperature: 20.6, temperature: 21, hvac_action: 'heating' }, + ], + 'climate.office': [ + 'heat_cool', + { + friendly_name: 'Office', + current_temperature: 22, + target_temp_low: 19, + target_temp_high: 24, + hvac_action: 'idle', + }, + ], + 'climate.gone': ['unavailable', { friendly_name: 'Gone' }], + 'sensor.temp': ['20', { friendly_name: 'Kitchen', unit_of_measurement: '°C' }], + }); + hass.callWS = vi.fn(async () => { + if (fail) throw new Error('boom'); + return history; + }) as HomeAssistant['callWS']; + return hass; +} + +async function mount(config: Partial, hass = hassWith({ 'climate.lounge': heatRows })) { + const card = document.createElement('chartdeck-card') as Card; + card.setConfig({ + type: 'custom:chartdeck-card', + style: 'climate', + entities: ['climate.lounge'], + ...config, + } as CardConfig); + card.hass = hass; + document.body.appendChild(card); + await card.updateComplete; + await flush(); + 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 text = (e: Element | null) => e!.textContent!.replace(/\s+/g, ' ').trim(); + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('climate chart', () => { + it('draws the room temperature, a stepped set point and shading while heating, from one history call', async () => { + const card = await mount({}); + expect(card.hass!.callWS).toHaveBeenCalledTimes(1); + const msg = (card.hass!.callWS as ReturnType).mock.calls[0][0]; + expect(msg).toMatchObject({ entity_ids: ['climate.lounge'], no_attributes: false }); + const current = q(card, 'path.current')!; + expect(current.getAttribute('d')!.match(/M/g)).toHaveLength(2); + expect(current.getAttribute('stroke')).toContain('--primary-color'); + const target = qa(card, 'path.set-point')[0]; + expect(target.getAttribute('d')).toMatch(/H.*V/); + expect(target.getAttribute('stroke')).toContain('--error-color'); + const spans = qa(card, 'rect.action-span'); + expect(spans).toHaveLength(2); + expect(spans.every((r) => r.classList.contains('action-heat'))).toBe(true); + expect(qa(card, '.series-item').map(text)).toEqual(['Lounge 20.6 °C', 'Set point 21.0 °C', 'Heating']); + expect(q(card, 'svg.plot')!.getAttribute('aria-label')).toBe('Climate chart of Lounge'); + }); + + it('draws a heat/cool range as two lines with the area between, and shades cooling', async () => { + const card = await mount({ entities: ['climate.office'] }, hassWith({ 'climate.office': rangeRows })); + const lines = qa(card, 'path.set-point').map((e) => e.getAttribute('d')); + expect(lines[0]).toBe(''); + expect(lines[1]).not.toBe(''); + expect(lines[2]).not.toBe(''); + expect(q(card, 'path.range-band')!.getAttribute('d')).toMatch(/Z$/); + expect(qa(card, 'rect.action-cool')).toHaveLength(1); + expect(qa(card, '.series-item').map(text)).toEqual(['Office 22.0 °C', 'Set point 19.0 – 24.0 °C', 'Idle']); + }); + + it('hides the set point, the shading, or shades the fan, as asked', async () => { + const plain = await mount({ set_point: false, shading: false }); + expect(qa(plain, 'path.set-point')).toHaveLength(0); + expect(qa(plain, 'rect.action-span')).toHaveLength(0); + expect(qa(plain, '.series-item').map(text)).toEqual(['Lounge 20.6 °C', 'Heating']); + const fan = await mount({ shade_fan: true, heating_color: 'green', current_color: 'purple' }); + expect(qa(fan, 'rect.action-fan')).toHaveLength(1); + expect(q(fan, 'rect.action-heat')!.getAttribute('fill')).toContain('--success-color'); + expect(q(fan, 'path.current')!.getAttribute('stroke')).toContain('--purple-color'); + }); + + it('says when an entity is not a thermostat, and shows an unavailable one as such', async () => { + const card = await mount({ entities: ['sensor.temp'] }); + expect(text(q(card, '.chart-message'))).toBe('Kitchen is not a thermostat. Pick a climate entity.'); + expect(qa(card, '.series-item').map(text)).toEqual(['Kitchen 20.0 °C']); + const gone = await mount({ entities: ['climate.gone'] }, hassWith({ 'climate.gone': heatRows })); + expect(qa(gone, '.series-item').map(text)).toEqual(['Gone Unavailable']); + expect(q(gone, 'path.current')).not.toBeNull(); + }); + + it('says when history is loading, failed, or empty', async () => { + const card = document.createElement('chartdeck-card') as Card; + card.setConfig({ type: 'custom:chartdeck-card', style: 'climate', entities: ['climate.lounge'] } as CardConfig); + card.hass = hassWith({}); + document.body.appendChild(card); + await card.updateComplete; + expect(text(q(card, '.chart-message'))).toBe('Loading history…'); + const failed = await mount({}, hassWith({}, true)); + expect(text(q(failed, '.chart-message.error'))).toBe("Couldn't load history: boom"); + const empty = await mount({ header: 'over' }, hassWith({})); + expect(text(q(empty, '.chart-message'))).toBe('No readings in this period.'); + }); + + it('shows the temperature, set point and action at the guide', async () => { + const card = await mount({}); + const area = q(card, '.plot-area')!; + area.dispatchEvent(new PointerEvent('pointermove', { clientX: 0, bubbles: true })); + await card.updateComplete; + const rows = qa(card, '.tooltip-row').map(text); + expect(rows).toEqual(['Lounge17.0 °C', 'Set point18.0 °C', 'Heating']); + expect(q(card, '.hover-dot')).not.toBeNull(); + }); + + it('takes new readings live', async () => { + const hass = hassWith({ 'climate.lounge': heatRows }); + const card = await mount({}, hass); + expect(qa(card, 'rect.action-span')).toHaveLength(2); + card.hass = { + ...hass, + states: { + ...hass.states, + 'climate.lounge': { + ...hass.states['climate.lounge'], + attributes: { ...hass.states['climate.lounge'].attributes, hvac_action: 'heating', current_temperature: 20 }, + last_updated: new Date(now - H).toISOString(), + }, + }, + }; + await card.updateComplete; + expect(qa(card, 'rect.action-span')).toHaveLength(3); + expect(card.hass.callWS).toHaveBeenCalledTimes(1); + }); + + it('checks its options', () => { + const card = document.createElement('chartdeck-card') as Card; + const set = (extra: Record) => () => + card.setConfig({ type: 'x', style: 'climate', entities: ['climate.lounge'], ...extra } as CardConfig); + expect(set({ source: 'statistics' })).toThrow(/"source" must be history/); + expect(set({ group_by: 'hour' })).toThrow(/group_by/); + expect(set({ set_point: 'yes' })).toThrow(/"set_point" must be true or false/); + expect(set({ heating_color: 3 })).toThrow(/heating_color/); + expect(set({ curve: 'wiggly' })).toThrow(/curve/); + expect(set({ source: 'history', shade_fan: true })).not.toThrow(); + }); +}); + +describe('climate chart editor', () => { + it('shows the Chart section without source or grouping, and writes the climate options', 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: 'climate', entities: ['climate.lounge'] } 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({ + period_calendar: 'rolling', + period_last: 24, + period_unit: 'hour', + period_offset: 0, + period_until: 'now', + period_navigate: false, + set_point: true, + shading: true, + shade_fan: false, + curve: 'smooth', + line_width: 2.5, + x_axis: true, + y_axis: true, + axis_labels: 'outside', + grid_horizontal: true, + grid_vertical: false, + tooltip: true, + header: 'above', + compact: false, + }); + const names = JSON.stringify(form.schema); + expect(names).not.toMatch(/"source"|"group_by"|"aggregate"/); + expect(names).toMatch(/"heating_color"/); + form.dispatchEvent( + new CustomEvent('value-changed', { + detail: { value: { ...(form.data as object), shading: false, heating_color: 'red', shade_fan: true } }, + }), + ); + expect(events.at(-1)).toEqual({ ...raw, shading: false, heating_color: 'red', shade_fan: true }); + const label = (form.computeLabel as (s: { name: string }) => string)({ name: 'set_point_color' }); + expect(label).toBe('Set point colour'); + }); +}); diff --git a/test/compare.test.ts b/test/compare.test.ts new file mode 100644 index 0000000..ef3f689 --- /dev/null +++ b/test/compare.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest'; +import { + direction, + extentOf, + lowHigh, + niceStep, + niceTicks, + readingAt, + relativeChange, + withZero, +} from '../src/chart/compare'; + +describe('comparison maths', () => { + it('picks a 1/2/2.5/5 step', () => { + expect(niceStep(10, 5)).toBe(2); + expect(niceStep(100, 4)).toBe(25); + expect(niceStep(7, 5)).toBe(2); + expect(niceStep(0.3, 3)).toBeCloseTo(0.1); + expect(niceStep(40, 5)).toBe(10); + expect(niceStep(0, 5)).toBe(1); + expect(niceStep(10, 0)).toBe(1); + }); + + it('makes round ticks that enclose the data', () => { + expect(niceTicks(16.1, 24.6, 5)).toEqual([16, 18, 20, 22, 24, 26]); + expect(niceTicks(0, 5, 5)).toEqual([0, 1, 2, 3, 4, 5]); + expect(niceTicks(-1800, 2200, 4)).toEqual([-2000, -1000, 0, 1000, 2000, 3000]); + expect(niceTicks(0.1, 0.3, 2)).toEqual([0.1, 0.2, 0.3]); + expect(niceTicks(5, 1, 4)).toEqual([1, 2, 3, 4, 5]); + expect(niceTicks(3, 3, 4)).toEqual([2.5, 2.75, 3, 3.25, 3.5]); + expect(niceTicks(NaN, 3)).toEqual([]); + }); + + it('finds the extent, skipping gaps', () => { + expect(extentOf([3, null, -2, undefined, NaN, 7])).toEqual([-2, 7]); + expect(extentOf([null])).toBeUndefined(); + expect(withZero([3, 7])).toEqual([0, 7]); + expect(withZero([-3, -1])).toEqual([-3, 0]); + expect(withZero([-3, 4])).toEqual([-3, 4]); + }); + + it('reads the value in force at a time', () => { + const points = [ + { t: 10, v: 1 }, + { t: 20, v: null }, + { t: 30, v: 3 }, + ]; + expect(readingAt(points, 5)).toBeNull(); + expect(readingAt(points, 10)).toBe(1); + expect(readingAt(points, 15)).toBe(1); + expect(readingAt(points, 25)).toBeNull(); + expect(readingAt(points, 99)).toBe(3); + expect(readingAt([], 1)).toBeNull(); + }); + + it('takes low and high from readings and statistics', () => { + expect( + lowHigh([ + { t: 1, v: 5, min: 2, max: 9 }, + { t: 2, v: null }, + { t: 3, v: 6 }, + ]), + ).toEqual([2, 9]); + expect(lowHigh([{ t: 1, v: null }])).toBeUndefined(); + }); + + it('measures change and direction', () => { + expect(relativeChange(100, 112)).toBeCloseTo(0.12); + expect(relativeChange(-10, -5)).toBeCloseTo(0.5); + expect(relativeChange(0, 5)).toBeUndefined(); + expect(relativeChange(null, 5)).toBeUndefined(); + expect(direction(1, 2)).toBe('up'); + expect(direction(2, 1)).toBe('down'); + expect(direction(2, 2)).toBe('flat'); + }); +}); diff --git a/test/data-labels.test.ts b/test/data-labels.test.ts new file mode 100644 index 0000000..62e3eb2 --- /dev/null +++ b/test/data-labels.test.ts @@ -0,0 +1,216 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import '../src/chartdeck-card'; +import { + autoSide, + edge, + labelOptions, + labelWidth, + pickPoints, + placeLabels, + sliceAnchor, + validateLabelOptions, +} from '../src/chart/data-labels'; +import type { CardConfig, HomeAssistant } from '../src/types'; +import { makeHass } from './helpers/hass'; + +const pts = (...vs: (number | null)[]) => vs.map((v, t) => ({ t, v })); + +describe('label options', () => { + it('reads and checks them', () => { + const check = (extra: Record) => () => validateLabelOptions({ type: 'x', ...extra } as CardConfig); + expect( + check({ labels: true, labels_show: 'minmax', labels_position: 'below', labels_overlap: true }), + ).not.toThrow(); + expect(check({ labels: 'yes' })).toThrow('"labels" must be true or false'); + expect(check({ labels_show: 'some' })).toThrow('"labels_show" must be one of: all, last, minmax, ends'); + expect(check({ labels_position: 'left' })).toThrow('"labels_position" must be one of: auto, above, below, center'); + expect(check({ labels_overlap: 1 })).toThrow('"labels_overlap" must be true or false'); + expect(labelOptions({ type: 'x' } as CardConfig)).toBeUndefined(); + expect(labelOptions({ type: 'x', labels: true } as CardConfig)).toEqual({ + show: 'all', + position: 'auto', + hideOverlap: true, + }); + expect(labelOptions({ type: 'x', labels: true, labels_overlap: true } as CardConfig)!.hideOverlap).toBe(false); + }); +}); + +describe('choosing and placing labels', () => { + it('picks the readings to label, most important first, never a gap', () => { + const p = pts(3, null, 9, 1, 5, null); + expect(pickPoints(p, 'last')).toEqual([4]); + expect(pickPoints(p, 'ends')).toEqual([4, 0]); + expect(pickPoints(p, 'minmax')).toEqual([2, 3]); + expect(pickPoints(p, 'all')).toEqual([4, 2, 3, 0]); + expect(pickPoints(pts(null), 'all')).toEqual([]); + }); + + it('puts a peak label above and a dip label below', () => { + const p = pts(1, 5, 2, null, 4); + expect(autoSide(p, 1)).toBe('above'); + expect(autoSide(p, 2)).toBe('below'); + expect(autoSide(p, 4)).toBe('above'); + expect(autoSide(pts(7), 0)).toBe('above'); + }); + + it('places labels where they fit, tries the other side, and hides the rest', () => { + const at = (x: number, y: number, sides: ('above' | 'below' | 'center' | 'auto')[] = ['above', 'below']) => ({ + x, + y, + width: 30, + sides, + }); + const placed = placeLabels( + [at(50, 50), at(60, 50), at(70, 50), at(50, 5, ['above']), at(200, 50, ['auto'])], + 100, + true, + ); + expect(placed.map((p) => p?.side)).toEqual(['above', 'below', undefined, undefined, 'above']); + expect(placed[0]!.box).toEqual([35, 30, 65, 44]); + expect(placeLabels([at(50, 50, ['center'])], 100, true)[0]!.box).toEqual([35, 43, 65, 57]); + // Allowed to overlap, each takes its first side. + expect(placeLabels([at(50, 50), at(50, 50), at(50, 2)], 100, false).map((p) => p?.side)).toEqual([ + 'above', + 'above', + 'above', + ]); + }); + + it('sizes labels, keeps them inside the plot, and anchors slice labels', () => { + expect(labelWidth('21.5')).toBe(30); + expect(edge(5, 30, 300)).toBe('start'); + expect(edge(295, 30, 300)).toBe('end'); + expect(edge(150, 30, 300)).toBe(''); + const [x, y] = sliceAnchor(0, Math.PI / 2, 0.4); + expect(x).toBeCloseTo(0.5 + 0.4 * Math.SQRT1_2); + expect(y).toBeCloseTo(0.5 - 0.4 * Math.SQRT1_2); + }); +}); + +type Card = HTMLElement & { setConfig(c: CardConfig): void; hass?: HomeAssistant; updateComplete: Promise }; +const now = Date.now(); +const H = 3_600_000; +const sec = (ago: number) => (now - ago) / 1000; + +function hass(rows = 6) { + const h = makeHass({ + 'sensor.in': ['21', { friendly_name: 'Inside', unit_of_measurement: '°C' }], + 'sensor.out': ['9', { friendly_name: 'Outside', unit_of_measurement: '°C' }], + }); + h.callWS = vi.fn(async () => ({ + 'sensor.in': Array.from({ length: rows }, (_, i) => ({ + s: String(20 + (i % 3)), + lu: sec((22 - (i * 20) / rows) * H), + })), + 'sensor.out': [ + { s: '5', lu: sec(20 * H) }, + { s: '2', lu: sec(10 * H) }, + { s: '9', lu: sec(4 * H) }, + ], + })) as HomeAssistant['callWS']; + return h; +} + +async function mount(extra: Record, rows?: number) { + const card = document.createElement('chartdeck-card') as Card; + card.setConfig({ + type: 'custom:chartdeck-card', + style: 'line', + entities: ['sensor.in', 'sensor.out'], + ...extra, + } as CardConfig); + card.hass = hass(rows); + document.body.appendChild(card); + await card.updateComplete; + await new Promise((r) => setTimeout(r, 0)); + await card.updateComplete; + return card; +} + +const labels = (card: Card) => + [...card.shadowRoot!.querySelectorAll('.point-label')].map((e) => ({ text: e.textContent, cls: e.className })); + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('data labels on the line chart', () => { + it('labels the readings it is asked to, not the carried edges', async () => { + const all = await mount({ labels: true }); + const texts = labels(all).map((l) => l.text); + expect(texts).toContain('5.0'); + expect(texts).toContain('2.0'); + expect(texts.filter((t) => t === '9.0')).toHaveLength(1); + const minmax = await mount({ labels: true, labels_show: 'minmax' }); + expect(labels(minmax).map((l) => l.text)).toEqual(expect.arrayContaining(['20.0', '22.0', '2.0', '9.0'])); + expect(labels(minmax)).toHaveLength(4); + const last = await mount({ labels: true, labels_show: 'last' }); + expect(labels(last).map((l) => l.text)).toEqual(expect.arrayContaining(['22.0', '9.0'])); + expect(labels(last)).toHaveLength(2); + const off = await mount({}); + expect(labels(off)).toHaveLength(0); + }); + + it('puts dips below, and honours a fixed position', async () => { + const auto = await mount({ labels: true, labels_show: 'minmax', y_min: -20 }); + expect(labels(auto).find((l) => l.text === '2.0')!.cls).toContain('below'); + const center = await mount({ labels: true, labels_position: 'center' }); + expect(labels(center).every((l) => l.cls.includes('center'))).toBe(true); + }); + + it('hides labels that would overlap, unless told to let them', async () => { + const crowded = await mount({ labels: true, entities: ['sensor.in'] }, 60); + const hidden = labels(crowded).length; + expect(hidden).toBeGreaterThan(3); + expect(hidden).toBeLessThan(60); + const overlap = await mount({ labels: true, labels_overlap: true, entities: ['sensor.in'] }, 30); + expect(labels(overlap)).toHaveLength(30); + // Allowed to overlap, a series too crowded to read gets none. + const tooMany = await mount({ labels: true, labels_overlap: true, entities: ['sensor.in'] }, 60); + expect(labels(tooMany)).toHaveLength(0); + }); +}); + +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 {}); +} + +describe('data labels in the editor', () => { + it('shows the label choices once labels are on, and writes them', async () => { + await import('../src/editor/chartdeck-card-editor'); + 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: 'line', 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(JSON.stringify(form().schema)).not.toContain('labels_show'); + ed.setConfig({ ...raw, labels: true }); + await ed.updateComplete; + expect(JSON.stringify(form().schema)).toContain('labels_show'); + form().dispatchEvent( + new CustomEvent('value-changed', { + detail: { value: { ...(form().data as object), labels_show: 'minmax', labels_overlap: true } }, + }), + ); + expect(events.at(-1)).toEqual({ ...raw, labels: true, labels_show: 'minmax', labels_overlap: true }); + }); +}); diff --git a/test/day-clock.test.ts b/test/day-clock.test.ts new file mode 100644 index 0000000..69fe6ce --- /dev/null +++ b/test/day-clock.test.ts @@ -0,0 +1,716 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import '../src/chartdeck-card'; +import { + DEFAULT_ACTIVE, + activeSet, + averageDay, + barHeights, + bucketBounds, + clip, + clockDays, + foldSlots, + formatClockDuration, + isGap, + minuteAngle, + polar, + ringLayout, + sectorPath, + stateIntervals, + switchOns, + totalMs, + wallMinute, + wallSpans, +} from '../src/chart/day-clock'; +import { DAY_CLOCK_FIELDS, dayClockEntityFields } from '../src/editor/day-clock-editor'; +import { startOf } from '../src/history/timezone'; +import type { Point } from '../src/history/types'; +import { day_clock, dialLabels, fitText, minuteLabel, ringKind, validateDayClock } from '../src/styles/day-clock'; +import { STYLES } from '../src/styles/index'; +import type { CardConfig, HomeAssistant } from '../src/types'; +import type { ResolvedEntity } from '../src/resolve'; +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 M = 60_000; +const H = 3_600_000; +const LONDON = 'Europe/London'; +const zone = { timeZone: LONDON, firstDay: 1 }; +const iso = (s: string) => Date.parse(s); +const txt = (...rows: [number, string | undefined][]): Point[] => rows.map(([t, s]) => ({ t, v: null, s })); + +describe('state intervals', () => { + // 29 Oct 2026 is GMT, so wall clock = UTC. + const midnight = iso('2026-10-29T00:00:00Z'); + const now = iso('2026-10-29T15:40:00Z'); + const on = new Set(['on']); + + it('clips an interval running over midnight from yesterday, and keeps one still running at now', () => { + // The store hands over the reading from before the range moved to its start, marked carried. + const points: Point[] = [ + { t: midnight, v: null, s: 'on', carried: true }, + ...txt([midnight + 1 * H, 'off'], [midnight + 6 * H, 'on'], [midnight + 7 * H, 'off'], [midnight + 15 * H, 'on']), + ]; + const spans = stateIntervals(points, { start: midnight, end: now }, on); + expect(spans.active).toEqual([ + { start: midnight, end: midnight + H, carried: true }, + { start: midnight + 6 * H, end: midnight + 7 * H }, + { start: midnight + 15 * H, end: now }, + ]); + expect(totalMs(spans.active)).toBe(2 * H + 40 * M); + // Already on at midnight is not a switch-on today. + expect(switchOns(spans.active)).toBe(2); + expect(spans.word).toBe('on'); + }); + + it('treats a point earlier than the range as carried, and skips gaps', () => { + const points = txt( + [midnight - 2 * H, 'on'], + [midnight + H, 'unavailable'], + [midnight + 2 * H, 'on'], + [midnight + 3 * H, 'unknown'], + ); + const spans = stateIntervals(points, { start: midnight, end: midnight + 4 * H }, on); + expect(spans.active).toEqual([ + { start: midnight, end: midnight + H, carried: true }, + { start: midnight + 2 * H, end: midnight + 3 * H }, + ]); + expect(spans.known).toEqual(spans.active); + expect(isGap(undefined)).toBe(true); + expect(isGap('')).toBe(true); + expect(isGap('off')).toBe(false); + }); + + it('joins repeats and honours a custom active set', () => { + const points = txt([0, 'heat'], [10, 'heat'], [20, 'idle'], [30, 'cool']); + expect(stateIntervals(points, { start: 0, end: 40 }, new Set(['heat', 'cool'])).active).toEqual([ + { start: 0, end: 20 }, + { start: 30, end: 40 }, + ]); + }); + + it('clips to a range, marking a cut start as carried', () => { + expect( + clip( + [ + { start: 0, end: 10 }, + { start: 20, end: 30 }, + { start: 40, end: 50 }, + ], + { start: 5, end: 25 }, + ), + ).toEqual([ + { start: 5, end: 10, carried: true }, + { start: 20, end: 25 }, + ]); + }); + + it('reads the active option as one state, a list, or the defaults', () => { + expect([...activeSet('heating')]).toEqual(['heating']); + expect([...activeSet([' on ', 'open', 3])]).toEqual(['on', 'open']); + expect([...activeSet([])]).toEqual(DEFAULT_ACTIVE); + expect(activeSet(undefined).has('home')).toBe(true); + }); +}); + +describe('the dial and the clock changes', () => { + it('maps a plain day minute by minute', () => { + const midnight = iso('2026-10-29T00:00:00Z'); + expect(wallMinute(midnight + 90 * M, midnight, LONDON)).toBe(90); + expect(wallSpans({ start: midnight + 6 * H, end: midnight + 8 * H }, midnight, LONDON)).toEqual([[360, 480]]); + expect(minuteAngle(360)).toBeCloseTo(Math.PI / 2); + }); + + it('on the 23-hour day, the skipped hour is left empty and the arc keeps its real length', () => { + const bounds = clockDays(iso('2026-03-29T12:00:00Z'), zone, 1, 0); + expect(bounds).toEqual([iso('2026-03-29T00:00:00Z'), iso('2026-03-29T23:00:00Z')]); + // 00:30 GMT to 03:30 BST: two hours of real time, drawn 00:30–01:00 and 02:00–03:30. + const spans = wallSpans( + { start: iso('2026-03-29T00:30:00Z'), end: iso('2026-03-29T02:30:00Z') }, + bounds[0], + LONDON, + ); + expect(spans).toEqual([ + [30, 60], + [120, 210], + ]); + // The day still ends at the top of the dial. + expect(wallSpans({ start: bounds[1] - H, end: bounds[1] }, bounds[0], LONDON)).toEqual([[1380, 1440]]); + }); + + it('on the 25-hour day, the repeated hour draws over itself', () => { + const bounds = clockDays(iso('2026-10-25T12:00:00Z'), zone, 1, 0); + expect(bounds[1] - bounds[0]).toBe(25 * H); + // 00:30 BST to 02:30 GMT: three hours, 01:00–02:00 twice. + const spans = wallSpans( + { start: iso('2026-10-24T23:30:00Z'), end: iso('2026-10-25T02:30:00Z') }, + bounds[0], + LONDON, + ); + expect(spans).toEqual([ + [30, 120], + [60, 150], + ]); + }); + + it('lists N whole days back from today, stepping back N days at a time', () => { + const now = iso('2026-10-29T15:40:00Z'); + const week = clockDays(now, zone, 7, 0); + expect(week).toHaveLength(8); + expect(week[6]).toBe(iso('2026-10-29T00:00:00Z')); + expect(week[7]).toBe(iso('2026-10-30T00:00:00Z')); + // Over the clock change: 25 Oct is 25 hours long. + expect(week[0]).toBe(iso('2026-10-22T23:00:00Z')); + expect(clockDays(now, zone, 7, 1)[7]).toBe(week[0]); + expect(clockDays(now, zone, 1, 1)).toEqual([iso('2026-10-28T00:00:00Z'), iso('2026-10-29T00:00:00Z')]); + }); + + it('draws sectors, whole rings and polar points', () => { + expect(polar(10, 0)).toEqual([0, -10]); + expect(polar(10, Math.PI / 2)).toEqual([10, 0]); + expect(sectorPath(5, 10, 0, Math.PI / 2)).toBe('M0 -10A10 10 0 0 1 10 0L5 0A5 5 0 0 0 0 -5Z'); + expect(sectorPath(0, 10, 0, 4)).toMatch(/L0 0Z$/); + expect(sectorPath(5, 10, 0, Math.PI * 2).match(/M/g)).toHaveLength(2); + expect(sectorPath(0, 10, 0, Math.PI * 2).match(/M/g)).toHaveLength(1); + expect(sectorPath(5, 10, 1, 1)).toBe(''); + }); +}); + +describe('the average day', () => { + const days = [0, 1, 2, 3].map((d) => iso('2026-10-26T00:00:00Z') + d * 24 * H); + + it('counts, for each minute, the days it was on out of the days it was known', () => { + const at = (d: number, h: number) => days[d] + h * H; + const active = [ + { start: at(0, 7), end: at(0, 8) }, + { start: at(1, 7), end: at(1, 7.5) }, + ]; + const known = [{ start: days[0], end: days[3] }]; + expect(averageDay(active, known, days, LONDON)).toEqual([ + { from: 420, to: 450, on: 2, of: 3 }, + { from: 450, to: 480, on: 1, of: 3 }, + ]); + }); + + it('leaves out days a minute was not known (unrecorded, or not yet happened)', () => { + const active = [ + { start: days[0] + 7 * H, end: days[0] + 8 * H }, + { start: days[2] + 7 * H, end: days[2] + 8 * H }, + ]; + // Day 1 unrecorded; day 2 only known until 07:30. + const known = [ + { start: days[0], end: days[1] }, + { start: days[2], end: days[2] + 7.5 * H }, + ]; + expect(averageDay(active, known, days, LONDON)).toEqual([ + { from: 420, to: 450, on: 2, of: 2 }, + { from: 450, to: 480, on: 1, of: 1 }, + ]); + }); +}); + +describe('bars by the hour', () => { + const midnight = iso('2026-10-29T00:00:00Z'); + const day = [midnight, midnight + 24 * H]; + const num = (...rows: [number, number | null][]): Point[] => rows.map(([t, v]) => ({ t, v })); + + it('buckets by real time from the day start', () => { + expect(bucketBounds({ start: 0, end: 2 * H }, 30)).toEqual([0, 30 * M, 60 * M, 90 * M, 2 * H]); + }); + + it('takes a time-weighted mean per hour, up to now', () => { + const points = num([midnight, 10], [midnight + 30 * M, 20], [midnight + 2 * H, 40]); + const slots = foldSlots(points, day, 60, 'mean', midnight + 3 * H, LONDON); + expect(slots).toEqual([ + { index: 0, v: 15, days: 1 }, + { index: 1, v: 20, days: 1 }, + { index: 2, v: 40, days: 1 }, + ]); + }); + + it('sums a meter’s change per half hour', () => { + const points = num([midnight, 100], [midnight + 10 * M, 101], [midnight + 40 * M, 103.5]); + const slots = foldSlots(points, day, 30, 'change', midnight + 60 * M, LONDON); + expect(slots.map((s) => [s.index, s.v])).toEqual([ + [0, 1], + [1, 2.5], + ]); + }); + + it('adds the two 01:00 hours of the 25-hour day together for a total, and averages them for a mean', () => { + const bounds = clockDays(iso('2026-10-25T12:00:00Z'), zone, 1, 0); + const first = iso('2026-10-25T00:00:00Z'); // 01:00 BST + const second = iso('2026-10-25T01:00:00Z'); // 01:00 GMT + const meter = num([first, 0], [first + 30 * M, 2], [second + 30 * M, 5]); + const change = foldSlots(meter, bounds, 60, 'change', bounds[1], LONDON); + expect(change.find((s) => s.index === 1)?.v).toBe(5); + const temp = num([first, 10], [second, 20], [second + H, 30]); + const mean = foldSlots(temp, bounds, 60, 'mean', second + H + M, LONDON); + expect(mean.find((s) => s.index === 1)?.v).toBe(15); + }); + + it('averages each hour across days', () => { + const two = [midnight - 24 * H, midnight, midnight + 24 * H]; + const points = num([two[0], 10], [two[0] + H, null], [midnight, 30], [midnight + H, null]); + const slots = foldSlots(points, two, 60, 'mean', midnight + 2 * H, LONDON); + expect(slots).toEqual([{ index: 0, v: 20, days: 2 }]); + expect(foldSlots(points, two, 60, 'max', midnight + 2 * H, LONDON)[0].v).toBe(20); + }); + + it('scales totals from zero and levels from lowest to highest', () => { + const slots = [5, 10, 0].map((v, index) => ({ index, v, days: 1 })); + expect(barHeights(slots, true)).toEqual([0.5, 1, 0]); + expect(barHeights(slots, false)).toEqual([0.15 + 0.85 * 0.5, 1, 0.15]); + expect(barHeights([{ index: 0, v: 3, days: 1 }], false)).toEqual([1]); + expect(barHeights([], true)).toEqual([]); + expect(barHeights([{ index: 0, v: 0, days: 1 }], true)).toEqual([0]); + }); +}); + +describe('layout and text', () => { + it('stacks rings from the outside in and keeps a hole for the centre', () => { + const one = ringLayout(['state']); + expect(one.rings).toEqual([{ r0: 77, r1: 92 }]); + const many = ringLayout(['state', 'numeric', 'state', 'numeric', 'state', 'state']); + expect(many.hole).toBeCloseTo(44); + expect(many.rings[0].r1).toBe(92); + expect(ringLayout([]).hole).toBe(92); + }); + + it('formats durations as the dial shows them', () => { + expect(formatClockDuration(4 * H + 10 * M)).toBe('4 h 10 m'); + expect(formatClockDuration(2 * H)).toBe('2 h'); + expect(formatClockDuration(25 * M)).toBe('25 m'); + expect(formatClockDuration(-5)).toBe('0 m'); + }); + + it('fits centre text, cutting it short only when it would get too small', () => { + expect(fitText('4 h 10 m', 80, 17)).toEqual({ size: 17, text: '4 h 10 m' }); + const cut = fitText('A very long name for a radiator valve on', 60, 11); + expect(cut.size).toBeCloseTo(11 * 0.62); + expect(cut.text.endsWith('…')).toBe(true); + }); + + it('labels the dial on a 24- or 12-hour clock', () => { + const h24 = makeHass({}, { locale: { language: 'en', number_format: 'language', time_format: '24' } }); + const h12 = makeHass({}, { locale: { language: 'en', number_format: 'language', time_format: '12' } }); + expect(dialLabels(h24)).toEqual(['00', '06', '12', '18']); + expect(dialLabels(h12)).toEqual(['12 AM', '6 AM', '12 PM', '6 PM']); + expect(dialLabels(makeHass({}, { locale: { language: 'en-GB', number_format: 'language' } }))).toEqual([ + '00', + '06', + '12', + '18', + ]); + expect(minuteLabel(420, h24)).toBe('07:00'); + expect(minuteLabel(1440, h24)).toBe('00:00'); + expect(minuteLabel(810, h12)).toBe('1:30 PM'); + }); + + it('decides arcs or bars from the domain, the active option, the unit and the value', () => { + const e = (id: string, extra: Partial = {}, attrs: Record = {}) => + ({ + entityId: id, + config: { entity: id }, + stateObj: { entity_id: id, state: '1', attributes: attrs }, + available: true, + ...extra, + }) as unknown as ResolvedEntity; + expect(ringKind(e('binary_sensor.door'))).toBe('state'); + expect(ringKind(e('sensor.power', {}, { unit_of_measurement: 'W' }))).toBe('numeric'); + expect(ringKind(e('sensor.mode', { available: false }))).toBe('state'); + expect(ringKind(e('sensor.count'))).toBe('numeric'); + // A text sensor with no unit gets a state ring, not empty number bars. + const washer = e('sensor.washer', { + stateObj: { entity_id: 'sensor.washer', state: 'washing', attributes: {} }, + } as never); + expect(ringKind(washer)).toBe('state'); + expect(ringKind(e('sensor.flag', { config: { entity: 'sensor.flag', active: '1' } } as never))).toBe('state'); + expect(ringKind(e('sensor.flag', { config: { entity: 'sensor.flag', active: [] } } as never))).toBe('numeric'); + }); +}); + +describe('validate', () => { + const base = { type: 'x', style: 'day_clock' } as CardConfig; + it('accepts its options and rejects bad ones readably', () => { + expect(() => + validateDayClock({ ...base, days: 7, center: 'now', bucket: 30, aggregate: 'max', period: { offset: 1 } }), + ).not.toThrow(); + expect(() => validateDayClock({ ...base, bucket: '15' })).not.toThrow(); + expect(() => validateDayClock({ ...base, days: 0 })).toThrow('"days" must be a whole number from 1 to 31'); + expect(() => validateDayClock({ ...base, days: 1.5 })).toThrow('"days"'); + expect(() => validateDayClock({ ...base, center: 'middle' })).toThrow('"center" must be one of: total, now, none'); + expect(() => validateDayClock({ ...base, bucket: 20 })).toThrow('"bucket" must be one of: 15, 30, 60'); + expect(() => validateDayClock({ ...base, aggregate: 'median' })).toThrow('"aggregate" must be one of'); + expect(() => validateDayClock({ ...base, tooltip: 'yes' })).toThrow('"tooltip" must be true or false'); + expect(() => + validateDayClock({ ...base, entities: ['switch.a', { entity: 'switch.b', active: ['on'] }] }), + ).not.toThrow(); + expect(() => validateDayClock({ ...base, entities: [{ entity: 'switch.b', active: 1 }] })).toThrow( + '"active" must be a state or a list of states', + ); + }); +}); + +/* ------------------------------------------------------------------ the card */ + +type Card = HTMLElement & { setConfig(c: CardConfig): void; hass?: HomeAssistant; updateComplete: Promise }; + +const NOW = iso('2026-10-29T15:40:00Z'); +const MIDNIGHT = iso('2026-10-29T00:00:00Z'); +const s = (t: number) => t / 1000; + +function hassFor( + history: Record, + extra: Record]> = {}, +) { + const hass = makeHass( + { + 'climate.house': ['heat', { friendly_name: 'Heating', hvac_action: 'heating' }], + 'switch.water': ['off', { friendly_name: 'Hot water' }], + 'sensor.motion': [ + '3', + { friendly_name: 'Motion', unit_of_measurement: 'events', state_class: 'total_increasing' }, + ], + ...extra, + }, + { + locale: { language: 'en', number_format: 'language', time_zone: 'server', time_format: '24' }, + config: { time_zone: LONDON }, + } as Partial, + ); + hass.formatEntityState = (o, st) => st ?? o.state; + hass.formatEntityAttributeValue = (_o, _a, v) => String(v); + hass.callWS = vi.fn(async (msg: { entity_ids: string[] }) => { + const out: Record = {}; + for (const id of msg.entity_ids) if (history[id]) out[id] = history[id]; + return out; + }) as unknown as HomeAssistant['callWS']; + return hass; +} + +const HISTORY = { + 'climate.house': [ + { s: 'heat', a: { hvac_action: 'heating' }, lu: s(MIDNIGHT - H) }, + { s: 'heat', a: { hvac_action: 'idle' }, lu: s(MIDNIGHT + H) }, + { s: 'heat', a: { hvac_action: 'heating' }, lu: s(MIDNIGHT + 6 * H) }, + { s: 'heat', a: { hvac_action: 'idle' }, lu: s(MIDNIGHT + 8 * H) }, + { s: 'heat', a: { hvac_action: 'heating' }, lu: s(MIDNIGHT + 15 * H) }, + ], + 'switch.water': [ + { s: 'off', lu: s(MIDNIGHT - H) }, + { s: 'on', lu: s(MIDNIGHT + 6 * H) }, + { s: 'unavailable', lu: s(MIDNIGHT + 7 * H) }, + { s: 'off', lu: s(MIDNIGHT + 8 * H) }, + ], + 'sensor.motion': [ + { s: '0', lu: s(MIDNIGHT - H) }, + { s: '2', lu: s(MIDNIGHT + 7 * H + 10 * M) }, + { s: '3', lu: s(MIDNIGHT + 9 * H) }, + ], +}; + +async function mount(config: Partial, hass = hassFor(HISTORY)) { + const card = document.createElement('chartdeck-card') as Card; + card.setConfig({ + type: 'custom:chartdeck-card', + style: 'day_clock', + entities: ['climate.house', 'switch.water', 'sensor.motion'], + ...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 text = (el: Element | null) => el?.textContent?.replace(/\s+/g, ' ').trim(); + +beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(NOW); +}); + +afterEach(() => { + document.body.innerHTML = ''; + vi.useRealTimers(); +}); + +describe('24-hour clock card', () => { + it('is registered as a history chart named "24-hour clock"', () => { + expect(STYLES.day_clock).toBe(day_clock); + expect(day_clock.group).toBe('history'); + expect(day_clock.thumbnail()).toBeTruthy(); + }); + + it('draws one ring per entity: arcs for states, bars for numbers, with a hand and the dimmed future', async () => { + const card = await mount({}); + const rings = qa(card, '.dc-ring'); + expect(rings.map((r) => r.getAttribute('data-index'))).toEqual(['0', '1', '2']); + expect(rings.map((r) => r.classList.contains('numeric'))).toEqual([false, false, true]); + // Heating: carried in from yesterday, 06–08, and running since 15:00. + expect(rings[0].querySelectorAll('.dc-mark')).toHaveLength(3); + // Hot water: on 06–07, then unavailable (a gap). + expect(rings[1].querySelectorAll('.dc-mark')).toHaveLength(1); + // Motion: a meter, so bars of its change in the hours it went up (07:00 and 08:00). + expect(rings[2].querySelectorAll('.dc-mark').length).toBeGreaterThan(0); + expect(q(card, '.dc-hand')).not.toBeNull(); + expect(q(card, '.dc-future')).not.toBeNull(); + expect(text(q(card, '.dc-centre-value'))).toBe('3 h 40 m'); + expect(text(q(card, '.dc-centre-label'))).toBe('Heating on'); + expect(qa(card, '.dc-label').map((l) => l.textContent)).toEqual(['00', '06', '12', '18']); + expect(q(card, 'svg.dc-svg')!.getAttribute('role')).toBe('img'); + expect(q(card, 'svg.dc-svg')!.getAttribute('aria-label')).toBe('24-hour clock chart of Heating, Hot water, Motion'); + const legend = qa(card, '.dc-item').map(text); + expect(legend[0]).toBe('Heating 3 h 40 m · 2 times'); + expect(legend[1]).toBe('Hot water 1 h · once'); + expect(legend[2]).toBe('Motion 3.0 events · busiest 07:00'); + expect(text(q(card, '.dc-now'))).toBe('15:40 · Heating on'); + // Climate rings follow what it is doing, not its mode. + const call = (card.hass!.callWS as ReturnType).mock.calls.find((c) => c[0].no_attributes === false); + expect(call?.[0].entity_ids).toEqual(['climate.house']); + }); + + it('shows a tooltip for an arc on hover or focus, and for a bar', async () => { + const card = await mount({}); + const arcs = qa(card, '.dc-ring')[0].querySelectorAll('.dc-mark'); + arcs[1].dispatchEvent(new PointerEvent('pointerenter', { pointerType: 'mouse' })); + await card.updateComplete; + expect(text(q(card, '.dc-tip .tooltip-time'))).toBe('06:00 – 08:00'); + expect(text(q(card, '.dc-tip .tooltip-value'))).toBe('2 h'); + arcs[2].dispatchEvent(new FocusEvent('focus')); + await card.updateComplete; + expect(text(q(card, '.dc-tip .tooltip-time'))).toBe('15:00 – now'); + expect(text(q(card, '.dc-tip .tooltip-value'))).toBe('40 m so far'); + arcs[2].dispatchEvent(new FocusEvent('blur')); + await card.updateComplete; + expect(q(card, '.dc-tip')).toBeNull(); + const bar = qa(card, '.dc-ring')[2].querySelector('.dc-mark')!; + bar.dispatchEvent(new PointerEvent('pointerenter', { pointerType: 'mouse' })); + await card.updateComplete; + expect(text(q(card, '.dc-tip .tooltip-time'))).toBe('07:00 – 08:00'); + expect(text(q(card, '.dc-tip .tooltip-value'))).toBe('2.0 events'); + bar.dispatchEvent(new PointerEvent('pointerleave', { pointerType: 'mouse' })); + await card.updateComplete; + expect(q(card, '.dc-tip')).toBeNull(); + }); + + it('keeps a touch tooltip up for a moment', async () => { + const card = await mount({}); + const arc = qa(card, '.dc-ring')[1].querySelector('.dc-mark')!; + vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }); + vi.setSystemTime(NOW); + arc.dispatchEvent(new PointerEvent('pointerdown', { pointerType: 'touch' })); + arc.dispatchEvent(new PointerEvent('pointerleave', { pointerType: 'touch' })); + await card.updateComplete; + expect(q(card, '.dc-tip')).not.toBeNull(); + vi.advanceTimersByTime(3100); + await card.updateComplete; + expect(q(card, '.dc-tip')).toBeNull(); + }); + + it('honours per-entity colour and active states, and centre options', async () => { + const card = await mount({ + center: 'now', + entities: [ + { entity: 'switch.water', color: 'red', active: 'off' }, + { entity: 'climate.house', name: 'House' }, + ], + } as Partial); + const first = qa(card, '.dc-ring')[0]; + expect(first.querySelector('.dc-mark')!.getAttribute('fill')).toContain('--error-color'); + // Off from before midnight to 06:00, and from 08:00 on. + expect(first.querySelectorAll('.dc-mark')).toHaveLength(2); + expect(text(q(card, '.dc-centre-value'))).toBe('15:40'); + expect(text(q(card, '.dc-centre-label'))).toBe('Hot water off'); + const none = await mount({ center: 'none' }); + expect(none.shadowRoot!.querySelector('.dc-centre')).toBeNull(); + }); + + it('shows a missing entity as an empty ring, and only a message when there is nothing at all', async () => { + const card = await mount({ entities: ['switch.water', 'light.gone'] }); + const rings = qa(card, '.dc-ring'); + expect(rings).toHaveLength(2); + expect(rings[1].querySelectorAll('.dc-mark')).toHaveLength(0); + expect(text(qa(card, '.dc-item')[1])).toBe('light.gone Entity not found'); + const empty = await mount({ entities: ['light.gone'] }); + expect(text(empty.shadowRoot!.querySelector('.chart-message'))).toBe('No readings in this period.'); + expect(empty.shadowRoot!.querySelector('.dc-dial')).toBeNull(); + }); + + it('says so while loading and when loading fails', async () => { + const hass = hassFor(HISTORY); + hass.callWS = vi.fn(() => new Promise(() => undefined)) as unknown as HomeAssistant['callWS']; + const loading = await mount({}, hass); + expect(text(loading.shadowRoot!.querySelector('.chart-message'))).toBe('Loading history…'); + const failing = hassFor(HISTORY); + failing.callWS = vi.fn(async () => { + throw new Error('boom'); + }) as unknown as HomeAssistant['callWS']; + const failed = await mount({}, failing); + expect(text(failed.shadowRoot!.querySelector('.chart-message.error'))).toBe("Couldn't load history: boom"); + }); + + it('shows the average day over several days, with no hand', async () => { + const day = 24 * H; + const history = { + 'switch.water': [ + { s: 'off', lu: s(MIDNIGHT - 3 * day) }, + { s: 'on', lu: s(MIDNIGHT - 2 * day + 7 * H) }, + { s: 'off', lu: s(MIDNIGHT - 2 * day + 8 * H) }, + { s: 'on', lu: s(MIDNIGHT - day + 7 * H) }, + { s: 'off', lu: s(MIDNIGHT - day + 7.5 * H) }, + ], + 'sensor.motion': [ + { s: '0', lu: s(MIDNIGHT - 3 * day) }, + { s: '4', lu: s(MIDNIGHT - day + 7 * H + M) }, + ], + }; + const card = await mount({ days: 3, entities: ['switch.water', 'sensor.motion'] }, hassFor(history)); + expect(q(card, '.dc-hand')).toBeNull(); + const arcs = [...qa(card, '.dc-ring')[0].querySelectorAll('.dc-mark')]; + expect(arcs.map((a) => a.getAttribute('fill-opacity'))).toEqual(['0.6666666666666666', '0.3333333333333333']); + // Arcs over several days are not each a tab stop. + expect(arcs[0].getAttribute('tabindex')).toBeNull(); + arcs[0].dispatchEvent(new PointerEvent('pointerenter', { pointerType: 'mouse' })); + await card.updateComplete; + expect(text(q(card, '.dc-tip'))).toBe('07:00 – 07:30 Hot water on 2 of 3 days'); + expect(text(qa(card, '.dc-item')[0])).toBe('Hot water 30 m a day · 0.7 times a day'); + expect(text(qa(card, '.dc-item')[1])).toBe('Motion 1.3 events a day · busiest 07:00'); + expect(text(q(card, '.dc-centre-label'))).toBe('Hot water on per day'); + expect(text(q(card, '.dc-foot'))).toBe('Average day · last 3 days'); + const bar = qa(card, '.dc-ring')[1].querySelector('.dc-mark')!; + bar.dispatchEvent(new PointerEvent('pointerenter', { pointerType: 'mouse' })); + await card.updateComplete; + expect(text(q(card, '.dc-tip .tooltip-value'))).toBe('1.3 events on average'); + }); + + it('steps back a day with the buttons, drawing the whole of yesterday', async () => { + const card = await mount({ period: { navigate: true } } as Partial); + expect(text(q(card, '.period-label'))).toBe('Today'); + (q(card, '.period-prev') as HTMLButtonElement).click(); + await card.updateComplete; + await new Promise((r) => setTimeout(r, 0)); + await card.updateComplete; + expect(text(q(card, '.period-label'))).toBe('Yesterday'); + expect(q(card, '.dc-hand')).toBeNull(); + expect(q(card, '.dc-now')).toBeNull(); + }); + + it('labels numbers that are levels by their peak, and a numeric-only centre by its value', async () => { + const hass = hassFor( + { + 'sensor.temp': [ + { s: '18', lu: s(MIDNIGHT - H) }, + { s: '21', lu: s(MIDNIGHT + 7 * H) }, + { s: '19', lu: s(MIDNIGHT + 9 * H) }, + ], + }, + { 'sensor.temp': ['19', { friendly_name: 'Lounge', unit_of_measurement: '°C' }] }, + ); + const card = await mount({ entities: ['sensor.temp'], bucket: 30 } as Partial, hass); + expect(text(q(card, '.dc-item'))).toBe('Lounge peak 21.0 °C at 07:00'); + expect(qa(card, '.dc-mark')).toHaveLength(32); + expect(text(q(card, '.dc-centre-value'))).toBe('19.0 °C'); + expect(text(q(card, '.dc-centre-label'))).toBe('Lounge'); + }); + + it('moves the hand every minute and stops when the card leaves', async () => { + vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }); + vi.setSystemTime(NOW); + const card = document.createElement('chartdeck-card') as Card; + card.setConfig({ type: 'custom:chartdeck-card', style: 'day_clock', entities: ['switch.water'] } as CardConfig); + card.hass = hassFor(HISTORY); + document.body.appendChild(card); + await vi.advanceTimersByTimeAsync(10); + await card.updateComplete; + const before = q(card, '.dc-hand')!.getAttribute('x2'); + await vi.advanceTimersByTimeAsync(20 * 60_000); + await card.updateComplete; + expect(q(card, '.dc-hand')!.getAttribute('x2')).not.toBe(before); + card.remove(); + expect(vi.getTimerCount()).toBe(0); + }); +}); + +describe('24-hour clock editor', () => { + it('shows the Chart section and writes days, centre, bucket and aggregate', 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: 'day_clock', entities: ['switch.a'], bucket: 30 } 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({ + days: 1, + period_offset: 0, + period_navigate: false, + center: 'total', + bucket: '30', + aggregate: 'auto', + tooltip: true, + }); + form.dispatchEvent( + new CustomEvent('value-changed', { + detail: { value: { ...(form.data as object), days: 7, center: 'none', bucket: '15', aggregate: 'max' } }, + }), + ); + expect(events.at(-1)).toEqual({ ...raw, days: 7, center: 'none', bucket: 15, aggregate: 'max' }); + form.dispatchEvent( + new CustomEvent('value-changed', { + detail: { value: { ...(form.data as object), bucket: '60', period_navigate: true } }, + }), + ); + const last = events.at(-1)!; + expect(last.bucket).toBeUndefined(); + expect(last.period).toEqual({ navigate: true }); + const list = ed.shadowRoot!.querySelector('cd-entities-editor') as Stub; + const fields = (list.chartFields as { fields: { name: string; path: string[] }[] }).fields; + expect(fields.map((f) => f.path.join('.'))).toEqual(['color', 'active']); + expect(dayClockEntityFields().schema).toHaveLength(1); + expect(DAY_CLOCK_FIELDS.map((f) => f.name)).toContain('bucket'); + }); +}); + +// The interval maths runs on any start of day the zone gives. +it('finds the start of a day in the zone', () => { + expect(startOf('day', NOW, zone)).toBe(MIDNIGHT); +}); + +describe('text history (shared loader, as on the state timeline branch)', () => { + it('keeps the words of states, keyed apart from the numbers', async () => { + const { key, parseHistory, toPoint, toText } = await import('../src/history/fetch'); + expect(parseHistory([{ s: 'on', lu: 1 }], undefined, true)).toEqual([{ t: 1000, v: null, s: 'on' }]); + expect(toPoint(5, 3, true)).toEqual({ t: 5, v: null, s: '3' }); + expect(toPoint(5, '3')).toEqual({ t: 5, v: 3 }); + expect(toText(true)).toBe('true'); + expect(toText(null)).toBeUndefined(); + expect(key({ entity: 'a', text: true })).toBe('a|text'); + expect(key({ entity: 'a', attribute: 'b', text: true })).toBe('a|b|text'); + }); +}); diff --git a/test/density.test.ts b/test/density.test.ts new file mode 100644 index 0000000..05846f6 --- /dev/null +++ b/test/density.test.ts @@ -0,0 +1,220 @@ +import { describe, expect, it } from 'vitest'; +import { + bandwidth, + byValue, + densityCurve, + jitter, + kde, + quantile, + readingsOf, + sampleReadings, + splitReadings, + summarize, +} from '../src/chart/density'; +import type { Reading } from '../src/chart/density'; +import { + categoryGeometry, + categoryOptions, + clockScale, + labelColumn, + labelCount, + numberScale, + validateCategoryOptions, +} from '../src/chart/category-plot'; +import type { CardConfig } from '../src/types'; + +const plain = (values: number[]): Reading[] => values.map((v, i) => ({ t: i, v, w: 1, real: true })); + +describe('readings', () => { + it('weights history by how long each value held, leaving out gaps and the edge point', () => { + const points = [ + { t: 0, v: 20, carried: true }, + { t: 10, v: 21 }, + { t: 11, v: null }, + { t: 15, v: 20 }, + { t: 100, v: 20 }, + ]; + expect(readingsOf(points, true, 100)).toEqual([ + { t: 0, v: 20, w: 10, real: false }, + { t: 10, v: 21, w: 1, real: true }, + { t: 15, v: 20, w: 85, real: true }, + ]); + }); + + it('counts grouped values and statistics rows once each', () => { + const points = [ + { t: 0, v: 1 }, + { t: 10, v: null }, + { t: 20, v: 3 }, + ]; + expect(readingsOf(points, false, 30).map((r) => r.w)).toEqual([1, 1]); + }); + + it('keeps two readings in the same instant, barely', () => { + const r = readingsOf( + [ + { t: 5, v: 1 }, + { t: 5, v: 2 }, + ], + true, + 10, + ); + expect(r.map((x) => x.w)).toEqual([1, 5]); + }); + + it('splits held values across period boundaries', () => { + const r: Reading[] = [ + { t: 0, v: 1, w: 150, real: false }, + { t: 150, v: 2, w: 50, real: true }, + ]; + const [a, b] = splitReadings(r, [0, 100, 200], true); + expect(a).toEqual([{ t: 0, v: 1, w: 100, real: false }]); + expect(b).toEqual([ + { t: 100, v: 1, w: 50, real: false }, + { t: 150, v: 2, w: 50, real: true }, + ]); + const byTime = splitReadings(plain([5, 6, 7]), [0, 2, 4], false); + expect(byTime.map((p) => p.map((x) => x.v))).toEqual([[5, 6], [7]]); + }); + + it('samples readings taken in the range, evenly', () => { + const r = [...plain([1, 2, 3, 4, 5, 6]), { t: 9, v: 0, w: 1, real: false }]; + expect(sampleReadings(r, 3).map((x) => x.v)).toEqual([1, 3, 5]); + expect(sampleReadings(r, 10)).toHaveLength(6); + }); +}); + +describe('quantiles and summary', () => { + it('interpolates between readings at the middle of their weight', () => { + const s = byValue(plain([4, 1, 3, 2])); + expect(quantile(s, 0.5)).toBe(2.5); + expect(quantile(s, 0)).toBe(1); + expect(quantile(s, 1)).toBe(4); + expect(quantile(byValue(plain([7])), 0.3)).toBe(7); + expect(quantile([], 0.5)).toBeNaN(); + }); + + it('lets a value held longer pull the median to it', () => { + const s = byValue([ + { t: 0, v: 20, w: 100, real: true }, + { t: 1, v: 30, w: 1, real: true }, + { t: 2, v: 31, w: 1, real: true }, + ]); + expect(quantile(s, 0.5)).toBeLessThan(20.5); + }); + + it('summarises with whiskers inside 1.5 box-lengths', () => { + const s = summarize(plain([1, 2, 3, 4, 5, 6, 7, 8, 100]))!; + expect(s).toMatchObject({ n: 9, min: 1, max: 100, median: 5, low: 1, high: 8 }); + expect(s.q1).toBeCloseTo(2.75); + expect(s.mean).toBeCloseTo(136 / 9); + expect(summarize([])).toBeUndefined(); + }); +}); + +describe('density', () => { + it('picks a bandwidth by Silverman’s rule, never finer than the sensor’s step', () => { + const values = Array.from({ length: 100 }, (_, i) => Math.round(Math.sin(i) * 50) / 10); + const h = bandwidth(plain(values)); + expect(h).toBeGreaterThanOrEqual(0.1); + expect(h).toBeLessThan(2); + // Coarse steps: never narrower than a step. + expect(bandwidth(plain([0, 0, 0, 5, 5, 5, 10, 10]))).toBeGreaterThanOrEqual(5); + // All the same: 1 % of the value, or 1 at zero. + expect(bandwidth(plain([20, 20, 20]))).toBeCloseTo(0.2); + expect(bandwidth(plain([0, 0]))).toBe(1); + expect(bandwidth([])).toBe(1); + }); + + it('counts a long-held value as one reading, not many', () => { + const many = plain([1, 2, 3, 4, 5, 6, 7, 8]); + const held = many.map((r, i) => ({ ...r, w: i === 0 ? 1000 : 1 })); + expect(bandwidth(held)).not.toBe(bandwidth(many)); + }); + + it('estimates a density that integrates to about one', () => { + const r = plain([0, 0, 1, 2]); + const xs = Array.from({ length: 401 }, (_, i) => -4 + i * 0.025); + const ds = kde(r, 0.5, xs); + const area = ds.reduce((a, d) => a + d * 0.025, 0); + expect(area).toBeCloseTo(1, 2); + expect(kde([], 1, [0])).toEqual([0]); + }); + + it('outlines the density from two bandwidths below to two above, peak scaled to one', () => { + const curve = densityCurve(plain([10, 11, 12]), 1, [0, 100], 8); + expect(curve).toHaveLength(9); + expect(curve[0].x).toBe(8); + expect(curve[8].x).toBe(14); + expect(Math.max(...curve.map((p) => p.d))).toBe(1); + expect(densityCurve(plain([10]), 1, [9, 10.5], 2)[0].x).toBe(9); + expect(densityCurve([], 1, [0, 1])).toEqual([]); + expect(densityCurve(plain([10]), 1, [20, 30])).toEqual([]); + }); + + it('jitters steadily within half a unit', () => { + expect(jitter(3)).toBe(jitter(3)); + for (let i = 0; i < 50; i++) expect(Math.abs(jitter(i))).toBeLessThanOrEqual(0.5); + }); +}); + +describe('category plot', () => { + const host = document.createElement('div'); + const ctx = (raw: Record) => ({ host, requestUpdate: () => undefined, raw: raw as CardConfig }); + + it('reads and checks its options', () => { + expect(categoryOptions({} as CardConfig)).toEqual({ + orientation: 'horizontal', + axis: true, + grid: true, + min: undefined, + max: undefined, + }); + const check = (raw: Record) => () => validateCategoryOptions(raw as CardConfig); + expect(check({ orientation: 'sideways' })).toThrow(/"orientation" must be one of: horizontal, vertical/); + expect(check({ value_axis: 1 })).toThrow(/value_axis/); + expect(check({ value_min: 'a' })).toThrow(/value_min/); + expect(check({ value_min: 5, value_max: 1 })).toThrow(/below/); + expect(check({ compact: 'yes' })).toThrow(/compact/); + expect(check({ tooltip: 1 })).toThrow(/tooltip/); + expect(check({ orientation: 'vertical', value_grid: false, value_min: 0, value_max: 1 })).not.toThrow(); + }); + + it('lays bands across or up the plot', () => { + const across = categoryGeometry(ctx({}), 2, { domain: [0, 10] }); + expect(across.at(5, 1)).toEqual([500, 150]); + expect(across.at(0, 0, -0.5)).toEqual([0, 0]); + expect(across.bandPx).toBe(64); + expect(categoryGeometry(ctx({ compact: true }), 2, { domain: [0, 1] }).bandPx).toBe(44); + expect(categoryGeometry(ctx({}), 10, { domain: [0, 1] }).bandPx).toBe(40); + expect(categoryGeometry(ctx({}), 20, { domain: [0, 1] }).bandPx).toBe(28); + expect(categoryGeometry(ctx({}), 2, { domain: [0, 1] }, 96).bandPx).toBe(96); + expect(categoryGeometry(ctx({ compact: true }), 2, { domain: [0, 1] }, 96).bandPx).toBe(66); + const up = categoryGeometry(ctx({ orientation: 'vertical' }), 4, { domain: [0, 10] }); + expect(up.at(10, 3)).toEqual([350, 0]); + expect(up.width).toBe(400); + expect(up.valuePx).toBe(180); + expect(categoryGeometry(ctx({ orientation: 'vertical', compact: true }), 1, { domain: [0, 1] }).valuePx).toBe(110); + }); + + it('sizes the name column and the number of labels to the card', () => { + expect(labelColumn(200)).toBe(64); + expect(labelColumn(1000)).toBe(140); + expect(labelCount(330, 'horizontal')).toBe(5); + expect(labelCount(50, 'horizontal')).toBe(2); + expect(labelCount(180, 'vertical')).toBe(4); + expect(labelCount(110, 'vertical')).toBe(3); + }); + + it('labels values as numbers, or hours as clock times', () => { + const n = numberScale([1, 9], { value_max: 20 } as CardConfig, 4, undefined); + expect(n.domain[1]).toBe(20); + expect(n.format(5)).toBe('5'); + const clock = clockScale(4, { locale: { language: 'en-GB', time_format: '24' } } as never); + expect(clock.ticks).toEqual([0, 6, 12, 18, 24]); + expect(clock.format(6)).toMatch(/^0?6:00$/); + expect(clock.format(13.5)).toBe('13:30'); + expect(clockScale(8, undefined).ticks).toHaveLength(9); + expect(clockScale(30, { language: 'not a language!' } as never).ticks).toHaveLength(25); + }); +}); diff --git a/test/drill.test.ts b/test/drill.test.ts new file mode 100644 index 0000000..bfe04ca --- /dev/null +++ b/test/drill.test.ts @@ -0,0 +1,190 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import '../src/chartdeck-card'; +import { drillEnabled, drilledRoot, validateDrill } from '../src/chart/drill'; +import { buildTree } from '../src/chart/hierarchy'; +import { normalizeConfig } from '../src/config'; +import { resolveEntities } from '../src/resolve'; +import type { CardConfig, HomeAssistant } from '../src/types'; +import { makeHass } from './helpers/hass'; + +type Card = HTMLElement & { setConfig(c: CardConfig): void; hass?: HomeAssistant; updateComplete: Promise }; + +function hass(): HomeAssistant { + return makeHass({ + 'sensor.oven': ['1', { friendly_name: 'Oven', unit_of_measurement: 'kW' }], + 'sensor.hob': ['0.8', { friendly_name: 'Hob', unit_of_measurement: 'kW' }], + 'sensor.tv': ['0.5', { friendly_name: 'TV', unit_of_measurement: 'kW' }], + 'sensor.desk': ['0.4', { friendly_name: 'Desk', unit_of_measurement: 'kW' }], + 'sensor.ev': ['3', { friendly_name: 'EV', unit_of_measurement: 'kW' }], + }); +} + +const ENTITIES = [ + { entity: 'sensor.oven', group: 'Ground / Kitchen' }, + { entity: 'sensor.hob', group: 'Ground / Kitchen' }, + { entity: 'sensor.tv', group: 'Ground / Lounge' }, + { entity: 'sensor.desk', group: 'First / Office' }, + { entity: 'sensor.ev', group: 'Outside' }, +]; + +async function mount(config: Partial) { + const card = document.createElement('chartdeck-card') as Card; + card.setConfig({ type: 'custom:chartdeck-card', entities: ENTITIES, ...config } as CardConfig); + card.hass = hass(); + document.body.appendChild(card); + 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 text = (el: Element | null) => el!.textContent!.replace(/\s+/g, ' ').trim(); + +async function click(card: Card, sel: string) { + q(card, sel)!.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true })); + await card.updateComplete; +} + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('drill path', () => { + const tree = () => { + const items = resolveEntities(normalizeConfig({ type: 'x', entities: ENTITIES } as CardConfig), hass()); + return buildTree( + items, + items.map((e) => String((e.config as { group?: string }).group).split(' / ')), + 'All', + ); + }; + + it('follows a path down, and stops at the deepest group that still exists', () => { + const t = tree(); + expect(drilledRoot(t, []).node).toBe(t); + const kitchen = drilledRoot(t, ['Ground', 'Kitchen']); + expect(kitchen.node.name).toBe('Kitchen'); + expect(kitchen.path).toEqual(['Ground', 'Kitchen']); + expect(drilledRoot(t, ['Ground', 'Attic'])).toMatchObject({ path: ['Ground'] }); + expect(drilledRoot(t, ['Nowhere', 'Kitchen'])).toMatchObject({ path: [] }); + }); + + it('is on unless turned off, and checks the option', () => { + expect(drillEnabled({ type: 'x' })).toBe(true); + expect(drillEnabled({ type: 'x', drill_down: false })).toBe(false); + expect(() => validateDrill({ type: 'x', drill_down: 'yes' } as unknown as CardConfig)).toThrow(/drill_down/); + expect(() => validateDrill({ type: 'x', drill_down: true })).not.toThrow(); + }); +}); + +describe('treemap drill-down', () => { + it('shows a tapped group as the whole, keeps its colour, and goes back', async () => { + const card = await mount({ style: 'treemap' }); + expect(q(card, '.drill-bar')).toBeNull(); + const ground = q(card, '.group[data-path="Ground"]')!; + expect(ground.getAttribute('role')).toBe('button'); + expect(ground.getAttribute('aria-label')).toBe('Ground, 2.3 kW. Show what makes it up'); + // Ground is the second top-level group (EV is bigger), so its colour is the second one. + expect(q(card, '.tile[data-index="0"]')!.style.background).toContain('--warning-color'); + await click(card, '.group[data-path="Ground"]'); + expect(qa(card, '.tile').map((t) => t.dataset.index)).toEqual(['0', '1', '2']); + expect(q(card, '.tile[data-index="0"]')!.style.background).toContain('--warning-color'); + expect(qa(card, '.group-name').map((g) => g.textContent)).toEqual(['Kitchen', 'Lounge']); + expect(text(q(card, '.drill-current'))).toBe('Ground 2.3 kW'); + expect(q(card, '.treemap-total')).toBeNull(); + const back = q(card, '.drill-back')!; + expect(back.getAttribute('aria-label')).toBe('Back to All'); + await click(card, '.group[data-path="Ground/Kitchen"]'); + expect(qa(card, '.tile').map((t) => t.dataset.index)).toEqual(['0', '1']); + expect(q(card, '.drill-back')!.getAttribute('aria-label')).toBe('Back to Ground'); + await click(card, '.drill-back'); + expect(text(q(card, '.drill-current'))).toBe('Ground 2.3 kW'); + await click(card, '.drill-back'); + expect(q(card, '.drill-bar')).toBeNull(); + expect(qa(card, '.tile')).toHaveLength(5); + }); + + it('drills with the keyboard, and keeps tile taps for the entity', async () => { + const card = await mount({ style: 'treemap' }); + const fired: string[] = []; + card.addEventListener('hass-action', (e) => fired.push((e as CustomEvent).detail.config.entity)); + q(card, '.group[data-path="First"]')!.dispatchEvent(new KeyboardEvent('keydown', { key: 'a', bubbles: true })); + await card.updateComplete; + expect(q(card, '.drill-bar')).toBeNull(); + q(card, '.group[data-path="First"]')!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + await card.updateComplete; + expect(text(q(card, '.drill-current'))).toBe('First 0.4 kW'); + const tile = q(card, '.tile[data-index="3"]')!; + tile.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true })); + tile.dispatchEvent(new MouseEvent('pointerup', { bubbles: true })); + expect(fired).toEqual(['sensor.desk']); + expect(text(q(card, '.drill-current'))).toBe('First 0.4 kW'); + }); + + it('falls back to the top when the group it was in goes away', async () => { + const card = await mount({ style: 'treemap' }); + await click(card, '.group[data-path="First"]'); + card.setConfig({ type: 'custom:chartdeck-card', style: 'treemap', entities: ENTITIES.slice(0, 3) } as CardConfig); + await card.updateComplete; + expect(q(card, '.drill-bar')).toBeNull(); + expect(qa(card, '.tile')).toHaveLength(3); + }); + + it('leaves groups plain when drilling is off', async () => { + const card = await mount({ style: 'treemap', drill_down: false } as Partial); + const ground = q(card, '.group[data-path="Ground"]')!; + expect(ground.getAttribute('role')).toBeNull(); + expect(ground.classList.contains('drillable')).toBe(false); + await click(card, '.group[data-path="Ground"]'); + expect(q(card, '.drill-bar')).toBeNull(); + }); +}); + +describe('sunburst and icicle drill-down', () => { + it('drills into a ring segment or a legend item, keeping colours, and goes back', async () => { + const card = await mount({ style: 'sunburst' }); + const before = q(card, 'path.leaf[data-index="0"]')!.getAttribute('fill'); + expect(q(card, 'path.group[data-path="Ground"]')!.getAttribute('aria-label')).toBe( + 'Ground, 2.3 kW. Show what makes it up', + ); + await click(card, 'path.group[data-path="Ground"]'); + expect(qa(card, 'path.leaf').map((p) => p.dataset.index)).toEqual(['0', '1', '2']); + expect(q(card, 'path.leaf[data-index="0"]')!.getAttribute('fill')).toBe(before); + expect(text(q(card, '.centre-value'))).toBe('2.3 kW'); + expect(text(q(card, '.drill-current'))).toBe('Ground'); + expect(qa(card, '.hierarchy-legend .legend-item').map(text)).toEqual(['Kitchen 1.8 kW', 'Lounge 0.5 kW']); + await click(card, '.hierarchy-legend .legend-item[data-path="Ground/Kitchen"]'); + expect(qa(card, 'path.leaf').map((p) => p.dataset.index)).toEqual(['0', '1']); + await click(card, '.drill-back'); + await click(card, '.drill-back'); + expect(q(card, '.drill-bar')).toBeNull(); + expect(qa(card, 'path.leaf')).toHaveLength(5); + }); + + it('drills into an icicle cell, but not the total on the left', async () => { + const card = await mount({ style: 'icicle' }); + expect(q(card, '.cell.text-root')!.getAttribute('role')).toBeNull(); + await click(card, '.cell.text-root'); + expect(q(card, '.drill-bar')).toBeNull(); + await click(card, '.cell.group[data-path="Ground"]'); + expect(text(q(card, '.cell.text-root'))).toBe('Ground 2.3 kW'); + expect(qa(card, '.cell.leaf').map((c) => c.dataset.index)).toEqual(['0', '1', '2']); + }); + + it('leaves arcs and cells plain when drilling is off', async () => { + const card = await mount({ style: 'sunburst', drill_down: false } as Partial); + expect(q(card, 'path.group')!.getAttribute('role')).toBeNull(); + expect(q(card, '.hierarchy-legend .legend-item')!.getAttribute('role')).toBeNull(); + const icicle = await mount({ style: 'icicle', drill_down: false } as Partial); + expect(q(icicle, '.cell.group[data-path="Ground"]')!.getAttribute('role')).toBeNull(); + }); + + it('checks the option', () => { + const card = document.createElement('chartdeck-card') as Card; + for (const style of ['treemap', 'sunburst', 'icicle']) { + expect(() => card.setConfig({ type: 'x', style, drill_down: 'no' } as unknown as CardConfig)).toThrow( + /drill_down/, + ); + } + }); +}); diff --git a/test/dumbbell.test.ts b/test/dumbbell.test.ts new file mode 100644 index 0000000..977015d --- /dev/null +++ b/test/dumbbell.test.ts @@ -0,0 +1,282 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import '../src/chartdeck-card'; +import { agoText, labelledMarks, rowMarks, stepDecimals } from '../src/styles/dumbbell'; +import type { ResolvedEntity } from '../src/resolve'; +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 H = 3_600_000; +const sec = (msAgo: number) => (now - msAgo) / 1000; + +const entity = (value: number | null, target: number | null = null) => + ({ available: value !== null, value, target }) as unknown as ResolvedEntity; + +describe('dumbbell maths', () => { + const points = [ + { t: 10, v: 18 }, + { t: 20, v: null }, + { t: 30, v: 23 }, + ]; + + it('takes low and high over the period, stretched to the current value, with now between', () => { + expect(rowMarks(entity(20), 'range', points, 0, true)).toEqual([ + { kind: 'low', value: 18 }, + { kind: 'now', value: 20 }, + { kind: 'high', value: 23 }, + ]); + expect(rowMarks(entity(25), 'range', points, 0, false)).toEqual([ + { kind: 'low', value: 18 }, + { kind: 'high', value: 25 }, + ]); + // No history: the current value alone, even when it isn't asked for. + expect(rowMarks(entity(20), 'range', [], 0, false)).toEqual([{ kind: 'now', value: 20 }]); + expect(rowMarks(entity(null), 'range', [], 0, true)).toEqual([]); + }); + + it('compares the start of the period with now', () => { + expect(rowMarks(entity(21), 'then', points, 15, true)).toEqual([ + { kind: 'then', value: 18 }, + { kind: 'now', value: 21 }, + ]); + // Statistics start after the period does: the first reading stands in. + expect(rowMarks(entity(16), 'then', points, 5, true)).toEqual([ + { kind: 'now', value: 16 }, + { kind: 'then', value: 18 }, + ]); + expect(rowMarks(entity(null), 'then', [], 0, true)).toEqual([]); + }); + + it('compares the target with now', () => { + expect(rowMarks(entity(21, 19), 'target', [], 0, true)).toEqual([ + { kind: 'target', value: 19 }, + { kind: 'now', value: 21 }, + ]); + expect(rowMarks(entity(21), 'target', [], 0, true)).toEqual([{ kind: 'now', value: 21 }]); + }); + + it('labels the two ends, skipping the current value in range mode', () => { + const low = { kind: 'low' as const, value: 1 }; + const now = { kind: 'now' as const, value: 2 }; + const high = { kind: 'high' as const, value: 3 }; + expect(labelledMarks([low, now, high])).toEqual([low, high]); + expect(labelledMarks([now])).toEqual([now]); + expect(labelledMarks([])).toEqual([]); + const then = { kind: 'then' as const, value: 0 }; + expect(labelledMarks([then, now])).toEqual([then, now]); + }); + + it('formats ticks with the decimals their step needs, and names the period', () => { + expect(stepDecimals([0, 5, 10])).toBe(0); + expect(stepDecimals([15, 17.5])).toBe(1); + expect(stepDecimals([0, 0.25])).toBe(2); + expect(stepDecimals([3])).toBe(0); + expect(agoText(undefined, { last: 1, unit: 'week' })).toBe('A week ago'); + expect(agoText(undefined, { last: 3, unit: 'day' })).toBe('3 days ago'); + }); +}); + +function hassWith(history: Record, fail = false) { + const hass = makeHass({ + 'sensor.lounge': ['21', { friendly_name: 'Lounge', unit_of_measurement: '°C' }], + 'sensor.kitchen': ['19.5', { friendly_name: 'Kitchen', unit_of_measurement: '°C' }], + 'sensor.off': ['unavailable', { friendly_name: 'Off', unit_of_measurement: '°C' }], + 'sensor.set': ['20', {}], + }); + hass.callWS = vi.fn(async () => { + if (fail) throw new Error('boom'); + return history; + }) as HomeAssistant['callWS']; + return hass; +} + +const history = { + 'sensor.lounge': [ + { s: '18', lu: sec(20 * H) }, + { s: '23', lu: sec(10 * H) }, + { s: '21', lu: sec(2 * H) }, + ], + 'sensor.kitchen': [ + { s: '17', lu: sec(30 * H) }, + { s: '19.5', lu: sec(5 * H) }, + ], +}; + +async function mount(config: Partial, hass = hassWith(history)) { + const card = document.createElement('chartdeck-card') as Card; + card.setConfig({ + type: 'custom:chartdeck-card', + style: 'dumbbell', + entities: ['sensor.lounge', 'sensor.kitchen', 'sensor.off', 'sensor.gone'], + ...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 dots = (row: Element) => [...row.querySelectorAll('.db-dot')].map((d) => d.classList[1]); + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('dumbbell chart', () => { + it("draws each entity's low, now and high on a shared axis, with rows for the unavailable and missing", async () => { + const card = await mount({}); + const rows = qa(card, '.entity.db-row'); + expect(rows.map((r) => r.getAttribute('data-index'))).toEqual(['0', '1', '2', '3']); + expect(dots(rows[0])).toEqual(['low', 'now', 'high']); + expect(rows[0].getAttribute('aria-label')).toBe('Lounge: Low 18.0 °C, Now 21.0 °C, High 23.0 °C'); + expect([...rows[0].querySelectorAll('.db-label')].map((l) => l.textContent)).toEqual(['18.0', '23.0']); + // Kitchen's history starts before the period: the carried reading counts. + expect(dots(rows[1])).toEqual(['low', 'high', 'now']); + expect(rows[2].querySelector('.db-status')!.textContent).toBe('Unavailable'); + expect(rows[2].classList.contains('unavailable')).toBe(true); + expect(rows[3].querySelector('.db-status')!.textContent).toBe('Entity not found'); + expect(qa(card, '.db-tick').map((t) => t.textContent)).toEqual(['16', '18', '20', '22', '24']); + expect(q(card, '.db-unit')!.textContent).toBe('°C'); + expect(qa(card, '.db-key').map((k) => k.textContent)).toEqual(['Low', 'Now', 'High']); + expect(q(card, '.db')!.getAttribute('aria-label')).toBe('Dumbbell chart of Lounge, Kitchen, Off, sensor.gone'); + }); + + it('leaves the current value out when asked, and draws columns', async () => { + const card = await mount({ show_now: false, orientation: 'vertical', show: { name: false, value: false } }); + expect(q(card, '.db.vertical.no-names.no-values')).not.toBeNull(); + expect(dots(qa(card, '.db-row')[0])).toEqual(['low', 'high']); + expect(qa(card, '.db-label')).toHaveLength(0); + expect(qa(card, '.db-name')).toHaveLength(0); + expect(qa(card, '.db-key').map((k) => k.textContent)).toEqual(['Low', 'High']); + }); + + it('compares the start of the period with now', async () => { + const card = await mount({ ends: 'then', period: { last: 1, unit: 'day' } }); + const rows = qa(card, '.db-row'); + expect(dots(rows[0])).toEqual(['then', 'now']); + expect(rows[0].getAttribute('aria-label')).toBe('Lounge: A day ago 18.0 °C, Now 21.0 °C'); + expect(qa(card, '.db-key').map((k) => k.textContent)).toEqual(['A day ago', 'Now']); + }); + + it('compares the target with now without loading history', async () => { + const hass = hassWith(history); + const card = await mount( + { + ends: 'target', + entities: [{ entity: 'sensor.lounge', target: 'sensor.set' }, { entity: 'sensor.kitchen' }], + } as Partial, + hass, + ); + expect(hass.callWS).not.toHaveBeenCalled(); + const rows = qa(card, '.db-row'); + expect(dots(rows[0])).toEqual(['target', 'now']); + expect(rows[0].querySelector('.db-dot.target')!.classList.contains('ring')).toBe(true); + expect(dots(rows[1])).toEqual(['now']); + expect(rows[1].querySelector('.db-bar')).toBeNull(); + expect(qa(card, '.db-key').map((k) => k.textContent)).toEqual(['Target', 'Now']); + }); + + it('says when history is loading or failed', async () => { + const card = document.createElement('chartdeck-card') as Card; + card.setConfig({ type: 'custom:chartdeck-card', style: 'dumbbell', entities: ['sensor.lounge'] } as CardConfig); + card.hass = hassWith(history); + document.body.appendChild(card); + await card.updateComplete; + expect(q(card, '.chart-message')!.textContent).toBe('Loading history…'); + const failed = await mount({}, hassWith(history, true)); + expect(q(failed, '.chart-message.error')!.textContent!.trim()).toBe("Couldn't load history: boom"); + }); + + it('draws rows without marks when nothing has a value', async () => { + const card = await mount({ entities: ['sensor.off'] }, hassWith({})); + expect(q(card, '.db-status')!.textContent).toBe('Unavailable'); + expect(q(card, '.db-legend')).toBeNull(); + expect(q(card, '.db-axis')).toBeNull(); + }); + + it('checks its options', () => { + const card = document.createElement('chartdeck-card') as Card; + const set = (extra: Record) => () => + card.setConfig({ type: 'x', style: 'dumbbell', entities: ['sensor.lounge'], ...extra } as CardConfig); + expect(set({ ends: 'both' })).toThrow(/"ends" must be one of: range, then, target/); + expect(set({ orientation: 'diagonal' })).toThrow(/orientation/); + expect(set({ show_now: 'yes' })).toThrow(/show_now/); + expect(set({ source: 'cloud' })).toThrow(/source/); + expect(set({ ends: 'target', orientation: 'vertical', show_now: false })).not.toThrow(); + }); + + it('opens more-info from a row', async () => { + const card = await mount({}); + const fired: string[] = []; + card.addEventListener('hass-action', (e) => fired.push((e as CustomEvent).detail.config.entity)); + const row = qa(card, '.db-row')[1] as HTMLElement; + row.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true })); + row.dispatchEvent(new MouseEvent('pointerup', { bubbles: true })); + expect(fired).toEqual(['sensor.kitchen']); + }); +}); + +describe('dumbbell editor', () => { + async function editor(raw: CardConfig) { + 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({}); + ed.setConfig(raw); + document.body.appendChild(ed); + await ed.updateComplete; + const form = ed.shadowRoot!.querySelector('.section-chart ha-form') as Stub; + return { form, events }; + } + const names = (schema: unknown) => + (schema as { name: string; schema?: { name: string }[] }[]).flatMap((s) => + s.schema ? s.schema.map((x) => x.name) : [s.name], + ); + + it('sets what to compare, the direction, the period and the current-value mark', async () => { + const raw = { type: 'custom:chartdeck-card', style: 'dumbbell', entities: ['sensor.a'] } as CardConfig; + const { form, events } = await editor(raw); + expect(form.data).toMatchObject({ ends: 'range', orientation: 'horizontal', show_now: true, period_last: 24 }); + expect(names(form.schema)).toEqual(['ends', 'orientation', 'period_last', 'period_unit', 'source', 'show_now']); + form.dispatchEvent( + new CustomEvent('value-changed', { + detail: { value: { ...(form.data as object), orientation: 'vertical', show_now: false, period_unit: 'week' } }, + }), + ); + expect(events.at(-1)).toEqual({ ...raw, orientation: 'vertical', show_now: false, period: { unit: 'week' } }); + }); + + it('hides the period for a target and the mark for a start-of-period comparison', async () => { + const target = await editor({ type: 'x', style: 'dumbbell', ends: 'target', entities: [] } as CardConfig); + expect(names(target.form.schema)).toEqual(['ends', 'orientation']); + document.body.innerHTML = ''; + const then = await editor({ type: 'x', style: 'dumbbell', ends: 'then', entities: [] } as CardConfig); + expect(names(then.form.schema)).toEqual(['ends', 'orientation', 'period_last', 'period_unit', 'source']); + }); +}); diff --git a/test/editor.test.ts b/test/editor.test.ts index 3a0ad1b..c834bc2 100644 --- a/test/editor.test.ts +++ b/test/editor.test.ts @@ -132,6 +132,23 @@ describe('chartdeck-card-editor', () => { expect(events.at(-1)).toEqual({ ...raw, columns: 2, title: 'Power' }); }); + it('drops a group_by the new style cannot read when the style changes', async () => { + const pick = async (config: Record, style: string) => { + const { editor, events } = await mount({ + type: 'custom:chartdeck-card', + entities: ['sensor.a'], + ...config, + } as never); + (editor.shadowRoot!.querySelector(`button[data-style="${style}"]`) as HTMLButtonElement).click(); + document.body.innerHTML = ''; + return events.at(-1)!; + }; + expect(await pick({ style: 'column', group_by: 'day' }, 'treemap')).not.toHaveProperty('group_by'); + expect(await pick({ style: 'treemap', group_by: ['floor', 'area'] }, 'line')).not.toHaveProperty('group_by'); + expect((await pick({ style: 'column', group_by: 'day' }, 'line')).group_by).toBe('day'); + expect((await pick({ style: 'treemap', group_by: 'area' }, 'sunburst')).group_by).toBe('area'); + }); + it('selecting a style emits style; selecting bar removes it', async () => { const { editor, events } = await mount(raw); const button = (name: string) => diff --git a/test/extremes.test.ts b/test/extremes.test.ts new file mode 100644 index 0000000..8991614 --- /dev/null +++ b/test/extremes.test.ts @@ -0,0 +1,405 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import '../src/chartdeck-card'; +import { + LABEL_H, + averageOf, + extremePoints, + extremeTime, + extremesOn, + extremesOptions, + labelWidth, + placeLabels, + seriesHasExtremes, + validateExtremes, +} from '../src/chart/extremes'; +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', +]) { + if (!customElements.get(tag)) customElements.define(tag, class extends Stub {}); +} +await import('../src/editor/chartdeck-card-editor'); + +const H = 3_600_000; +const DAY = 24 * H; + +describe('extreme points', () => { + it('finds the lowest and highest readings, skipping gaps and the reading carried in', () => { + const points: Point[] = [ + { t: 0, v: 1, carried: true }, + { t: 1, v: 5 }, + { t: 2, v: null }, + { t: 3, v: 2 }, + { t: 4, v: 9 }, + { t: 5, v: 9 }, + ]; + const { min, max } = extremePoints(points); + expect(min).toEqual({ t: 3, v: 2 }); + // A tie goes to the earliest: the value carried on to the edge never wins. + expect(max).toEqual({ t: 4, v: 9 }); + }); + + it('has nothing to mark without readings', () => { + expect(extremePoints([])).toEqual({ min: undefined, max: undefined }); + expect(extremePoints([{ t: 0, v: null }])).toEqual({ min: undefined, max: undefined }); + }); +}); + +describe('average', () => { + const points: Point[] = [ + { t: 0, v: 20 }, + { t: 10 * H, v: 30 }, + { t: 11 * H, v: null }, + { t: 12 * H, v: 20 }, + { t: 14 * H, v: 20 }, + ]; + + it('weights each history reading by how long it held, leaving out unavailable stretches', () => { + // 20 for 10 h, 30 for 1 h, unavailable for 1 h, 20 for 2 h. + expect(averageOf(points, true)).toBeCloseTo((20 * 10 + 30 + 20 * 2) / 13); + }); + + it('stops at now, and counts the reading carried in from before the start', () => { + expect(averageOf(points, true, 5 * H)).toBe(20); + expect( + averageOf( + [ + { t: 0, v: 10, carried: true }, + { t: H, v: 20 }, + { t: 3 * H, v: 20 }, + ], + true, + ), + ).toBeCloseTo((10 + 40) / 3); + }); + + it('takes a plain mean of groups and statistics, without the row carried in', () => { + expect(averageOf(points, false)).toBe(22.5); + expect( + averageOf( + [ + { t: 0, v: 100, carried: true }, + { t: 1, v: 4 }, + { t: 2, v: 6 }, + ], + false, + ), + ).toBe(5); + }); + + it('falls back to the mean for a single reading, and to nothing without one', () => { + expect(averageOf([{ t: 5, v: 7 }], true)).toBe(7); + expect(averageOf([{ t: 5, v: null }], true)).toBeUndefined(); + }); +}); + +describe('placing labels', () => { + const W = 400; + const Hpx = 160; + + it('puts the high above and the low below, centred on the dot', () => { + const [high, low] = placeLabels( + [ + { x: 0.5, y: 0.5, text: 'High 1', prefer: 'above', dot: true }, + { x: 0.3, y: 0.7, text: 'Low 1', prefer: 'below', dot: true }, + ], + W, + Hpx, + ); + expect(high).toEqual({ side: 'above', align: 'center', tier: 0 }); + expect(low).toEqual({ side: 'below', align: 'center', tier: 0 }); + }); + + it('flips a label that would leave the top or bottom, and holds it inside the sides', () => { + const [top, bottom] = placeLabels( + [ + { x: 0.01, y: 0.02, text: 'High 100 °C · 12:00', prefer: 'above' }, + { x: 0.99, y: 0.98, text: 'Low 1 °C · 03:00', prefer: 'below' }, + ], + W, + Hpx, + ); + expect(top).toEqual({ side: 'below', align: 'start', tier: 0 }); + expect(bottom).toEqual({ side: 'above', align: 'end', tier: 0 }); + }); + + it('moves a label off one already placed, a dot or an obstacle', () => { + const near = placeLabels( + [ + { x: 0.5, y: 0.5, text: 'High 20', prefer: 'above' }, + { x: 0.5, y: 0.52, text: 'High 21', prefer: 'above' }, + ], + W, + Hpx, + ); + expect(near[1].side).toBe('below'); + const dotted = placeLabels( + [ + { x: 0.5, y: 0.5, text: 'High 20', prefer: 'above', dot: true }, + { x: 0.5, y: 0.4, text: 'x', prefer: 'below', dot: true }, + ], + W, + Hpx, + ); + // Above would cover the other dot at 0.4: below instead. + expect(dotted[0].side).toBe('below'); + const blocked = placeLabels([{ x: 0.1, y: 0.5, text: 'High 20', prefer: 'above' }], W, Hpx, [ + { l: 0, r: 200, t: 0, b: 75 }, + ]); + expect(blocked[0].side).toBe('below'); + }); + + it('steps a label further out when both near spots are taken, and keeps a side that fits when nothing is clear', () => { + const crowded = placeLabels( + [ + { x: 0.5, y: 0.5, text: 'A', prefer: 'above' }, + { x: 0.5, y: 0.5, text: 'B', prefer: 'below' }, + { x: 0.5, y: 0.5, text: 'C', prefer: 'above' }, + ], + W, + Hpx, + ); + expect(crowded[2]).toEqual({ side: 'above', align: 'center', tier: 1 }); + const tiny = placeLabels( + [ + { x: 0.5, y: 0.5, text: 'A', prefer: 'above' }, + { x: 0.5, y: 0.5, text: 'B', prefer: 'above' }, + ], + W, + LABEL_H * 2 + 20, + ); + expect(tiny[1].tier).toBe(0); + expect(labelWidth('abc')).toBeGreaterThan(18); + }); +}); + +describe('extreme times', () => { + const hass = makeHass({}, { locale: { language: 'en', number_format: 'language', time_format: '24' } }); + const t = Date.UTC(2026, 9, 27, 14, 10); + + it('shows a clock time within a day and the date beyond', () => { + expect(extremeTime(t, { start: t - H, end: t + H }, hass)).toMatch(/\d{2}:10/); + const long = extremeTime(t, { start: t - 3 * DAY, end: t }, hass); + expect(long).toMatch(/Oct/); + expect(long).toMatch(/:10/); + }); + + it('names a grouped point by its day or month', () => { + const range = { start: t - 7 * DAY, end: t }; + const day = extremeTime(t, range, hass, { unit: 'day', aggregate: 'mean' }); + expect(day).toMatch(/Oct/); + expect(day).not.toMatch(/:/); + expect(extremeTime(t, range, hass, { unit: 'month', aggregate: 'mean' })).toMatch(/Oct 2026/); + expect(extremeTime(t, range, hass, { unit: 'hour', aggregate: 'mean' })).toMatch(/:10/); + }); +}); + +describe('extremes options', () => { + const cfg = (extra: Record) => ({ type: 'x', entities: ['sensor.a'], ...extra }) as CardConfig; + + it('fills in defaults and says when any mark is on', () => { + expect(extremesOptions(cfg({}))).toEqual({ min: false, max: false, average: 'none' }); + expect(extremesOn(extremesOptions(cfg({})))).toBe(false); + expect(extremesOn(extremesOptions(cfg({ extremes: { average: 'figure' } })))).toBe(true); + expect(seriesHasExtremes({ entity: 'a' })).toBe(true); + expect(seriesHasExtremes({ entity: 'a', extremes: false })).toBe(false); + }); + + it('rejects bad values with readable errors', () => { + expect(() => validateExtremes(cfg({ extremes: true }))).toThrow(/"extremes" must have/); + expect(() => validateExtremes(cfg({ extremes: { min: 'yes' } }))).toThrow(/"extremes.min" must be true or false/); + expect(() => validateExtremes(cfg({ extremes: { average: 'median' } }))).toThrow( + /"extremes.average" must be one of: none, line, figure/, + ); + expect(() => validateExtremes(cfg({ entities: [{ entity: 'sensor.a', extremes: 'no' }] }))).toThrow( + /"extremes" of sensor.a/, + ); + expect(() => validateExtremes(cfg({ extremes: { min: true, max: false, average: 'line' } }))).not.toThrow(); + }); +}); + +/* ------------------------------------------------------------------ card ------------------------------------------------------------------ */ + +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: '20', lu: sec(10 * H) }, + { s: 'unavailable', lu: sec(8 * H) }, + { s: '21', lu: sec(2 * H) }, + ], + 'sensor.out': [ + { s: '5', lu: sec(20 * H) }, + { s: '9', lu: sec(4 * H) }, + ], +}; + +function hassWith() { + const hass = makeHass({ + 'sensor.in': ['21', { friendly_name: 'Inside', unit_of_measurement: '°C' }], + 'sensor.out': ['9', { friendly_name: 'Outside', unit_of_measurement: '°C' }], + }); + hass.callWS = vi.fn(async () => history) as HomeAssistant['callWS']; + return hass; +} + +async function mount(config: Partial) { + const card = document.createElement('chartdeck-card') as Card; + card.setConfig({ + type: 'custom:chartdeck-card', + style: 'line', + entities: ['sensor.in', 'sensor.out'], + ...config, + } as CardConfig); + card.hass = hassWith(); + 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 texts = (card: Card, sel: string) => qa(card, sel).map((e) => e.textContent!.replace(/\s+/g, ' ').trim()); + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('line chart with extremes', () => { + it('draws nothing extra by default', async () => { + const card = await mount({}); + expect(qa(card, '.extremes-layer')).toHaveLength(0); + expect(qa(card, '.average-line')).toHaveLength(0); + }); + + it('marks each series high and low with its value and time, and draws a labelled average line', async () => { + const card = await mount({ extremes: { min: true, max: true, average: 'line' } } as Partial); + expect(qa(card, '.extreme-dot')).toHaveLength(4); + const labels = texts(card, '.extreme-label'); + expect(labels.filter((l) => l.startsWith('High'))).toHaveLength(2); + expect(labels.some((l) => /^High 21\.0 °C · .+/.test(l))).toBe(true); + expect(labels.some((l) => /^Low 19\.0 °C · .+/.test(l))).toBe(true); + expect(labels.some((l) => /^Low 5\.0 °C/.test(l))).toBe(true); + // Inside: 19 for 10 h, 20 for 2 h, unavailable for 6 h, 21 for 2 h. + expect(labels).toContain('Avg 19.4 °C'); + const lines = qa(card, '.average-line'); + expect(lines).toHaveLength(2); + expect(lines[0].getAttribute('stroke')).toContain('--primary-color'); + const dot = qa(card, '.extreme-dot')[0] as HTMLElement; + expect(dot.getAttribute('style')).toMatch(/left:[\d.]+%;top:[\d.]+%/); + }); + + it('shows only the marks asked for, and skips a series switched off', async () => { + const card = await mount({ + extremes: { max: true, average: 'figure' }, + entities: ['sensor.in', { entity: 'sensor.out', extremes: false }], + } as Partial); + expect(texts(card, '.extreme-label')).toEqual([expect.stringMatching(/^High 21\.0 °C/)]); + expect(qa(card, '.average-line')).toHaveLength(0); + expect(texts(card, '.extreme-figure')).toEqual(['Avg 19.4 °C']); + // More than one series on the card: each figure gets its colour swatch. + expect(qa(card, '.extreme-figure .swatch')).toHaveLength(1); + }); + + it('averages grouped readings plainly and names their day', async () => { + const card = await mount({ + entities: ['sensor.out'], + group_by: 'hour', + aggregate: 'max', + extremes: { min: true, average: 'line' }, + } as Partial); + const labels = texts(card, '.extreme-label'); + expect(labels.some((l) => /^Low 5\.0 °C · /.test(l))).toBe(true); + expect(labels.some((l) => l.startsWith('Avg '))).toBe(true); + }); + + it('keeps labels clear of a header laid over the plot', async () => { + const card = await mount({ header: 'over', extremes: { max: true } } as Partial); + expect(qa(card, '.extreme-label')).toHaveLength(2); + }); + + it('rejects a bad extremes option', () => { + const card = document.createElement('chartdeck-card') as Card; + expect(() => + card.setConfig({ + type: 'x', + style: 'line', + entities: ['sensor.in'], + extremes: { average: 'mode' }, + } as CardConfig), + ).toThrow(/extremes.average/); + }); +}); + +describe('extremes editor', () => { + async function editor(config: Partial) { + 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: 'line', entities: ['sensor.in'], ...config } as CardConfig; + ed.setConfig(raw); + document.body.appendChild(ed); + await ed.updateComplete; + return { ed, events, raw }; + } + + it('sets the high, low and average marks in the Chart section', async () => { + const { ed, events, raw } = await editor({}); + const form = ed.shadowRoot!.querySelector('.section-chart ha-form') as Stub; + const names = JSON.stringify(form.schema); + for (const n of ['extremes_max', 'extremes_min', 'extremes_average']) expect(names).toContain(n); + form.dispatchEvent( + new CustomEvent('value-changed', { + detail: { value: { ...(form.data as object), extremes_max: true, extremes_average: 'line' } }, + }), + ); + expect(events.at(-1)).toEqual({ ...raw, extremes: { max: true, average: 'line' } }); + }); + + it('removes the marks when they go back to off', async () => { + const { ed, events, raw } = await editor({ extremes: { max: true } } as Partial); + const form = ed.shadowRoot!.querySelector('.section-chart ha-form') as Stub; + expect((form.data as Record).extremes_max).toBe(true); + form.dispatchEvent( + new CustomEvent('value-changed', { detail: { value: { ...(form.data as object), extremes_max: false } } }), + ); + const { extremes, ...rest } = raw as CardConfig & { extremes?: unknown }; + expect(extremes).toEqual({ max: true }); + expect(events.at(-1)).toEqual(rest); + }); + + it('offers each series its own switch only while the card marks something', async () => { + const fieldNames = (ed: HTMLElement) => + ( + (ed.shadowRoot!.querySelector('cd-entities-editor') as Stub).chartFields as { fields: { name: string }[] } + ).fields.map((f) => f.name); + const off = await editor({}); + expect(fieldNames(off.ed)).not.toContain('extremes'); + const on = await editor({ extremes: { min: true } } as Partial); + expect(fieldNames(on.ed)).toEqual(['series_color', 'curve', 'line_width', 'dash', 'on_top', 'dots', 'extremes']); + const schema = ( + (on.ed.shadowRoot!.querySelector('cd-entities-editor') as Stub).chartFields as { schema: unknown[] } + ).schema; + expect(JSON.stringify(schema)).toContain('"name":"extremes"'); + }); +}); diff --git a/test/funnel.test.ts b/test/funnel.test.ts new file mode 100644 index 0000000..9ca0194 --- /dev/null +++ b/test/funnel.test.ts @@ -0,0 +1,212 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import '../src/chartdeck-card'; +import { stageEdges, stagePercents, stageWidths, trapezoid } from '../src/styles/funnel'; +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 }; + +function hass() { + return makeHass({ + 'sensor.solar': ['24.6', { friendly_name: 'Solar produced', unit_of_measurement: 'kWh' }], + 'sensor.home': ['18.4', { friendly_name: 'Used at home', unit_of_measurement: 'kWh' }], + 'sensor.battery': ['7.2', { friendly_name: 'Through battery', unit_of_measurement: 'kWh' }], + 'sensor.dark': ['unavailable', { friendly_name: 'After dark' }], + }); +} + +async function mount(config: Partial) { + const card = document.createElement('chartdeck-card') as Card; + card.setConfig({ + type: 'custom:chartdeck-card', + style: 'funnel', + entities: ['sensor.solar', 'sensor.home', 'sensor.battery'], + ...config, + } as CardConfig); + card.hass = hass(); + document.body.appendChild(card); + await card.updateComplete; + return card; +} + +const qa = (card: Card, sel: string) => [...card.shadowRoot!.querySelectorAll(sel)]; +const text = (el: Element) => el.textContent!.replace(/\s+/g, ' ').trim(); + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('funnel maths', () => { + it('sizes stages against the largest, with nothing for missing or non-positive values', () => { + expect(stageWidths([20, 10, null, -5, 5])).toEqual([100, 50, 0, 0, 25]); + expect(stageWidths([null, 0])).toEqual([0, 0]); + }); + + it('gives percentages of the first or the previous stage', () => { + expect(stagePercents([200, 150, 30], 'first')).toEqual([100, 75, 15]); + expect(stagePercents([200, 150, 30], 'previous')).toEqual([100, 75, 20]); + expect(stagePercents([0, 5, null], 'first')).toEqual([null, null, null]); + expect(stagePercents([10, null, 5], 'previous')).toEqual([100, null, null]); + expect(stagePercents([10, 5], 'none')).toEqual([null, null]); + }); + + it('joins each funnel stage to the next, tapering the last and around gaps', () => { + expect(stageEdges([100, 50, 0, 20], 'funnel')).toEqual([ + { top: 100, bottom: 50 }, + { top: 50, bottom: 40 }, + { top: 0, bottom: 0 }, + { top: 20, bottom: 16 }, + ]); + }); + + it('builds a pyramid up from the first stage to a point', () => { + expect(stageEdges([100, 50, 25], 'pyramid')).toEqual([ + { top: 50, bottom: 100 }, + { top: 25, bottom: 50 }, + { top: 0, bottom: 25 }, + ]); + expect(stageEdges([100, 0, 25], 'pyramid')[0]).toEqual({ top: 80, bottom: 100 }); + }); + + it('centres a trapezoid in a 100 × 10 box', () => { + expect(trapezoid(100, 50)).toBe('0,0 100,0 75,10 25,10'); + expect(trapezoid(0, 25)).toBe('50,0 50,0 62.5,10 37.5,10'); + }); +}); + +describe('funnel chart', () => { + it('draws one stage per entity, top down, with percentages of the first and labels', async () => { + const card = await mount({}); + const stages = qa(card, '.stage.entity'); + expect(stages.map((s) => s.getAttribute('data-index'))).toEqual(['0', '1', '2']); + expect(qa(card, '.percent').map(text)).toEqual(['100%', '75%', '29%']); + expect(qa(card, '.label').map(text)).toEqual([ + 'Solar produced 24.6 kWh', + 'Used at home 18.4 kWh', + 'Through battery 7.2 kWh', + ]); + const first = qa(card, 'polygon')[0]; + expect(first.getAttribute('points')).toBe(trapezoid(100, (18.4 / 24.6) * 100)); + expect(first.getAttribute('fill')).toContain('--primary-color'); + expect(qa(card, 'polygon')[1].getAttribute('fill')).toContain('--warning-color'); + // Amber takes dark text. + expect(qa(card, '.percent')[1].classList.contains('dark')).toBe(true); + expect(stages[0].getAttribute('aria-label')).toBe('Solar produced, 24.6 kWh, 100%'); + expect(card.shadowRoot!.querySelector('.funnel')!.getAttribute('aria-label')).toBe( + 'Funnel of Solar produced, Used at home, Through battery', + ); + }); + + it('stacks a pyramid from the bottom, and shows shares of the stage before', async () => { + const card = await mount({ shape: 'pyramid', percent_of: 'previous' } as Partial); + const stages = qa(card, '.stage.entity'); + expect(stages.map((s) => s.getAttribute('data-index'))).toEqual(['2', '1', '0']); + expect(qa(card, '.percent').map(text)).toEqual(['39%', '75%', '100%']); + expect(qa(card, 'polygon')[0].getAttribute('points')!.startsWith('50,0 50,0')).toBe(true); + expect(card.shadowRoot!.querySelector('.funnel')!.getAttribute('aria-label')).toMatch(/^Pyramid of/); + }); + + it('hides percentages and labels when asked', async () => { + const card = await mount({ percent_of: 'none', show: { name: false, value: false } } as Partial); + expect(qa(card, '.percent')).toHaveLength(0); + expect(qa(card, '.label')).toHaveLength(0); + expect(card.shadowRoot!.querySelector('.funnel.no-labels')).not.toBeNull(); + }); + + it('colours by band and by an entity’s own colour', async () => { + const card = await mount({ + color_by: 'bands', + severity: [ + { from: 0, color: 'red' }, + { from: 10, color: 'green' }, + ], + } as Partial); + const fills = qa(card, 'polygon').map((p) => p.getAttribute('fill')); + expect(fills[0]).toContain('--success-color'); + expect(fills[2]).toContain('--error-color'); + document.body.innerHTML = ''; + const own = await mount({ + entities: [{ entity: 'sensor.solar', color: '#ffeb3b' }, 'sensor.home'], + } as Partial); + expect(qa(own, 'polygon')[0].getAttribute('fill')).toBe('#ffeb3b'); + expect(qa(own, '.percent')[0].classList.contains('dark')).toBe(true); + }); + + it('draws an unavailable or missing stage as an empty outline with its state', async () => { + const card = await mount({ entities: ['sensor.solar', 'sensor.dark', 'sensor.missing'] }); + expect(qa(card, 'polygon.empty')).toHaveLength(2); + expect(qa(card, '.percent').map(text)).toEqual(['100%']); + expect(qa(card, '.label').map(text)).toEqual([ + 'Solar produced 24.6 kWh', + 'After dark Unavailable', + 'sensor.missing Entity not found', + ]); + expect(qa(card, '.stage.unavailable')).toHaveLength(2); + }); + + it('checks its options', () => { + const card = document.createElement('chartdeck-card') as Card; + const set = (extra: Record) => () => + card.setConfig({ type: 'x', style: 'funnel', entities: ['sensor.a'], ...extra } as CardConfig); + expect(set({ shape: 'cone' })).toThrow(/"shape" must be one of: funnel, pyramid/); + expect(set({ percent_of: 'last' })).toThrow(/percent_of/); + expect(set({ color_by: 'mood' })).toThrow(/color_by/); + expect(set({ shape: 'pyramid', percent_of: 'none', color_by: 'bands' })).not.toThrow(); + }); + + it('opens more-info from a stage', async () => { + const card = await mount({}); + const fired: string[] = []; + card.addEventListener('hass-action', (e) => fired.push((e as CustomEvent).detail.config.entity)); + const stage = qa(card, '.stage.entity')[1] as HTMLElement; + stage.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true })); + stage.dispatchEvent(new MouseEvent('pointerup', { bubbles: true })); + expect(fired).toEqual(['sensor.home']); + }); +}); + +describe('funnel editor', () => { + it('shows the Chart section and writes shape, percentages and colour', 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: 'funnel', entities: ['sensor.solar'] } 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({ funnel_shape: 'funnel', funnel_percent_of: 'first', funnel_color_by: 'series' }); + const labels = (form.computeLabel as (s: { name: string }) => string)({ name: 'funnel_percent_of' }); + expect(labels).toBe('Percentages'); + form.dispatchEvent( + new CustomEvent('value-changed', { + detail: { value: { ...(form.data as object), funnel_shape: 'pyramid', funnel_color_by: 'bands' } }, + }), + ); + expect(events.at(-1)).toEqual({ ...raw, shape: 'pyramid', color_by: 'bands' }); + expect( + ed.shadowRoot!.querySelector('.styles[data-group="totals"] .style[data-style="funnel"] svg polygon'), + ).not.toBeNull(); + }); +}); diff --git a/test/gauge.test.ts b/test/gauge.test.ts new file mode 100644 index 0000000..3f24d97 --- /dev/null +++ b/test/gauge.test.ts @@ -0,0 +1,282 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { + angleAt, + arcBox, + centerContent, + customAngles, + gaugeAngles, + labelPlacement, + parseGaugeOptions, + scaleTicks, + tickDecimals, + tickStep, + union, +} from '../src/gauge'; +import { arcPath } from '../src/geometry'; +import type { CardConfig } from '../src/types'; +import { makeHass } from './helpers/hass'; + +class StubElement 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 StubElement {}); +} +await import('../src/chartdeck-card'); +await import('../src/editor/chartdeck-card-editor'); + +const cfg = (c: Partial) => ({ type: 'custom:chartdeck-card', ...c }) as CardConfig; + +describe('gauge options', () => { + it("defaults to the value in the middle, no tick labels, the style's own angles", () => { + const o = parseGaugeOptions(cfg({ style: 'ring' })); + expect(o).toEqual({ center: 'value', centerText: '', tickLabels: false }); + expect(customAngles(o)).toBe(false); + expect(gaugeAngles([-135, 135], o)).toEqual([-135, 135]); + }); + + it('reads angles, numeric strings too, and fills a missing one from the style', () => { + const o = parseGaugeOptions(cfg({ style: 'needle', start_angle: '-120' })); + expect(o.startAngle).toBe(-120); + expect(customAngles(o)).toBe(true); + expect(gaugeAngles([-90, 90], o)).toEqual([-120, 90]); + expect(parseGaugeOptions(cfg({ style: 'arc', start_angle: 0, end_angle: 360 })).endAngle).toBe(360); + }); + + it('rejects bad options with readable errors', () => { + expect(() => parseGaugeOptions(cfg({ style: 'ring', start_angle: 'up' }))).toThrow( + '"start_angle" must be a number of degrees from -360 to 360', + ); + expect(() => parseGaugeOptions(cfg({ style: 'ring', end_angle: 400 }))).toThrow(/end_angle/); + expect(() => parseGaugeOptions(cfg({ style: 'needle', start_angle: 100 }))).toThrow( + '"end_angle" (90) must be greater than "start_angle" (100)', + ); + expect(() => parseGaugeOptions(cfg({ style: 'ring', start_angle: -200, end_angle: 200 }))).toThrow( + /at most 360 degrees/, + ); + expect(() => parseGaugeOptions(cfg({ style: 'ring', center: 'middle' }))).toThrow( + '"center" must be one of: value, band, name, text, none', + ); + expect(() => parseGaugeOptions(cfg({ style: 'ring', center_text: 5 }))).toThrow('"center_text" must be text'); + expect(() => parseGaugeOptions(cfg({ style: 'needle', tick_labels: 'yes' }))).toThrow(/tick_labels/); + // Not a gauge: the order of the angles isn't checked (there is no default to fill from). + expect(() => parseGaugeOptions(cfg({ style: 'bar', start_angle: 100 }))).not.toThrow(); + }); + + it('chooses the centre content', () => { + const e = { name: 'Air', band: { label: 'Good' } }; + const opts = (c: Partial) => parseGaugeOptions(cfg({ style: 'arc', ...c })); + expect(centerContent(e, undefined)).toEqual({ kind: 'value' }); + expect(centerContent(e, opts({ center: 'band' }))).toEqual({ kind: 'text', text: 'Good', band: true }); + expect(centerContent({ name: 'Air', band: {} }, opts({ center: 'band' }))).toEqual({ kind: 'value' }); + expect(centerContent(e, opts({ center: 'name' }))).toEqual({ kind: 'text', text: 'Air', band: false }); + expect(centerContent(e, opts({ center: 'text', center_text: 'Hi' }))).toEqual({ + kind: 'text', + text: 'Hi', + band: false, + }); + expect(centerContent(e, opts({ center: 'text' }))).toEqual({ kind: 'none' }); + expect(centerContent(e, opts({ center: 'none' }))).toEqual({ kind: 'none' }); + }); +}); + +describe('gauge geometry', () => { + it('maps a position along the sweep', () => { + expect(angleAt([-90, 90], 0.5)).toBe(0); + expect(angleAt([-120, 60], 1)).toBe(60); + }); + + it('boxes an arc by its ends and the compass points it passes', () => { + // Top half of a circle at (60, 60), radius 10: from 9 o'clock over the top to 3 o'clock. + expect(arcBox(60, 60, 10, -90, 90, 0)).toEqual({ x0: 50, y0: 50, x1: 70, y1: 60 }); + const full = arcBox(60, 60, 10, -180, 180, 1); + expect(full.x0).toBeCloseTo(49); + expect(full.y1).toBeCloseTo(71); + expect(union({ x0: 0, y0: 5, x1: 3, y1: 6 }, { x0: 1, y0: -1, x1: 2, y1: 9 })).toEqual({ + x0: 0, + y0: -1, + x1: 3, + y1: 9, + }); + }); + + it('makes round tick values from min to max, ends always labelled', () => { + expect(tickStep(5)).toBe(1); + expect(tickStep(100)).toBe(20); + expect(tickStep(0)).toBe(1); + expect(scaleTicks(0, 5)).toEqual([0, 1, 2, 3, 4, 5]); + expect(scaleTicks(0, 100)).toEqual([0, 20, 40, 60, 80, 100]); + expect(scaleTicks(-2000, 2000)).toEqual([-2000, -1000, 0, 1000, 2000]); + // 13 and 87 are the scale's ends; 20 and 80 sit too close to them (under 40 % of a step). + expect(scaleTicks(13, 87)).toEqual([13, 40, 60, 87]); + // Here 20 and 80 are far enough from the ends to keep. + expect(scaleTicks(11, 99)).toEqual([11, 20, 40, 60, 80, 99]); + expect(scaleTicks(5, 5)).toEqual([]); + expect(tickDecimals(2.5, 2.5)).toBe(1); + expect(tickDecimals(13.25, 20)).toBe(2); + expect(tickDecimals(1000, 1000)).toBe(0); + }); + + it('anchors labels outward from the dial', () => { + expect(labelPlacement(-90)).toEqual({ anchor: 'end', dy: 0.35 }); + expect(labelPlacement(90)).toEqual({ anchor: 'start', dy: 0.35 }); + expect(labelPlacement(0)).toEqual({ anchor: 'middle', dy: 0 }); + expect(labelPlacement(180)).toEqual({ anchor: 'middle', dy: 0.75 }); + }); +}); + +type Card = HTMLElement & { setConfig(c: CardConfig): void; hass?: unknown; updateComplete: Promise }; + +async function mount(config: Partial): Promise { + const card = document.createElement('chartdeck-card') as Card; + card.setConfig(cfg({ entities: ['sensor.a'], ...config })); + card.hass = makeHass({ + 'sensor.a': ['42', { unit_of_measurement: '%', friendly_name: 'Air' }], + 'sensor.b': 'unavailable', + }); + document.body.appendChild(card); + 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('gauges with extras', () => { + it('leave the default drawing alone', async () => { + const ring = await mount({ style: 'ring' }); + expect(q(ring, '.dial')!.classList.contains('fitted')).toBe(false); + expect(q(ring, '.radial svg')!.getAttribute('viewBox')).toBe('0 0 120 120'); + const needle = await mount({ style: 'needle' }); + expect(q(needle, '.needle-gauge svg')!.getAttribute('viewBox')).toBe('0 0 130 104'); + expect(qa(needle, '.tickmark')).toHaveLength(11); + expect(qa(needle, '.tick-label')).toHaveLength(0); + }); + + it('draw a ring or arc over custom angles, cropped to the sweep', async () => { + const card = await mount({ style: 'arc', start_angle: -90, end_angle: 90, target: 50 }); + expect(q(card, '.track')!.getAttribute('d')).toBe(arcPath(60, 60, 46, -90, 90)); + const dial = q(card, '.dial') as HTMLElement; + expect(dial.classList.contains('fitted')).toBe(true); + // Top half: the arc's top (60 - 46 - 6.5) to the centre disc's bottom (94). + expect(q(card, '.radial svg')!.getAttribute('viewBox')).toBe('0 7 120 87'); + expect(dial.getAttribute('style')).toContain('--y0: 7'); + // The target at 50 % is straight up. + const tick = q(card, '.tick')!; + expect(tick.getAttribute('x1')).toBe('60.00'); + }); + + it('show band label, name, own text or nothing in the middle', async () => { + const bands = [ + { from: 0, color: 'red', label: 'Poor' }, + { from: 30, color: 'green', label: 'Good' }, + ]; + const band = await mount({ style: 'ring', center: 'band', severity: bands }); + expect(q(band, '.center-text')!.textContent).toBe('Good'); + expect(q(band, '.center-text')!.getAttribute('style')).toContain('--success-color'); + expect(q(band, '.value')).toBeNull(); + + const name = await mount({ style: 'arc', center: 'name' }); + expect(q(name, '.center-text')!.textContent).toBe('Air'); + expect(q(name, '.name')).toBeNull(); + + const text = await mount({ style: 'ring', center: 'text', center_text: 'Comfy' }); + expect(q(text, '.center-text')!.textContent).toBe('Comfy'); + + const none = await mount({ style: 'ring', center: 'none' }); + expect(q(none, '.center-text')).toBeNull(); + expect(q(none, '.value')).toBeNull(); + expect(q(none, '.icon')!.classList.contains('centre')).toBe(true); + + const needle = await mount({ style: 'needle', center: 'band', severity: bands }); + expect(q(needle, '.center-text')!.textContent).toBe('Good'); + }); + + it('still show status text for an unavailable entity whatever the centre', async () => { + const card = await mount({ style: 'ring', center: 'text', center_text: 'Hi', entities: ['sensor.b'] }); + expect(q(card, '.na')!.textContent).toContain('Unavailable'); + expect(q(card, '.center-text')).toBeNull(); + const needle = await mount({ + style: 'needle', + tick_labels: true, + start_angle: -135, + end_angle: 135, + entities: ['sensor.b', 'sensor.missing'], + }); + expect(qa(needle, '.na')).toHaveLength(2); + expect(qa(needle, '.needle')).toHaveLength(0); + }); + + it('label the needle scale, following custom angles, outside the target marker', async () => { + const card = await mount({ style: 'needle', tick_labels: true, max: 5, target: 3 }); + expect(qa(card, '.tick-label').map((t) => t.textContent)).toEqual(['0', '1', '2', '3', '4', '5']); + expect(qa(card, '.tickmark')).toHaveLength(6); + const gauge = q(card, '.needle-gauge') as HTMLElement; + expect(gauge.classList.contains('fitted')).toBe(true); + expect(gauge.getAttribute('style')).toContain('--needle-start: -90deg'); + // Left end label: at 9 o'clock, past the target marker (R + 17 = 67 from the hub at x 65). + const first = qa(card, '.tick-label')[0]; + expect(first.getAttribute('x')).toBe('-2.00'); + expect(first.getAttribute('text-anchor')).toBe('end'); + + const wide = await mount({ style: 'needle', start_angle: -135, end_angle: 135, show: { target: false } }); + expect(q(wide, '.needle g, g.needle')!.getAttribute('style')).toMatch(/rotate\(-21\.6deg\)/); + // The value line moves under the arc, which now comes down past the hub. + expect(Number(q(wide, '.value')!.getAttribute('y'))).toBeGreaterThan(110); + }); +}); + +describe('gauge editor', () => { + type Editor = HTMLElement & { setConfig(c: CardConfig): void; hass?: unknown; updateComplete: Promise }; + async function editor(config: Partial) { + const ed = document.createElement('chartdeck-card-editor') as Editor; + ed.hass = makeHass({ 'sensor.a': '1' }); + ed.setConfig(cfg(config)); + const events: CardConfig[] = []; + ed.addEventListener('config-changed', (e) => events.push((e as CustomEvent).detail.config)); + document.body.appendChild(ed); + await ed.updateComplete; + return { ed, events }; + } + const form = (ed: Editor) => ed.shadowRoot!.querySelector('ha-form[data-section="gauge"]') as StubElement | null; + const names = (schema: unknown) => + (schema as { name: string; schema?: { name: string }[] }[]).flatMap((f) => + f.schema ? f.schema.map((x) => x.name) : [f.name], + ); + + it('shows gauge options only for gauge styles, tick labels only on the needle', async () => { + expect(form((await editor({ style: 'bar' })).ed)).toBeNull(); + const ring = form((await editor({ style: 'ring' })).ed)!; + expect(names(ring.schema)).toEqual(['start_angle', 'end_angle', 'center']); + const needle = form((await editor({ style: 'needle', center: 'text' })).ed)!; + expect(names(needle.schema)).toEqual(['start_angle', 'end_angle', 'center', 'center_text', 'tick_labels']); + expect(needle.data).toEqual({ center: 'text', tick_labels: false }); + }); + + it('writes the options to the card, dropping defaults', async () => { + const { ed, events } = await editor({ style: 'arc', center: 'band' }); + const f = form(ed)!; + f.dispatchEvent( + new CustomEvent('value-changed', { + detail: { value: { start_angle: -90, end_angle: 90, center: 'value' } }, + bubbles: true, + composed: true, + }), + ); + const last = events.at(-1)!; + expect(last.start_angle).toBe(-90); + expect(last.end_angle).toBe(90); + expect('center' in last).toBe(false); + }); +}); diff --git a/test/grouped-bars.test.ts b/test/grouped-bars.test.ts new file mode 100644 index 0000000..072c18c --- /dev/null +++ b/test/grouped-bars.test.ts @@ -0,0 +1,288 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import '../src/chartdeck-card'; +import type { CardConfig, HomeAssistant } from '../src/types'; +import { layoutBars, rowHeight } from '../src/styles/grouped-bars'; +import type { BarInput } from '../src/styles/grouped-bars'; +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 input = (index: number, group: string, series: string, value: number | null): BarInput => ({ + index, + group, + series, + value, +}); + +describe('bar layout', () => { + it('groups entities into rows, side by side, from zero', () => { + const layout = layoutBars( + [input(0, 'K', 'Today', 4), input(1, 'K', 'Yesterday', 3), input(2, 'L', 'Today', -2)], + 'grouped', + ); + expect(layout.rows.map((r) => r.key)).toEqual(['K', 'L']); + expect(layout.series).toEqual(['Today', 'Yesterday']); + expect(layout.rows[0].segments.map((s) => [s.from, s.to, s.slot])).toEqual([ + [0, 4, 0], + [0, 3, 1], + ]); + expect(layout.rows[0].total).toBe(7); + expect(layout.domain).toEqual([-2, 4]); + }); + + it('stacks positives right and negatives left of zero', () => { + const layout = layoutBars( + [input(0, 'A', 'x', 3), input(1, 'A', 'y', -1), input(2, 'A', 'z', 2), input(3, 'A', 'w', -4)], + 'stacked', + ); + expect(layout.rows[0].segments.map((s) => [s.from, s.to])).toEqual([ + [0, 3], + [0, -1], + [3, 5], + [-1, -5], + ]); + expect(layout.domain).toEqual([-5, 5]); + expect(layout.rows[0].total).toBe(0); + }); + + it('stacks to 100 % of the absolute total', () => { + const layout = layoutBars([input(0, 'A', 'x', 3), input(1, 'A', 'y', 1), input(2, 'B', 'x', 0)], 'percent'); + expect(layout.rows[0].segments.map((s) => [s.from, s.to])).toEqual([ + [0, 0.75], + [0.75, 1], + ]); + expect(layout.rows[1].segments[0].to).toBe(0); + expect(layout.domain).toEqual([0, 1]); + const mixed = layoutBars([input(0, 'A', 'x', 3), input(1, 'A', 'y', -1)], 'percent'); + expect(mixed.domain).toEqual([-0.25, 0.75]); + }); + + it('leaves a missing value out, and sorts rows by total with empty rows last', () => { + const inputs = [input(0, 'A', '', 1), input(1, 'B', '', null), input(2, 'C', '', 5), input(3, 'A', '', 1)]; + expect(layoutBars(inputs, 'stacked', 'descending').rows.map((r) => r.key)).toEqual(['C', 'A', 'B']); + expect(layoutBars(inputs, 'stacked', 'ascending').rows.map((r) => r.key)).toEqual(['A', 'C', 'B']); + const layout = layoutBars(inputs, 'grouped'); + expect(layout.rows[1].total).toBeNull(); + expect(layout.rows[1].segments[0].to).toBe(0); + expect(layoutBars([input(0, 'A', '', null)], 'grouped').domain).toEqual([0, 1]); + }); + + it('sizes grouped rows by their bars', () => { + const layout = layoutBars([input(0, 'A', 'x', 1), input(1, 'A', 'y', 1), input(2, 'B', 'x', 1)], 'grouped'); + expect(rowHeight(layout.rows[0], 'grouped')).toBeGreaterThan(rowHeight(layout.rows[1], 'grouped')); + expect(rowHeight(layout.rows[0], 'stacked')).toBe(rowHeight(layout.rows[1], 'stacked')); + }); +}); + +type Card = HTMLElement & { setConfig(c: CardConfig): void; hass?: HomeAssistant; updateComplete: Promise }; + +function hass(values: Record = {}) { + return makeHass({ + 'sensor.k_today': [values.k_today ?? '4', { friendly_name: 'Kitchen today', unit_of_measurement: 'kWh' }], + 'sensor.k_yday': ['3', { friendly_name: 'Kitchen yesterday', unit_of_measurement: 'kWh' }], + 'sensor.l_today': [values.l_today ?? '2', { friendly_name: 'Lounge today', unit_of_measurement: 'kWh' }], + 'sensor.l_yday': ['unavailable', { friendly_name: 'Lounge yesterday', unit_of_measurement: 'kWh' }], + 'sensor.grid': ['-500', { friendly_name: 'Grid', unit_of_measurement: 'W' }], + }); +} + +const ENTITIES = [ + { entity: 'sensor.k_today', group: 'Kitchen', series: 'Today' }, + { entity: 'sensor.k_yday', group: 'Kitchen', series: 'Yesterday' }, + { entity: 'sensor.l_today', group: 'Lounge', series: 'Today' }, + { entity: 'sensor.l_yday', group: 'Lounge', series: 'Yesterday', color: 'red' }, + { entity: 'sensor.missing', group: 'Hall', series: 'Today' }, +]; + +async function mount(config: Partial, h = hass()) { + const card = document.createElement('chartdeck-card') as Card; + card.setConfig({ + type: 'custom:chartdeck-card', + style: 'grouped_bars', + decimals: 0, + entities: ENTITIES, + ...config, + } as CardConfig); + card.hass = h; + document.body.appendChild(card); + 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)] as HTMLElement[]; +const rowNames = (card: Card) => qa(card, '.row-name').map((e) => e.textContent!.trim()); + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('grouped bars', () => { + it('draws a row per group with one tappable bar per entity, values and a series legend', async () => { + const card = await mount({}); + expect(rowNames(card)).toEqual(['Kitchen', 'Lounge', 'Hall']); + const bars = qa(card, '.bar-seg.entity'); + expect(bars.map((b) => b.dataset.index)).toEqual(['0', '1', '2', '3', '4']); + expect(bars[0].getAttribute('style')).toContain('--primary-color'); + expect(bars[1].getAttribute('style')).toContain('--warning-color'); + expect(bars[3].getAttribute('style')).toContain('--error-color'); + expect(bars[3].classList.contains('unavailable')).toBe(true); + expect(bars[0].getAttribute('aria-label')).toBe('Kitchen today, 4 kWh'); + const values = qa(card, '.bar-value').map((e) => e.textContent!.trim()); + expect(values).toEqual(['4 kWh', '3 kWh', '2 kWh', 'Unavailable', 'Entity not found']); + expect(qa(card, '.bar-legend-item').map((e) => e.textContent!.trim())).toEqual(['Today', 'Yesterday']); + expect(q(card, '.bars')!.getAttribute('role')).toBe('img'); + expect(q(card, '.bars')!.getAttribute('aria-label')).toMatch(/^Bar chart of Kitchen today/); + }); + + it('stacks, totals each row, and re-orders rows by total, keeping each row element', async () => { + const card = await mount({ mode: 'stacked', sort: 'descending' }); + expect(rowNames(card)).toEqual(['Kitchen', 'Lounge', 'Hall']); + expect(qa(card, '.bar-value.total').map((e) => e.textContent!.trim())).toEqual([ + '7 kWh', + '2 kWh', + 'Entity not found', + ]); + const kitchen = qa(card, '.bar-row')[0]; + const before = kitchen.style.transform; + card.hass = hass({ k_today: '0', l_today: '9' }); + await card.updateComplete; + expect(rowNames(card)).toEqual(['Lounge', 'Kitchen', 'Hall']); + // The same element moved down rather than another row being redrawn into it. + expect(qa(card, '.bar-row').indexOf(kitchen)).toBe(1); + expect(kitchen.style.transform).not.toBe(before); + }); + + it('labels shares in percent mode', async () => { + const card = await mount({ mode: 'percent' }); + const labels = qa(card, '.seg-text').map((e) => e.textContent!.trim()); + expect(labels).toEqual(['57 %', '43 %', '100 %']); + expect(qa(card, '.bar-value')).toHaveLength(0); + }); + + it('draws negatives left of zero, and rows without a group under the entity name', async () => { + const card = await mount({ entities: ['sensor.grid', 'sensor.k_today'], show: { legend: true } }); + expect(rowNames(card)).toEqual(['Grid', 'Kitchen today']); + const [grid, kitchen] = qa(card, '.bar-seg'); + expect(grid.classList.contains('negative')).toBe(true); + expect(kitchen.classList.contains('negative')).toBe(false); + expect(q(card, '.bar-legend')).toBeNull(); + }); + + it('colours by band, hides names, values and legend when asked', async () => { + const card = await mount({ + color_by: 'bands', + show: { name: false, value: false }, + severity: [ + { from: 0, color: 'green' }, + { from: 3.5, color: 'red' }, + ], + } as Partial); + const bars = qa(card, '.bar-seg'); + expect(bars[0].getAttribute('style')).toContain('--error-color'); + expect(bars[1].getAttribute('style')).toContain('--success-color'); + expect(qa(card, '.row-name')).toHaveLength(0); + expect(qa(card, '.bar-value')).toHaveLength(0); + expect(q(card, '.bar-legend')).toBeNull(); + const off = await mount({ show: { legend: false } }); + expect(q(off, '.bar-legend')).toBeNull(); + }); + + it('runs an entity action from a bar', async () => { + const card = await mount({}); + const fired: string[] = []; + card.addEventListener('hass-action', (e) => fired.push((e as CustomEvent).detail.config.entity)); + const bar = qa(card, '.bar-seg')[2]; + bar.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true })); + bar.dispatchEvent(new MouseEvent('pointerup', { bubbles: true })); + expect(fired).toEqual(['sensor.l_today']); + }); + + it('checks its options', () => { + const card = document.createElement('chartdeck-card') as Card; + const set = (extra: Record) => () => + card.setConfig({ type: 'x', style: 'grouped_bars', entities: ['sensor.a'], ...extra } as CardConfig); + expect(set({ mode: 'sideways' })).toThrow(/"mode" must be one of: grouped, stacked, percent/); + expect(set({ color_by: 'mood' })).toThrow(/color_by/); + expect(set({ entities: [{ entity: 'sensor.a', group: ['x'] }] })).toThrow(/Entity 1: "group" must be text/); + expect(set({ entities: ['sensor.a', { entity: 'sensor.b', series: 2 }] })).not.toThrow(); + }); +}); + +describe('grouped bars editor', () => { + async function editor(raw: CardConfig) { + 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({}); + ed.setConfig(raw); + document.body.appendChild(ed); + await ed.updateComplete; + return { ed, events }; + } + + it('writes mode and colour from the Chart section', async () => { + const raw = { type: 'custom:chartdeck-card', style: 'grouped_bars', entities: ['sensor.a'] } as CardConfig; + const { ed, events } = await editor(raw); + const form = ed.shadowRoot!.querySelector('.section-chart ha-form') as Stub; + expect(form.data).toEqual({ mode: 'grouped', bars_color_by: 'series' }); + form.dispatchEvent( + new CustomEvent('value-changed', { detail: { value: { mode: 'percent', bars_color_by: 'bands' } } }), + ); + expect(events.at(-1)).toEqual({ ...raw, mode: 'percent', color_by: 'bands' }); + }); + + it("shows row, series and colour in each entity's options, only for this style", async () => { + const raw = { + type: 'custom:chartdeck-card', + style: 'grouped_bars', + entities: [{ entity: 'sensor.a', group: 'Kitchen' }], + } as CardConfig; + const { ed, events } = await editor(raw); + const list = ed.shadowRoot!.querySelector('cd-entities-editor') as HTMLElement & { + updateComplete: Promise; + chartFields?: unknown; + }; + expect(list.chartFields).toBeDefined(); + (list.shadowRoot!.querySelector('.expand') as HTMLElement).click(); + await list.updateComplete; + const form = list.shadowRoot!.querySelector('ha-form[data-section="entity-chart"]') as Stub; + expect(form.data).toEqual({ bar_group: 'Kitchen' }); + form.dispatchEvent( + new CustomEvent('value-changed', { + detail: { value: { bar_group: 'Kitchen', bar_series: 'Today', bar_color: 'green' } }, + }), + ); + expect(events.at(-1)!.entities).toEqual([ + { entity: 'sensor.a', group: 'Kitchen', series: 'Today', color: 'green' }, + ]); + + document.body.innerHTML = ''; + const bar = await editor({ type: 'custom:chartdeck-card', style: 'bar', entities: ['sensor.a'] } as CardConfig); + const plain = bar.ed.shadowRoot!.querySelector('cd-entities-editor') as HTMLElement & { + updateComplete: Promise; + }; + (plain.shadowRoot!.querySelector('.expand') as HTMLElement).click(); + await plain.updateComplete; + expect(plain.shadowRoot!.querySelector('ha-form[data-section="entity-chart"]')).toBeNull(); + expect(plain.shadowRoot!.querySelector('ha-form[data-section="entity-text"]')).not.toBeNull(); + }); +}); diff --git a/test/hierarchy.test.ts b/test/hierarchy.test.ts new file mode 100644 index 0000000..5db1641 --- /dev/null +++ b/test/hierarchy.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from 'vitest'; +import { normalizeConfig } from '../src/config'; +import { + buildTree, + findNode, + groupPath, + levelName, + parseGroupBy, + partition, + splitGroup, + treeHeight, + validateGroups, +} from '../src/chart/hierarchy'; +import { resolveEntities } from '../src/resolve'; +import type { CardConfig, HomeAssistant } from '../src/types'; +import { makeHass } from './helpers/hass'; + +function registries(): HomeAssistant { + const hass = makeHass({ + 'sensor.oven': ['1.2', { friendly_name: 'Oven' }], + 'sensor.kettle': ['0.6', { friendly_name: 'Kettle' }], + 'sensor.tv': ['0.5', { friendly_name: 'TV' }], + 'sensor.ev': ['3.6', { friendly_name: 'EV' }], + 'sensor.off': ['unavailable', { friendly_name: 'Off' }], + }); + Object.assign(hass, { + entities: { + 'sensor.oven': { entity_id: 'sensor.oven', device_id: 'd1', labels: ['cooking'] }, + 'sensor.kettle': { entity_id: 'sensor.kettle', area_id: 'kitchen' }, + 'sensor.tv': { entity_id: 'sensor.tv', area_id: 'living_room' }, + 'sensor.ev': { entity_id: 'sensor.ev' }, + }, + devices: { d1: { id: 'd1', name: 'Oven', name_by_user: 'Big oven', area_id: 'kitchen', labels: ['kitchen_kit'] } }, + areas: { + kitchen: { area_id: 'kitchen', name: 'Kitchen', floor_id: 'ground' }, + living_room: { area_id: 'living_room', floor_id: 'first' }, + }, + floors: { ground: { name: 'Ground floor' } }, + labels: { cooking: { name: 'Cooking' } }, + }); + return hass; +} + +function items(config: Partial, hass = registries()) { + return resolveEntities(normalizeConfig({ type: 'x', ...config } as CardConfig), hass); +} + +describe('grouping', () => { + it('reads group_by as a list in house order, and rejects unknown levels', () => { + expect(parseGroupBy(undefined)).toEqual([]); + expect(parseGroupBy('area')).toEqual(['area']); + expect(parseGroupBy(['area', 'floor', 'area'])).toEqual(['floor', 'area']); + expect(() => parseGroupBy(['room'])).toThrow(/group_by.*floor, area, device, label/); + expect(() => validateGroups({ type: 'x', entities: [{ entity: 'a', group: 3 }] } as unknown as CardConfig)).toThrow( + /Entity 1: "group" must be text/, + ); + expect(() => + validateGroups({ type: 'x', entities: ['a', { entity: 'b', group: 'X' }] } as CardConfig), + ).not.toThrow(); + }); + + it('splits an own group at slashes', () => { + expect(splitGroup(' Ground / Kitchen /')).toEqual(['Ground', 'Kitchen']); + expect(splitGroup(undefined)).toEqual([]); + }); + + it('names levels from the entity, its device, area and floor', () => { + const hass = registries(); + expect(levelName('sensor.oven', 'area', hass)).toBe('Kitchen'); + expect(levelName('sensor.oven', 'floor', hass)).toBe('Ground floor'); + expect(levelName('sensor.oven', 'device', hass)).toBe('Big oven'); + expect(levelName('sensor.oven', 'label', hass)).toBe('Cooking'); + expect(levelName('sensor.tv', 'area', hass)).toBe('Living room'); + expect(levelName('sensor.tv', 'floor', hass)).toBe('First'); + expect(levelName('sensor.kettle', 'device', hass)).toBeUndefined(); + expect(levelName('sensor.ev', 'area', hass)).toBeUndefined(); + expect(levelName('sensor.ev', 'label', hass)).toBeUndefined(); + expect(levelName('sensor.nope', 'floor', hass)).toBeUndefined(); + }); + + it('puts an entity in its own group, else its registry groups, else "Other"', () => { + const hass = registries(); + const list = items({ entities: ['sensor.oven', 'sensor.ev', { entity: 'sensor.tv', group: 'Up / Lounge' }] }, hass); + const paths = list.map((e) => groupPath(e, ['floor', 'area'], hass, 'Other')); + expect(paths).toEqual([ + ['Ground floor', 'Kitchen'], + ['Other', 'Other'], + ['Up', 'Lounge'], + ]); + expect(groupPath(list[0], [], hass, 'Other')).toEqual([]); + }); +}); + +describe('tree', () => { + it('sums groups, orders largest first and keeps item indexes', () => { + const list = items({ entities: ['sensor.tv', 'sensor.oven', 'sensor.kettle', 'sensor.ev', 'sensor.off'] }); + const paths = [['Lounge'], ['Kitchen'], ['Kitchen'], [], ['Kitchen']]; + const root = buildTree(list, paths, 'All'); + expect(root.value).toBeCloseTo(5.9); + expect(root.children.map((c) => c.name)).toEqual(['EV', 'Kitchen', 'Lounge']); + const kitchen = findNode(root, ['Kitchen'])!; + expect(kitchen.value).toBeCloseTo(1.8); + expect(kitchen.children.map((c) => [c.name, c.index])).toEqual([ + ['Oven', 1], + ['Kettle', 2], + ['Off', 4], + ]); + expect(kitchen.children[2].value).toBe(0); + expect(kitchen.path).toEqual(['Kitchen']); + expect(findNode(root, ['Nowhere'])).toBeUndefined(); + expect(findNode(root, [])).toBe(root); + expect(treeHeight(root)).toBe(2); + }); + + it('partitions each level in proportion, leaving out empty nodes', () => { + const list = items({ entities: ['sensor.tv', 'sensor.oven', 'sensor.kettle', 'sensor.off'] }); + const root = buildTree(list, [['A'], ['B'], ['B'], ['B']], 'All'); + const spans = partition(root); + expect(spans.map((s) => [s.node.name, s.level])).toEqual([ + ['All', 0], + ['B', 1], + ['Oven', 2], + ['Kettle', 2], + ['A', 1], + ['TV', 2], + ]); + const b = spans[1]; + expect(b.x0).toBe(0); + expect(b.x1).toBeCloseTo(1.8 / 2.3); + expect(spans.at(-1)!.x1).toBeCloseTo(1); + const empty = buildTree([], [], 'None'); + expect(partition(empty)).toHaveLength(1); + }); +}); diff --git a/test/history.test.ts b/test/history.test.ts index 52600f5..273f44f 100644 --- a/test/history.test.ts +++ b/test/history.test.ts @@ -37,10 +37,12 @@ describe('period', () => { }); describe('fetch', () => { - it('reads numbers and turns everything else into gaps', () => { + it('reads numbers and on / off, and turns everything else into gaps', () => { expect(toReading('21.5')).toBe(21.5); expect(toReading(3)).toBe(3); expect(toReading('unavailable')).toBeNull(); + expect(toReading('on')).toBe(1); + expect(toReading('off')).toBe(0); expect(toReading('')).toBeNull(); expect(toReading(Infinity)).toBeNull(); }); diff --git a/test/line.test.ts b/test/line.test.ts index 69a7e5b..59e7c51 100644 --- a/test/line.test.ts +++ b/test/line.test.ts @@ -268,16 +268,28 @@ describe('line chart editor', () => { curve: 'smooth', color_by: 'series', labels: false, + labels_show: 'all', + labels_position: 'auto', + labels_overlap: false, line_width: 2.5, dots: false, + extremes_max: false, + extremes_min: false, + extremes_average: 'none', x_axis: true, y_axis: true, axis_labels: 'outside', grid_horizontal: true, grid_vertical: false, + y_scale: 'linear', tooltip: true, + zoom: false, + overview: false, + zones: false, header: 'above', compact: false, + multiples: false, + multiples_scale: 'auto', }); form.dispatchEvent( new CustomEvent('value-changed', { diff --git a/test/log-scale.test.ts b/test/log-scale.test.ts new file mode 100644 index 0000000..37cd7f5 --- /dev/null +++ b/test/log-scale.test.ts @@ -0,0 +1,258 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import '../src/chartdeck-card'; +import { axisOptions, validateAxisOptions } from '../src/chart/axes'; +import { logAxis, logScale, logTickText, positivePoints } from '../src/chart/log-scale'; +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']) { + if (!customElements.get(tag)) customElements.define(tag, class extends Stub {}); +} +await import('../src/editor/chartdeck-card-editor'); + +describe('log axis maths', () => { + it('labels each power of ten when a few decades are shown, with fainter steps between', () => { + const a = logAxis([1, 1000], {}, 4); + expect(a.domain).toEqual([1, 1000]); + expect(a.ticks).toEqual([1, 10, 100, 1000]); + expect(a.minor).toContain(2); + expect(a.minor).toContain(900); + expect(a.minor).toHaveLength(24); + }); + + it('moves loose ends out to the next labelled step', () => { + expect(logAxis([12, 870], {}, 4)).toMatchObject({ domain: [10, 1000], ticks: [10, 100, 1000] }); + }); + + it('labels 1, 2 and 5 within a decade, or every step for a narrow span', () => { + expect(logAxis([0.5, 3], {}, 4)).toMatchObject({ + domain: [0.5, 5], + ticks: [0.5, 1, 2, 5], + tickDecimals: [1, 0, 0, 0], + }); + const narrow = logAxis([18, 24], {}, 4); + expect(narrow.ticks).toEqual([10, 20, 30]); + expect(narrow.minor).toEqual([]); + }); + + it('skips powers of ten when there are more decades than labels, counting from the bottom', () => { + const a = logAxis([0.003, 2e6], {}, 4); + expect(a.domain).toEqual([0.001, 1e7]); + expect(a.ticks).toEqual([0.001, 1, 1000, 1e6]); + expect(a.minor).toEqual([0.01, 0.1, 10, 100, 10000, 100000, 1e7]); + expect(a.decimals).toBe(3); + }); + + it('keeps fixed ends exactly', () => { + expect(logAxis([3, 40], { min: 1 }, 4)).toMatchObject({ domain: [1, 100], ticks: [1, 10, 100] }); + expect(logAxis([3, 40], { max: 500 }, 4).domain).toEqual([1, 500]); + }); + + it('gives flat readings, readings past a fixed end, and no readings some room', () => { + expect(logAxis([5, 5], {}, 4).domain).toEqual([1, 20]); + expect(logAxis([5, 8], { min: 50 }, 4).domain).toEqual([50, 500]); + expect(logAxis([500, 800], { max: 50 }, 4).domain).toEqual([5, 50]); + expect(logAxis(undefined, {}, 4).domain).toEqual([1, 100]); + // A caller that didn't drop zero: the bottom becomes three decades under the top. + expect(logAxis([0, 1000], {}, 4).domain).toEqual([1, 1000]); + expect(logAxis([-5, -1], {}, 4).domain).toEqual([0.1, 100]); + }); + + it('maps by logarithm and has no place for zero or below', () => { + const y = logScale([1, 1000], [200, 0]); + expect(y(1)).toBe(200); + expect(y(1000)).toBe(0); + expect(y(10)).toBeCloseTo(133.33, 1); + expect(y(0)).toBeNaN(); + expect(y(-3)).toBeNaN(); + expect(logScale([5, 5], [200, 0])(5)).toBe(200); + }); + + it('turns readings at or below zero into gaps, min and max too', () => { + const pts = positivePoints([ + { t: 1, v: 5 }, + { t: 2, v: 0 }, + { t: 3, v: -2, min: -4, max: 3 }, + { t: 4, v: null }, + ]); + expect(pts).toEqual([ + { t: 1, v: 5 }, + { t: 2, v: null }, + { t: 3, v: null, min: null, max: 3 }, + { t: 4, v: null }, + ]); + }); + + it('writes small ticks with the decimals they need and big ones short', () => { + const hass = makeHass({}); + expect(logTickText(0.01, 2, hass)).toBe('0.01'); + expect(logTickText(1000, 0, hass)).toBe('1,000'); + expect(logTickText(1e6, 0, hass)).toBe('1M'); + expect(logTickText(20000, 0, { ...hass, locale: { language: 'xx-nope' } } as HomeAssistant)).toBe('20K'); + }); +}); + +describe('log scale option', () => { + const base = { type: 'x', entities: [] } as unknown as CardConfig; + const check = (extra: Record) => () => validateAxisOptions({ ...base, ...extra } as CardConfig); + + it('defaults to linear and reads log', () => { + expect(axisOptions(base).scale).toBe('linear'); + expect(axisOptions({ ...base, y_scale: 'log' } as CardConfig).scale).toBe('log'); + }); + + it('explains a bad scale, and ends at or below zero on a log scale', () => { + expect(check({ y_scale: 'logarithmic' })).toThrow('"y_scale" must be one of: linear, log'); + expect(check({ y_scale: 'log', y_min: 0 })).toThrow('"y_min" must be above 0 on a log scale'); + expect(check({ y_scale: 'log', y_max: -1 })).toThrow('"y_max" must be above 0 on a log scale'); + expect(check({ y_scale: 'log', y_min: 0.1, y_max: 1000 })).not.toThrow(); + expect(check({ y_min: 0 })).not.toThrow(); + }); +}); + +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; + +async function mount(config: Partial, history: Record) { + const hass = makeHass({ + 'sensor.lux': ['800', { friendly_name: 'Light', unit_of_measurement: 'lx' }], + 'sensor.net': ['0', { friendly_name: 'Traffic', unit_of_measurement: 'B/s' }], + }); + hass.callWS = vi.fn(async () => history) as HomeAssistant['callWS']; + const card = document.createElement('chartdeck-card') as Card; + card.setConfig({ + type: 'custom:chartdeck-card', + style: 'line', + entities: ['sensor.lux', 'sensor.net'], + y_scale: 'log', + ...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 qa = (card: Card, sel: string) => [...card.shadowRoot!.querySelectorAll(sel)]; + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('line chart on a log scale', () => { + const history = { + 'sensor.lux': [ + { s: '2', lu: sec(20 * H) }, + { s: '40', lu: sec(12 * H) }, + { s: '0', lu: sec(10 * H) }, + { s: '30000', lu: sec(6 * H) }, + { s: '800', lu: sec(2 * H) }, + ], + 'sensor.net': [ + { s: '-5', lu: sec(20 * H) }, + { s: '0', lu: sec(4 * H) }, + ], + }; + + it('labels powers of ten, breaks the line at zero, and leaves the all-zero series undrawn', async () => { + const card = await mount({ labels: true }, history); + const labels = qa(card, '.y-axis .axis-label').map((e) => e.textContent!.trim()); + // Five decades is more than the labels fit: every other power of ten, the rest as faint lines. + expect(labels).toEqual(['1', '100', '10K']); + expect(qa(card, '.grid line.minor').length).toBeGreaterThan(0); + const [lux, net] = qa(card, 'path.line'); + // Two runs: before the zero, and after it. + expect(lux.getAttribute('d')!.match(/M/g)).toHaveLength(2); + expect(net.getAttribute('d')).toBe(''); + const points = qa(card, '.point-label').map((e) => e.textContent); + expect(points).toEqual(['2.0', '40.0', '30,000.0', '800.0']); + expect(points).not.toContain('0.0'); + }); + + it('puts band stops where the values are drawn', async () => { + const card = await mount( + { + color_by: 'bands', + y_min: 1, + y_max: 100, + severity: [ + { from: 0, color: 'blue' }, + { from: 10, color: 'red' }, + ], + }, + history, + ); + const offsets = qa(card, '#cd-bands-0 stop').map((s) => Number(s.getAttribute('offset'))); + expect(offsets).toEqual([0, 0.5, 0.5, 1]); + }); + + it('reads a zero out in the tooltip without a dot for it', async () => { + const card = await mount({}, history); + const area = card.shadowRoot!.querySelector('.plot-area')!; + area.getBoundingClientRect = () => ({ left: 0, top: 0, width: 1000, height: 160 }) as DOMRect; + // 10 h ago: the light read 0 then, and the traffic's nearest reading is its 0 from 4 h ago. + const ev = new MouseEvent('pointermove', { bubbles: true, clientX: (14 / 24) * 1000 }); + Object.defineProperty(ev, 'pointerType', { value: 'mouse' }); + area.dispatchEvent(ev); + await card.updateComplete; + const rows = qa(card, '.tooltip-row').map((r) => r.textContent!.replace(/\s+/g, ' ').trim()); + expect(rows).toEqual(['Light 0.0 lx', 'Traffic 0.0 B/s']); + expect(qa(card, '.hover-dot')).toHaveLength(0); + }); + + it('breaks the line at zero in each small chart too', async () => { + const card = await mount({ multiples: true }, history); + const lines = qa(card, 'path.line'); + // The light's line in two runs; the traffic never goes above zero, so its chart says it has nothing. + expect(lines).toHaveLength(1); + expect(lines[0].getAttribute('d')!.match(/M/g)).toHaveLength(2); + expect(lines[0].getAttribute('d')).not.toContain('NaN'); + expect(qa(card, '.panel .chart-message')).toHaveLength(1); + }); + + it('says so when nothing is above zero', async () => { + const card = await mount({ entities: ['sensor.net'] }, history); + expect(card.shadowRoot!.querySelector('.chart-message')!.textContent).toContain('Nothing above zero'); + }); + + it('turns down a log scale with a bottom at zero', () => { + const card = document.createElement('chartdeck-card') as Card; + expect(() => + card.setConfig({ type: 'x', style: 'line', entities: ['sensor.lux'], y_scale: 'log', y_min: 0 } as CardConfig), + ).toThrow('"y_min" must be above 0 on a log scale'); + }); +}); + +describe('log scale in the editor', () => { + it('offers the value scale in the Chart section and writes y_scale', 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: 'line', entities: ['sensor.lux'] } 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 as Record).y_scale).toBe('linear'); + const field = JSON.stringify(form.schema); + expect(field).toContain('"name":"y_scale"'); + expect(field).toContain('Logarithmic (powers of ten)'); + form.dispatchEvent( + new CustomEvent('value-changed', { detail: { value: { ...(form.data as object), y_scale: 'log' } } }), + ); + expect(events.at(-1)).toEqual({ ...raw, y_scale: 'log' }); + }); +}); diff --git a/test/multiples.test.ts b/test/multiples.test.ts new file mode 100644 index 0000000..ee21d52 --- /dev/null +++ b/test/multiples.test.ts @@ -0,0 +1,192 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import '../src/chartdeck-card'; +import { multiplesColumns, multiplesOptions, panelWidth, validateMultiples } from '../src/chart/multiples'; +import type { CardConfig, HomeAssistant } from '../src/types'; +import { makeHass } from './helpers/hass'; + +describe('small multiples options', () => { + it('reads and checks them', () => { + const check = (extra: Record) => () => validateMultiples({ type: 'x', ...extra } as CardConfig); + expect(check({ multiples: true, multiples_scale: 'independent', multiples_columns: 3 })).not.toThrow(); + expect(check({ multiples: 'yes' })).toThrow('"multiples" must be true or false'); + expect(check({ multiples_scale: 'own' })).toThrow('"multiples_scale" must be one of: auto, shared, independent'); + expect(check({ multiples_columns: 0 })).toThrow('"multiples_columns" must be a whole number from 1 to 6'); + expect(check({ multiples_columns: 2.5 })).toThrow('"multiples_columns" must be a whole number from 1 to 6'); + expect(multiplesOptions({ type: 'x' } as CardConfig)).toBeUndefined(); + expect(multiplesOptions({ type: 'x', multiples: true } as CardConfig)).toEqual({ + scale: 'auto', + columns: undefined, + }); + }); + + it('fits columns to the card, never more than there are charts', () => { + expect(multiplesColumns(4, 488)).toBe(3); + expect(multiplesColumns(2, 488)).toBe(2); + expect(multiplesColumns(4, 288)).toBe(1); + expect(multiplesColumns(5, 488, 4)).toBe(4); + // A set count drops to fewer on a narrow card. + expect(multiplesColumns(5, 288, 4)).toBe(2); + expect(multiplesColumns(0, 488)).toBe(1); + expect(panelWidth(488, 3)).toBeCloseTo(154.67, 2); + }); +}); + +type Card = HTMLElement & { setConfig(c: CardConfig): void; hass?: HomeAssistant; updateComplete: Promise }; +const now = Date.now(); +const H = 3_600_000; +const sec = (ago: number) => (now - ago) / 1000; + +function hass() { + const h = makeHass({ + 'sensor.in': ['21', { friendly_name: 'Inside', unit_of_measurement: '°C' }], + 'sensor.out': ['9', { friendly_name: 'Outside', unit_of_measurement: '°C' }], + 'sensor.off': 'unavailable', + }); + h.callWS = vi.fn(async () => ({ + 'sensor.in': [ + { s: '19', lu: sec(20 * H) }, + { s: '23', lu: sec(12 * H) }, + { s: '21', lu: sec(2 * H) }, + ], + 'sensor.out': [ + { s: '50', lu: sec(20 * H) }, + { s: '90', lu: sec(4 * H) }, + ], + 'sensor.off': [{ s: 'unavailable', lu: sec(20 * H) }], + })) as HomeAssistant['callWS']; + return h; +} + +async function mount(extra: Record) { + const card = document.createElement('chartdeck-card') as Card; + card.setConfig({ + type: 'custom:chartdeck-card', + style: 'line', + entities: ['sensor.in', 'sensor.out', 'sensor.off'], + multiples: true, + ...extra, + } 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) as HTMLElement | null; +const qa = (card: Card, sel: string) => [...card.shadowRoot!.querySelectorAll(sel)] as HTMLElement[]; +const yLabels = (panel: Element) => [...panel.querySelectorAll('.y-axis .axis-label')].map((e) => e.textContent); + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('small multiples on the line chart', () => { + it('draws one titled chart per entity, on one value scale', async () => { + const card = await mount({ period: { last: 24, unit: 'hour', navigate: true } }); + const panels = qa(card, '.panel'); + expect(panels).toHaveLength(3); + expect(qa(card, '.series-header')).toHaveLength(0); + expect(q(card, '.period-nav')).not.toBeNull(); + expect(qa(card, '.panel-title').map((t) => t.textContent!.replace(/\s+/g, ' ').trim())).toEqual([ + 'Inside 21.0 °C', + 'Outside 9.0 °C', + 'sensor.off Unavailable', + ]); + expect(qa(card, '.panel-title')[1].dataset.index).toBe('1'); + expect(panels[0].querySelectorAll('path.line')).toHaveLength(1); + expect(panels[0].querySelector('path.line')!.getAttribute('stroke')).toContain('--primary-color'); + expect(panels[1].querySelector('path.line')!.getAttribute('stroke')).toContain('--warning-color'); + expect(yLabels(panels[0])).toEqual(yLabels(panels[1])); + expect(panels[2].querySelector('.chart-message')!.textContent).toBe('No readings in this period.'); + expect(q(card, '.multiples')!.getAttribute('style')).toMatch(/repeat\(2,/); + }); + + it('gives each chart its own scale when asked, and a set number of columns', async () => { + const card = await mount({ multiples_scale: 'independent', multiples_columns: 1 }); + const [a, b] = qa(card, '.panel'); + expect(yLabels(a)).not.toEqual(yLabels(b)); + expect(q(card, '.multiples')!.getAttribute('style')).toMatch(/repeat\(1,/); + }); + + it('shows every chart its reading at the pointer, in its title', async () => { + const card = await mount({}); + const area = q(card, '.panel .plot-area')!; + area.getBoundingClientRect = () => + ({ left: 0, top: 0, width: 1000, height: 100, right: 1000, bottom: 100 }) as DOMRect; + const ev = new MouseEvent('pointermove', { bubbles: true, clientX: 500 }); + Object.defineProperty(ev, 'pointerType', { value: 'mouse' }); + area.dispatchEvent(ev); + await card.updateComplete; + expect(qa(card, '.crosshair')).toHaveLength(2); + expect(qa(card, '.panel-title .series-value').map((e) => e.textContent)).toEqual(['23.0 °C', '50.0 °C', '—']); + }); + + it('keeps band gradients apart between charts, and can hide the titles', async () => { + const card = await mount({ + color_by: 'bands', + severity: [ + { from: 0, color: 'blue' }, + { from: 20, color: 'red' }, + ], + show: { name: false, value: false }, + }); + const ids = qa(card, 'linearGradient').map((g) => g.id); + expect(new Set(ids).size).toBe(ids.length); + expect(qa(card, 'path.line')[1].getAttribute('stroke')).toBe('url(#cd-bands-1-1)'); + expect(qa(card, '.panel-title')).toHaveLength(0); + }); +}); + +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 {}); +} + +describe('small multiples in the editor', () => { + it('shows scale and columns once switched on, and writes them', async () => { + await import('../src/editor/chartdeck-card-editor'); + 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: 'line', 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(JSON.stringify(form().schema)).not.toContain('multiples_scale'); + ed.setConfig({ ...raw, multiples: true }); + await ed.updateComplete; + expect(JSON.stringify(form().schema)).toContain('multiples_scale'); + form().dispatchEvent( + new CustomEvent('value-changed', { + detail: { value: { ...(form().data as object), multiples_scale: 'independent', multiples_columns: 2 } }, + }), + ); + expect(events.at(-1)).toEqual({ ...raw, multiples: true, multiples_scale: 'independent', multiples_columns: 2 }); + }); +}); + +describe('small multiples scale', () => { + it('shares one scale only when the units match, unless told', async () => { + const { MULTIPLES_SCALES, multiplesOptions } = await import('../src/chart/multiples'); + expect(MULTIPLES_SCALES[0]).toBe('auto'); + expect(multiplesOptions({ type: 'x', multiples: true } as never).scale).toBe('auto'); + }); +}); diff --git a/test/polar-area.test.ts b/test/polar-area.test.ts new file mode 100644 index 0000000..0b4b47c --- /dev/null +++ b/test/polar-area.test.ts @@ -0,0 +1,187 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import '../src/chartdeck-card'; +import { normalizeConfig } from '../src/config'; +import { resolveEntities } from '../src/resolve'; +import { sliceAngles, sliceColor, sliceLength } from '../src/styles/polar-area'; +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 hass = () => + makeHass({ + 'sensor.oven': ['1200', { friendly_name: 'Oven', unit_of_measurement: 'W' }], + 'sensor.ev': ['3600', { friendly_name: 'EV', unit_of_measurement: 'W' }], + 'sensor.tv': ['400', { friendly_name: 'TV', unit_of_measurement: 'W' }], + 'sensor.off': ['unavailable', { friendly_name: 'Off' }], + }); + +const ENTITIES = ['sensor.oven', 'sensor.ev', 'sensor.tv', 'sensor.off', 'sensor.missing']; + +async function mount(config: Partial) { + const card = document.createElement('chartdeck-card') as Card; + card.setConfig({ type: 'custom:chartdeck-card', style: 'polar_area', entities: ENTITIES, ...config } as CardConfig); + card.hass = hass(); + document.body.appendChild(card); + 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('polar area maths', () => { + const items = resolveEntities(normalizeConfig({ type: 'x', entities: ENTITIES, max: 4000 } as CardConfig), hass()); + + it('measures slice length against the largest value or the entity limits', () => { + expect(sliceLength(items[0], 'shared', 3600)).toBeCloseTo(1 / 3); + expect(sliceLength(items[0], 'limits', 3600)).toBeCloseTo(0.3); + expect(sliceLength(items[0], 'shared', 0)).toBe(0); + expect(sliceLength(items[3], 'shared', 3600)).toBeNull(); + }); + + it('centres each slice on its spoke with a gap, and fills the circle with one', () => { + const [a0, a1] = sliceAngles(0, 4); + expect(a0).toBeCloseTo(-Math.PI / 4 + 0.02); + expect(a1).toBeCloseTo(Math.PI / 4 - 0.02); + expect(sliceAngles(0, 1)).toEqual([0, Math.PI * 2]); + }); + + it('colours by series, shade or band', () => { + expect(sliceColor(items[1], 1, 1, 'series', 'primary')).toContain('--warning-color'); + expect(sliceColor(items[1], 1, 1, 'shades', 'red')).toBe('var(--error-color, #ef3b5a)'); + expect(sliceColor(items[1], 1, 0.5, 'shades', '#123456')).toBe( + 'color-mix(in srgb, #123456 65%, var(--card-background-color, #fff))', + ); + expect(sliceColor(items[1], 1, 1, 'bands', 'primary')).toContain('--primary-color'); + }); +}); + +describe('polar area chart', () => { + it('draws a slice per value, sized against the largest, with names, values and ring values', async () => { + const card = await mount({}); + const slices = qa(card, 'path.slice.entity'); + expect(slices.map((s) => s.getAttribute('data-index'))).toEqual(['0', '1', '2']); + expect(slices[1].getAttribute('aria-label')).toBe('EV, 3,600.0 W'); + expect(slices[0].getAttribute('fill')).toContain('--primary-color'); + const labels = qa(card, '.slice-label').map((t) => t.textContent!.replace(/\s+/g, ' ').trim()); + expect(labels).toEqual([ + 'Oven 1,200.0 W', + 'EV 3,600.0 W', + 'TV 400.0 W', + 'Off Unavailable', + 'sensor.missing Entity not found', + ]); + expect(qa(card, '.slice-label.muted')).toHaveLength(2); + expect(qa(card, 'circle.ring')).toHaveLength(3); + expect(qa(card, '.ring-label').map((t) => t.textContent)).toEqual(['1,200.0 W', '2,400.0 W', '3,600.0 W']); + expect(q(card, 'svg.polar-plot')!.getAttribute('aria-label')).toBe( + 'Polar area chart of Oven, EV, TV, Off, sensor.missing', + ); + }); + + it('shades one colour, uses limits without ring values, and hides labels when asked', async () => { + const card = await mount({ + color_by: 'shades', + color: 'green', + scale: 'limits', + max: 4000, + show: { name: false, value: false }, + }); + expect(qa(card, 'path.slice')[1].getAttribute('fill')).toContain('color-mix(in srgb, var(--success-color'); + expect(qa(card, '.ring-label')).toHaveLength(0); + expect(qa(card, '.slice-label')).toHaveLength(0); + }); + + it('colours by band and shows values alone', async () => { + const card = await mount({ + color_by: 'bands', + show: { name: false }, + severity: [ + { from: 0, color: 'green' }, + { from: 1000, color: 'red' }, + ], + }); + const fills = qa(card, 'path.slice').map((s) => s.getAttribute('fill')); + expect(fills[2]).toContain('--success-color'); + expect(fills[0]).toContain('--error-color'); + expect(q(card, '.slice-label')!.textContent!.trim()).toBe('1,200.0 W'); + }); + + it('draws one entity as a disc, and nothing for a zero', async () => { + const card = await mount({ entities: ['sensor.ev'] }); + expect(q(card, 'path.slice')!.getAttribute('d')!.match(/A/g)).toHaveLength(4); + document.body.innerHTML = ''; + const none = await mount({ entities: ['sensor.off'] }); + expect(qa(none, 'path.slice')).toHaveLength(0); + expect(qa(none, '.ring-label')).toHaveLength(0); + }); + + it('opens more-info from a slice', async () => { + const card = await mount({}); + const fired: string[] = []; + card.addEventListener('hass-action', (e) => fired.push((e as CustomEvent).detail.config.entity)); + const slice = qa(card, 'path.slice')[2] as HTMLElement; + slice.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true })); + slice.dispatchEvent(new MouseEvent('pointerup', { bubbles: true })); + expect(fired).toEqual(['sensor.tv']); + }); + + it('checks its options', () => { + const card = document.createElement('chartdeck-card') as Card; + const set = (extra: Record) => () => + card.setConfig({ type: 'x', style: 'polar_area', entities: ['sensor.a'], ...extra } as CardConfig); + expect(set({ scale: 'log' })).toThrow(/"scale" must be one of: shared, limits/); + expect(set({ color_by: 'mood' })).toThrow(/"color_by" must be one of: series, shades, bands/); + expect(set({ color: 4 })).toThrow(/color/); + expect(set({ color: '#fa0', color_by: 'shades', scale: 'limits' })).not.toThrow(); + }); +}); + +describe('polar area editor', () => { + it('writes scale, colouring and the shade colour', 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 = hass(); + const raw = { type: 'custom:chartdeck-card', style: 'polar_area', entities: ['sensor.ev'] } 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({ polar_scale: 'shared', polar_color_by: 'series', polar_color: 'primary' }); + form.dispatchEvent( + new CustomEvent('value-changed', { + detail: { value: { polar_scale: 'limits', polar_color_by: 'shades', polar_color: 'purple' } }, + }), + ); + expect(events.at(-1)).toEqual({ ...raw, scale: 'limits', color_by: 'shades', color: 'purple' }); + const button = ed.shadowRoot!.querySelector('button[data-style="polar_area"]')!; + expect(button.closest('.styles')!.getAttribute('data-group')).toBe('compare'); + expect(button.textContent).toContain('Polar area'); + }); +}); diff --git a/test/polar.test.ts b/test/polar.test.ts new file mode 100644 index 0000000..f7795bb --- /dev/null +++ b/test/polar.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { axisAngle, labelAnchor, polar, polygonPoints, sectorPath } from '../src/chart/polar'; + +describe('polar maths', () => { + it('measures angles clockwise from 12 o’clock', () => { + expect(polar(10, 0)).toEqual([0, -10]); + expect(polar(10, Math.PI / 2)).toEqual([10, 0]); + expect(polar(10, Math.PI)).toEqual([0, 10]); + expect(polar(10, (3 * Math.PI) / 2)).toEqual([-10, 0]); + }); + + it('spaces spokes evenly, the first straight up', () => { + expect(axisAngle(0, 4)).toBe(0); + expect(axisAngle(1, 4)).toBeCloseTo(Math.PI / 2); + expect(axisAngle(3, 6)).toBeCloseTo(Math.PI); + expect(axisAngle(0, 0)).toBe(0); + }); + + it('draws wedges, ring sectors and whole rings', () => { + expect(sectorPath(0, 10, 0, Math.PI / 2)).toBe('M0 -10A10 10 0 0 1 10 0L0 0Z'); + expect(sectorPath(5, 10, 0, Math.PI / 2)).toBe('M0 -10A10 10 0 0 1 10 0L5 0A5 5 0 0 0 0 -5Z'); + expect(sectorPath(0, 10, 0, (3 * Math.PI) / 2)).toContain('A10 10 0 1 1'); + const disc = sectorPath(0, 10, 0, Math.PI * 2); + expect(disc.match(/A/g)).toHaveLength(2); + const ring = sectorPath(4, 10, 0, Math.PI * 2); + expect(ring.match(/A/g)).toHaveLength(4); + expect(sectorPath(0, 0, 0, 1)).toBe(''); + expect(sectorPath(0, 10, 1, 1)).toBe(''); + }); + + it('writes polygon points and anchors labels by side', () => { + expect( + polygonPoints([ + [0, -1], + [1, 0], + ]), + ).toBe('0,-1 1,0'); + expect(labelAnchor(0)).toBe('middle'); + expect(labelAnchor(Math.PI / 2)).toBe('start'); + expect(labelAnchor((3 * Math.PI) / 2)).toBe('end'); + expect(labelAnchor(Math.PI)).toBe('middle'); + }); +}); diff --git a/test/radar.test.ts b/test/radar.test.ts new file mode 100644 index 0000000..cce5931 --- /dev/null +++ b/test/radar.test.ts @@ -0,0 +1,229 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import '../src/chartdeck-card'; +import { deviceClassName, radarModel, radius } from '../src/styles/radar'; +import { normalizeConfig } from '../src/config'; +import { resolveEntities } from '../src/resolve'; +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 }; + +function air(): HomeAssistant { + const hass = makeHass({ + 'sensor.lounge_temp': [ + '21', + { friendly_name: 'Lounge temp', device_class: 'temperature', unit_of_measurement: '°C' }, + ], + 'sensor.lounge_co2': ['640', { friendly_name: 'Lounge CO2', device_class: 'carbon_dioxide' }], + 'sensor.lounge_hum': ['48', { friendly_name: 'Lounge humidity', device_class: 'humidity' }], + 'sensor.bed_temp': ['19', { friendly_name: 'Bedroom temp', device_class: 'temperature' }], + 'sensor.bed_co2': ['1120', { friendly_name: 'Bedroom CO2', device_class: 'carbon_dioxide' }], + 'sensor.bed_hum': ['unavailable', { friendly_name: 'Bedroom humidity', device_class: 'humidity' }], + }); + Object.assign(hass, { + entities: Object.fromEntries( + ['lounge_temp', 'lounge_co2', 'lounge_hum', 'bed_temp', 'bed_co2', 'bed_hum'].map((id) => [ + `sensor.${id}`, + { entity_id: `sensor.${id}`, area_id: id.startsWith('lounge') ? 'lounge' : 'bedroom' }, + ]), + ), + areas: { lounge: { area_id: 'lounge', name: 'Lounge' }, bedroom: { area_id: 'bedroom', name: 'Bedroom' } }, + }); + return hass; +} + +const ALL = [ + 'sensor.lounge_temp', + 'sensor.lounge_co2', + 'sensor.lounge_hum', + 'sensor.bed_temp', + 'sensor.bed_co2', + 'sensor.bed_hum', +]; + +async function mount(config: Partial, hass = air()) { + const card = document.createElement('chartdeck-card') as Card; + card.setConfig({ type: 'custom:chartdeck-card', style: 'radar', entities: ALL.slice(0, 3), ...config } as CardConfig); + card.hass = hass; + document.body.appendChild(card); + 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 = ''; +}); + +function items(config: Partial, hass = air()) { + return resolveEntities(normalizeConfig({ type: 'x', ...config } as CardConfig), hass); +} + +describe('radar maths', () => { + it('names device classes readably', () => { + expect(deviceClassName('carbon_dioxide')).toBe('CO₂'); + expect(deviceClassName('pm25')).toBe('PM2.5'); + expect(deviceClassName('atmospheric_pressure')).toBe('Atmospheric pressure'); + }); + + it('places a value between its limits, or as a share of the largest', () => { + const [temp, co2, , , , gone] = items({ entities: ALL, max: 2000 }); + expect(radius(temp, 'limits', 1120)).toBeCloseTo(21 / 2000); + expect(radius(co2, 'shared', 1120)).toBeCloseTo(640 / 1120); + expect(radius(co2, 'shared', 0)).toBe(0); + expect(radius(gone, 'limits', 1)).toBeNull(); + }); + + it('makes one series of every entity without groups', () => { + const hass = air(); + const model = radarModel(items({ entities: ALL.slice(0, 3) }, hass), { type: 'x' }, hass); + expect(model.axes.map((a) => a.name)).toEqual(['Lounge temp', 'Lounge CO2', 'Lounge humidity']); + expect(model.series).toHaveLength(1); + }); + + it('lines groups up on axes by axis name, device class, then name', () => { + const hass = air(); + const list = items( + { entities: [...ALL, { entity: 'sensor.lounge_temp', axis: 'Warmth', group: 'Spare' }, 'sensor.missing'] }, + hass, + ); + const model = radarModel(list, { type: 'x', group_by: 'area' }, hass); + expect(model.axes.map((a) => a.name)).toEqual(['Temperature', 'CO₂', 'Humidity', 'Warmth', 'sensor.missing']); + expect(model.series.map((s) => s.name)).toEqual(['Lounge', 'Bedroom', 'Spare', 'Other']); + expect(model.series[1].points.map((p) => p.axis)).toEqual([0, 1, 2]); + expect(model.series[1].points[2].r).toBeNull(); + expect(model.series[1].color).toContain('--warning-color'); + }); +}); + +describe('radar chart', () => { + it('draws a filled shape with a marker per value, round labelled axes', async () => { + const card = await mount({ max: 2000 }); + expect(qa(card, 'polygon.grid')).toHaveLength(4); + expect(qa(card, 'line.spoke')).toHaveLength(3); + const area = q(card, 'polygon.area')!; + expect(area.classList.contains('filled')).toBe(true); + expect(area.getAttribute('stroke')).toContain('--primary-color'); + expect(qa(card, '.marker.entity').map((m) => m.getAttribute('data-index'))).toEqual(['0', '1', '2']); + expect(qa(card, '.axis-label').map((t) => t.textContent!.replace(/\s+/g, ' ').trim())).toEqual([ + 'Lounge temp 21.0 °C', + 'Lounge CO2 640.0', + 'Lounge humidity 48.0', + ]); + expect(q(card, 'svg.radar-plot')!.getAttribute('aria-label')).toBe( + 'Radar chart of Lounge temp, Lounge CO2, Lounge humidity', + ); + expect(q(card, '.radar-legend')).toBeNull(); + }); + + it('draws outlines only when fill is off, and hides names when asked', async () => { + const card = await mount({ fill: false, show: { name: false } }); + expect(q(card, 'polygon.area')!.classList.contains('filled')).toBe(false); + expect(q(card, 'polygon.area')!.getAttribute('fill')).toBe('none'); + expect(q(card, '.axis-label')!.textContent!.trim()).toBe('21.0 °C'); + }); + + it('draws one shape per area with a legend, skipping the missing value', async () => { + const card = await mount({ entities: ALL, group_by: ['area'], scale: 'shared' }); + expect(qa(card, 'polygon.area')).toHaveLength(2); + expect(qa(card, '.marker')).toHaveLength(5); + expect(qa(card, '.radar-legend-item').map((e) => e.textContent!.trim())).toEqual(['Lounge', 'Bedroom']); + expect(qa(card, '.axis-label').map((e) => e.textContent!.trim())).toEqual(['Temperature', 'CO₂', 'Humidity']); + }); + + it('keeps going with two axes, unavailable and missing entities', async () => { + const card = await mount({ entities: ['sensor.bed_hum', 'sensor.missing'] }); + expect(qa(card, 'circle.grid')).toHaveLength(4); + expect(q(card, 'polygon.area')).toBeNull(); + expect(qa(card, '.marker')).toHaveLength(0); + expect(qa(card, '.axis-value.muted')).toHaveLength(2); + }); + + it('opens more-info from a marker', async () => { + const card = await mount({}); + const fired: string[] = []; + card.addEventListener('hass-action', (e) => fired.push((e as CustomEvent).detail.config.entity)); + const marker = qa(card, '.marker')[1] as HTMLElement; + marker.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true })); + marker.dispatchEvent(new MouseEvent('pointerup', { bubbles: true })); + expect(fired).toEqual(['sensor.lounge_co2']); + }); + + it('checks its options', () => { + const card = document.createElement('chartdeck-card') as Card; + const set = (extra: Record) => () => + card.setConfig({ type: 'x', style: 'radar', entities: ['sensor.a'], ...extra } as CardConfig); + expect(set({ scale: 'log' })).toThrow(/"scale" must be one of: limits, shared/); + expect(set({ fill: 'yes' })).toThrow(/fill/); + expect(set({ group_by: 'room' })).toThrow(/group_by/); + expect(set({ entities: [{ entity: 'sensor.a', axis: 3 }] })).toThrow(/axis/); + expect(set({ scale: 'shared', fill: false, group_by: ['area'] })).not.toThrow(); + }); +}); + +describe('radar editor', () => { + it('writes scale, fill, grouping, and each entity’s group and axis', 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 = air(); + const raw = { + type: 'custom:chartdeck-card', + style: 'radar', + group_by: 'area', + entities: ALL.slice(0, 2), + } as CardConfig; + ed.setConfig(raw); + document.body.appendChild(ed); + await ed.updateComplete; + const [form, groups, axes] = [...ed.shadowRoot!.querySelectorAll('.section-chart ha-form')] as Stub[]; + expect(form.data).toEqual({ radar_scale: 'limits', radar_fill: true, group_by: ['area'] }); + form.dispatchEvent( + new CustomEvent('value-changed', { + detail: { value: { ...(form.data as object), radar_scale: 'shared', radar_fill: false } }, + }), + ); + expect(events.at(-1)).toEqual({ ...raw, group_by: ['area'], scale: 'shared', fill: false }); + expect(groups.data).toEqual({ e0: undefined, e1: undefined }); + expect((groups.computeLabel as (s: { name: string }) => string)({ name: 'e1' })).toBe('Lounge CO2'); + groups.dispatchEvent(new CustomEvent('value-changed', { detail: { value: { e0: 'Spare', e1: '' } } })); + expect(events.at(-1)!.entities).toEqual([{ entity: 'sensor.lounge_temp', group: 'Spare' }, 'sensor.lounge_co2']); + axes.dispatchEvent(new CustomEvent('value-changed', { detail: { value: { e1: 'Air' } } })); + expect(events.at(-1)!.entities).toEqual(['sensor.lounge_temp', { entity: 'sensor.lounge_co2', axis: 'Air' }]); + }); + + it('says to add an entity first when there are none', async () => { + const ed = document.createElement('chartdeck-card-editor') as HTMLElement & { + setConfig(c: CardConfig): void; + hass?: unknown; + updateComplete: Promise; + }; + ed.hass = air(); + ed.setConfig({ type: 'custom:chartdeck-card', style: 'radar' } as CardConfig); + document.body.appendChild(ed); + await ed.updateComplete; + expect(ed.shadowRoot!.querySelector('.entity-options')!.textContent).toContain('Add an entity above first.'); + }); +}); diff --git a/test/range-area.test.ts b/test/range-area.test.ts new file mode 100644 index 0000000..663539b --- /dev/null +++ b/test/range-area.test.ts @@ -0,0 +1,140 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import '../src/chartdeck-card'; +import { groupHistory, groupStatistics, statisticsQueryFor } from '../src/history/group'; +import { bandPath, bandRuns, rangeRaw } from '../src/styles/range-area'; +import type { CardConfig } from '../src/types'; +import { H, ago, chartHass, mountCard, mountEditor, pointAt, q, qa, tooltipRows } from './helpers/chart'; +import type { Card } from './helpers/chart'; + +const now = Date.now(); + +const states = { + 'sensor.out': ['9', { friendly_name: 'Outside', unit_of_measurement: '°C' }], + 'sensor.in': ['21', { friendly_name: 'Inside', unit_of_measurement: '°C' }], +} as const; + +/** Hourly statistics for the last day: mean 10 + i/4, spread ±3; a gap three hours long. */ +const hourly = Array.from({ length: 24 }, (_, i) => { + const gap = i >= 10 && i < 13; + const mean = 10 + i / 4; + return { start: now - (24 - i) * H, mean: gap ? null : mean, min: gap ? null : mean - 3, max: gap ? null : mean + 3 }; +}); + +const history = { + 'sensor.in': [ + { s: '18', lu: ago(now, 40 * H) }, + { s: '22', lu: ago(now, 30 * H) }, + { s: '20', lu: ago(now, 20 * H) }, + ], +}; + +const mount = (config: Record, hass = chartHass(states, history, { 'sensor.out': hourly })) => + mountCard({ style: 'range_area', entities: ['sensor.out'], ...config } as Partial, hass); + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('range area maths', () => { + it('reads statistics unless told otherwise, and groups history so it has a spread', () => { + const raw = { type: 'x' } as CardConfig; + expect(rangeRaw(raw).source).toBe('statistics'); + expect(rangeRaw(raw).group_by).toBeUndefined(); + expect(rangeRaw({ ...raw, source: 'history' }).group_by).toBe('hour'); + expect(rangeRaw({ ...raw, source: 'history', period: { last: 7, unit: 'day' } }).group_by).toBe('day'); + expect(rangeRaw({ ...raw, source: 'history', period: { calendar: 'day' } }).group_by).toBe('hour'); + expect(rangeRaw({ ...raw, source: 'history', group_by: 'week' }).group_by).toBe('week'); + }); + + it('splits at gaps and closes the band back along the lows', () => { + const runs = bandRuns([ + { t: 0, v: 5, min: 2, max: 8 }, + { t: 1, v: 6 }, + { t: 2, v: null }, + { t: 3, v: 4, min: 1, max: 9 }, + ]); + expect(runs).toHaveLength(2); + expect(runs[0][1]).toEqual({ t: 1, v: 6, lo: 6, hi: 6 }); + const highs: [number, number][] = [ + [0, 10], + [10, 20], + ]; + const lows: [number, number][] = [ + [0, 50], + [10, 60], + ]; + expect(bandPath(highs, lows, 'straight')).toBe('M0,10L10,20L10,60L0,50Z'); + expect(bandPath(highs, lows, 'step')).toBe('M0,10L10,10L10,20L10,60L10,50L0,50Z'); + expect(bandPath([[0, 1]], [[0, 2]], 'smooth')).toBe(''); + }); + + it('keeps each group’s spread when asked', () => { + const pts = [ + { t: 0, v: 5 }, + { t: 10, v: 9 }, + ]; + expect(groupHistory(pts, [0, 20], 'mean', 20, true)[0]).toMatchObject({ min: 5, max: 9 }); + const rows = [{ t: 0, v: 5, min: 1, max: 7 }]; + expect(groupStatistics(rows, [0, 20], 'mean', Infinity, true)[0]).toMatchObject({ min: 1, max: 7 }); + const zone = { timeZone: 'UTC', firstDay: 1 }; + expect(statisticsQueryFor({ unit: 'day', aggregate: 'max' }, zone, 'UTC', true).types).toEqual(['max', 'min']); + }); +}); + +describe('range area chart', () => { + it('shades lowest to highest from statistics, broken at a gap, with the average through it', async () => { + const hass = chartHass(states, history, { 'sensor.out': hourly }); + const card = await mount({}, hass); + const band = q(card, 'path.band')!; + expect(band.getAttribute('d')!.match(/M/g)).toHaveLength(2); + expect(band.getAttribute('d')).toMatch(/Z$/); + expect(q(card, 'path.line')).not.toBeNull(); + expect(q(card, 'svg.plot')!.getAttribute('aria-label')).toBe('Range area chart of Outside'); + const msg = (hass.callWS as unknown as { mock: { calls: [{ type: string; types: string[] }][] } }).mock.calls[0][0]; + expect(msg.type).toBe('recorder/statistics_during_period'); + expect(msg.types).toEqual(expect.arrayContaining(['min', 'max'])); + pointAt(q(card, '.plot-area')!, 'pointermove', 0.25); + await card.updateComplete; + expect(tooltipRows(card)[0]).toMatch(/^Outside \d+\.\d – \d+\.\d °C · \d+\.\d$/); + expect(qa(card, '.hover-dot')).toHaveLength(1); + }); + + it('can leave the average out', async () => { + const card = await mount({ mean: false, curve: 'step' }); + expect(q(card, 'path.line')).toBeNull(); + pointAt(q(card, '.plot-area')!, 'pointermove', 0.25); + await card.updateComplete; + expect(tooltipRows(card)[0]).not.toContain('·'); + expect(qa(card, '.hover-dot')).toHaveLength(0); + }); + + it('groups history to get a spread, and says when there is nothing', async () => { + const card = await mount({ source: 'history', entities: ['sensor.in'], period: { last: 2, unit: 'day' } }); + expect(q(card, 'path.band')!.getAttribute('d')).toMatch(/Z/); + const empty = await mount({ entities: ['sensor.missing'] }, chartHass(states, {}, {})); + 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: 'range_area', entities: ['sensor.out'], ...extra } as CardConfig); + expect(set({ mean: 'yes' })).toThrow(/mean/); + expect(set({ source: 'cloud' })).toThrow(/source/); + expect(set({ mean: false, group_by: 'day' })).not.toThrow(); + }); +}); + +describe('range area editor', () => { + it('defaults to statistics and edits the average line', async () => { + const raw = { type: 'custom:chartdeck-card', style: 'range_area', entities: ['sensor.out'] } as CardConfig; + const { ed, form, change, events, entityFields } = await mountEditor(raw, chartHass(states, {})); + expect(form().data).toMatchObject({ source: 'statistics', mean: true, curve: 'smooth' }); + change({ mean: false, source: 'history', group_by: 'day' }); + expect(events.at(-1)).toEqual({ ...raw, mean: false, source: 'history', group_by: 'day' }); + await ed.updateComplete; + change({ source: 'statistics' }); + expect(events.at(-1)).not.toHaveProperty('source'); + expect(entityFields().fields.map((f) => f.name)).toEqual(['range_color', 'curve', 'dash']); + }); +}); diff --git a/test/rings.test.ts b/test/rings.test.ts index f58f096..1f962e2 100644 --- a/test/rings.test.ts +++ b/test/rings.test.ts @@ -193,3 +193,17 @@ describe('rings editor', () => { expect((changed.mock.calls[0][0] as CustomEvent).detail.config.end_angle).toBe(360); }); }); + +describe('rings and pie keep their own center option', () => { + it('is not checked against the gauge centre modes', async () => { + const { normalizeConfig } = await import('../src/config'); + for (const style of ['rings', 'pie'] as const) { + expect(() => + normalizeConfig({ type: 'x', style, center: 'total', entities: ['sensor.a'] } as never), + ).not.toThrow(); + } + expect(() => + normalizeConfig({ type: 'x', style: 'ring', center: 'total', entities: ['sensor.a'] } as never), + ).toThrow(/center/); + }); +}); diff --git a/test/sankey.test.ts b/test/sankey.test.ts new file mode 100644 index 0000000..070e566 --- /dev/null +++ b/test/sankey.test.ts @@ -0,0 +1,411 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import '../src/chartdeck-card'; +import { + acceptFlows, + findCycle, + fitText, + flowValue, + linkPath, + nodeColumns, + sankeyLayout, + spreadLabels, + textWidth, +} from '../src/chart/sankey'; +import type { Flow } from '../src/chart/sankey'; +import { drawSankey, sankeyColors, validateSankey } from '../src/styles/sankey'; +import { STYLES, isChart } from '../src/styles/index'; +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 flow = (index: number, from: string, to: string, value: number | null): Flow => ({ index, from, to, value }); + +/** The mockup's house: solar and grid in, battery and export out of solar, circuits out of the house. */ +const HOUSE: Flow[] = [ + flow(0, 'Solar', 'House', 3), + flow(1, 'Solar', 'Battery', 0.4), + flow(2, 'Solar', 'Export', 0.7), + flow(3, 'Grid', 'House', 0.9), + flow(4, 'House', 'Heat pump', 1.6), + flow(5, 'House', 'EV', 1.2), + flow(6, 'House', 'Kitchen', 0.7), + flow(7, 'House', 'Other', 0.4), +]; + +const OPTS = { width: 400, height: 200, left: 50, right: 60, nodeWidth: 10, gap: 10 }; + +describe('sankey maths', () => { + it('counts nothing flowing as 0', () => { + expect([flowValue(2), flowValue(0), flowValue(-3), flowValue(null), flowValue(NaN)]).toEqual([2, 0, 0, 0, 0]); + }); + + it('leaves out flows without both ends, to themselves, or closing a loop', () => { + const kept = acceptFlows([ + { from: 'A', to: 'B' }, + { from: '', to: 'B' }, + { from: 'B', to: 'B' }, + { from: 'B', to: 'C' }, + { from: 'C', to: 'A' }, + { from: 'A', to: 'C' }, + ]); + expect(kept.map((f) => `${f.from}${f.to}`)).toEqual(['AB', 'BC', 'AC']); + }); + + it('finds a loop in from/to pairs', () => { + expect( + findCycle([ + ['A', 'B'], + ['B', 'C'], + ]), + ).toBeUndefined(); + expect( + findCycle([ + ['A', 'B'], + ['B', 'C'], + ['C', 'A'], + ]), + ).toEqual(['A', 'B', 'C', 'A']); + expect( + findCycle([ + ['X', 'Y'], + ['A', 'B'], + ['B', 'A'], + ]), + ).toEqual(['A', 'B', 'A']); + expect(findCycle([['A', 'A']])).toEqual(['A', 'A']); + }); + + it('puts each node in the column of its longest path from a source', () => { + const cols = nodeColumns(HOUSE); + expect(Object.fromEntries(cols)).toEqual({ + Solar: 0, + Grid: 0, + House: 1, + Battery: 1, + Export: 1, + 'Heat pump': 2, + EV: 2, + Kitchen: 2, + Other: 2, + }); + // A shortcut doesn't pull a node back: C is reached through B as well. + expect(nodeColumns([flow(0, 'A', 'C', 1), flow(1, 'A', 'B', 1), flow(2, 'B', 'C', 1)]).get('C')).toBe(2); + }); + + it('values each node at the larger of what comes in and what goes out', () => { + const { nodes, columns } = sankeyLayout(HOUSE, OPTS); + expect(columns).toBe(3); + const house = nodes.find((n) => n.name === 'House')!; + expect(house.in).toBeCloseTo(3.9); + expect(house.out).toBeCloseTo(3.9); + expect(nodes.find((n) => n.name === 'Solar')!.value).toBeCloseTo(4.1); + expect(nodes.find((n) => n.name === 'Other')!.value).toBeCloseTo(0.4); + const lop = sankeyLayout([flow(0, 'A', 'B', 5), flow(1, 'B', 'C', 2)], OPTS).nodes.find((n) => n.name === 'B')!; + expect(lop.value).toBe(5); + }); + + it('scales heights by the fullest column, centres columns and spaces them across the width', () => { + const { nodes } = sankeyLayout(HOUSE, OPTS); + const col = (c: number) => nodes.filter((n) => n.column === c); + // Middle column: 3.9 + 0.4 + 0.7 = 5, with two gaps; it fills the height exactly. + const mid = col(1); + const scale = (200 - 20) / 5; + expect(mid[0].y).toBeCloseTo(0); + expect(mid[0].h).toBeCloseTo(3.9 * scale); + expect(mid[2].y + mid[2].h).toBeCloseTo(200); + // Left column: 5.0 tall too, with one gap, so it sits centred. + const left = col(0); + expect(left[0].y).toBeCloseTo(5); + expect(left.map((n) => n.x)).toEqual([50, 50]); + expect(mid[0].x).toBe(50 + (400 - 50 - 60 - 10) / 2); + expect(col(2)[0].x).toBe(400 - 60 - 10); + }); + + it('stacks links at each end in the order of the nodes at their other end', () => { + const { links, nodes } = sankeyLayout(HOUSE, OPTS); + const solar = nodes.find((n) => n.name === 'Solar')!; + const fromSolar = links.filter((l) => l.source === solar).sort((a, b) => a.y0 - b.y0); + expect(fromSolar.map((l) => l.target.name)).toEqual(['House', 'Battery', 'Export']); + expect(fromSolar[0].y0).toBeCloseTo(solar.y + fromSolar[0].width / 2); + expect(fromSolar[1].y0).toBeCloseTo(solar.y + fromSolar[0].width + fromSolar[1].width / 2); + const house = nodes.find((n) => n.name === 'House')!; + const intoHouse = links.filter((l) => l.target === house).sort((a, b) => a.y1 - b.y1); + expect(intoHouse.map((l) => l.source.name)).toEqual(['Solar', 'Grid']); + expect(intoHouse[1].y1).toBeCloseTo(house.y + intoHouse[0].width + intoHouse[1].width / 2); + const w = fromSolar[0].width / 2; + const d = linkPath(fromSolar[0], 10); + expect(d.startsWith(`M60,${Math.round((fromSolar[0].y0 - w) * 10) / 10}C125,`)).toBe(true); + expect(d).toMatch(/ 190,[\d.]+L190,[\d.]+C.* 60,[\d.]+Z$/); + }); + + it('drops zero, negative and missing flows, and closes up an emptied column', () => { + const { nodes, links, columns } = sankeyLayout( + [flow(0, 'A', 'B', 0), flow(1, 'B', 'C', 2), flow(2, 'B', 'D', -1), flow(3, 'B', 'E', null)], + OPTS, + ); + expect(links.map((l) => l.flow.index)).toEqual([1]); + expect(nodes.map((n) => [n.name, n.column])).toEqual([ + ['B', 0], + ['C', 1], + ]); + expect(columns).toBe(2); + }); + + it('ignores a flow that would make a loop instead of failing', () => { + const { links } = sankeyLayout([flow(0, 'A', 'B', 1), flow(1, 'B', 'A', 1)], OPTS); + expect(links.map((l) => l.flow.index)).toEqual([0]); + expect(sankeyLayout([], OPTS)).toEqual({ nodes: [], links: [], columns: 0 }); + }); + + it('leaves room above a node when asked', () => { + const { nodes } = sankeyLayout([flow(0, 'A', 'B', 1), flow(1, 'A', 'C', 1)], { + ...OPTS, + above: (n) => (n.name === 'C' ? 20 : 0), + }); + const [b, c] = nodes.filter((n) => n.column === 1); + expect(c.y - (b.y + b.h)).toBeCloseTo(10 + 20); + }); + + it('fits and spreads labels', () => { + expect(textWidth('abcd', 10)).toBeCloseTo(23.2); + expect(fitText('Heat pump', 100, 12)).toBe('Heat pump'); + expect(fitText('Heat pump', 30, 12)).toBe('Hea…'); + expect(fitText('Heat pump', 5, 12)).toBe(''); + expect(spreadLabels([50, 55, 200], 20, 300)).toEqual([50, 70, 200]); + expect(spreadLabels([290, 295], 20, 300)).toEqual([270, 290]); + expect(spreadLabels([0], 20, 300)).toEqual([10]); + }); +}); + +describe('sankey drawing', () => { + const format = (v: number) => `${v.toFixed(1)} kW`; + + it('puts labels left, right and above the middle node, and cuts long names on a narrow card', () => { + const d = drawSankey(HOUSE, 500, { name: true, value: true, format }); + const side = Object.fromEntries(d.labels.map((l) => [l.node.name, l.side])); + expect(side).toMatchObject({ Solar: 'left', House: 'above', Battery: 'right', 'Heat pump': 'right' }); + expect(d.labels.find((l) => l.node.name === 'House')!.value).toBe('3.9 kW'); + expect(d.height).toBeGreaterThanOrEqual(200); + const long = [flow(0, 'A very long source name indeed', 'Somewhere far away from here', 1)]; + const narrow = drawSankey(long, 240, { name: true, value: true, format }); + const names = narrow.labels.map((l) => l.name); + expect(names.every((n) => n.endsWith('…'))).toBe(true); + for (const l of narrow.labels) expect(textWidth(l.name, narrow.font)).toBeLessThanOrEqual(240 * 0.3); + }); + + it('draws no labels when names and values are hidden', () => { + const d = drawSankey(HOUSE, 500, { name: false, value: false, format }); + expect(d.labels).toEqual([]); + expect(d.layout.nodes[0].x).toBe(2); + }); + + it('colours nodes in order, and links by source or, out of a middle node, by target', () => { + const { node, link } = sankeyColors(HOUSE, 'auto', () => undefined); + expect(node.get('Solar')).toContain('--primary-color'); + expect(node.get('House')).toContain('--warning-color'); + expect(link.get(0)).toBe(node.get('Solar')); + expect(link.get(4)).toBe(node.get('Heat pump')); + expect(sankeyColors(HOUSE, 'source', () => undefined).link.get(4)).toBe(node.get('House')); + expect(sankeyColors(HOUSE, 'target', () => undefined).link.get(0)).toBe(node.get('House')); + }); + + it("uses a flow's own colour for its link and the node it is coloured by", () => { + const { node, link } = sankeyColors(HOUSE, 'auto', (i) => (i === 4 ? 'red' : i === 0 ? '#123456' : undefined)); + expect(link.get(4)).toContain('--error-color'); + expect(node.get('Heat pump')).toContain('--error-color'); + expect(node.get('Solar')).toBe('#123456'); + expect(link.get(1)).toBe(node.get('Solar')); + expect(link.get(2)).toBe(node.get('Solar')); + }); +}); + +describe('sankey validation', () => { + const v = (raw: Partial) => () => validateSankey({ type: 'x', ...raw } as CardConfig); + + it('accepts flows and the link colour choices', () => { + expect( + v({ + link_color: 'target', + entities: ['sensor.a', { entity: 'sensor.b', from: 'A', to: 'B' }, { entity: 'sensor.c', from: 'B' }], + }), + ).not.toThrow(); + }); + + it('explains what is wrong', () => { + expect(v({ link_color: 'rainbow' })).toThrow('"link_color" must be one of: auto, source, target'); + expect(v({ entities: [{ entity: 'sensor.a', from: 3 }] as CardConfig['entities'] })).toThrow( + 'Entity 1: "from" must be a node name', + ); + expect(v({ entities: [{ entity: 'sensor.a', from: 'A', to: ' A ' }] as CardConfig['entities'] })).toThrow( + 'Entity 1: "from" and "to" must be different nodes', + ); + expect( + v({ + entities: [ + { entity: 'sensor.a', from: 'A', to: 'B' }, + { entity: 'sensor.b', from: 'B', to: 'A' }, + ] as CardConfig['entities'], + }), + ).toThrow("Flows can't go round in a loop: A → B → A"); + }); + + it('is checked when the card is set up', () => { + const card = document.createElement('chartdeck-card') as HTMLElement & { setConfig(c: CardConfig): void }; + expect(() => card.setConfig({ type: 'x', style: 'sankey', link_color: 'x' } as CardConfig)).toThrow(/link_color/); + }); +}); + +type Card = HTMLElement & { setConfig(c: CardConfig): void; hass?: HomeAssistant; updateComplete: Promise }; + +const hass = (overrides: Record = {}) => + makeHass({ + 'sensor.solar': [overrides.solar ?? '3', { friendly_name: 'Solar to house', unit_of_measurement: 'kW' }], + 'sensor.grid': [overrides.grid ?? '0.9', { friendly_name: 'Grid import', unit_of_measurement: 'kW' }], + 'sensor.heat': [overrides.heat ?? '1.6', { friendly_name: 'Heat pump', unit_of_measurement: 'kW' }], + 'sensor.off': ['unavailable', { friendly_name: 'Wind' }], + }); + +const ENTITIES = [ + { entity: 'sensor.solar', from: 'Solar', to: 'House' }, + { entity: 'sensor.grid', from: 'Grid', to: 'House', color: 'purple' }, + { entity: 'sensor.heat', from: 'House', to: 'Heat pump' }, + { entity: 'sensor.off', from: 'Wind', to: 'House' }, + { entity: 'sensor.missing', from: 'Battery', to: 'House' }, +]; + +async function mount(config: Partial, h = hass()) { + const card = document.createElement('chartdeck-card') as Card; + card.setConfig({ type: 'custom:chartdeck-card', style: 'sankey', entities: ENTITIES, ...config } as CardConfig); + card.hass = h; + document.body.appendChild(card); + 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('sankey card', () => { + it('is a chart in the parts-of-a-total group', () => { + expect(isChart(STYLES.sankey)).toBe(true); + expect(STYLES.sankey.group).toBe('totals'); + }); + + it('draws a band per flow that has something flowing, each one opening its entity', async () => { + const card = await mount({}); + const flows = qa(card, 'g.flow.entity'); + expect(flows.map((f) => f.getAttribute('data-index'))).toEqual(['0', '1', '2']); + expect(flows[0].getAttribute('tabindex')).toBe('0'); + expect(flows[0].querySelector('title')!.textContent).toBe('Solar to House 3.0 kW'); + expect(flows[1].querySelector('path')!.getAttribute('fill')).toContain('--purple-color'); + // Out of the middle node, the band takes the heat pump's colour. + const heatNode = qa(card, 'rect.node')[3]; + expect(flows[2].querySelector('path')!.getAttribute('fill')).toBe(heatNode.getAttribute('fill')); + expect(qa(card, 'rect.node')).toHaveLength(4); + const svg = q(card, 'svg.sankey')!; + expect(svg.getAttribute('role')).toBe('img'); + expect(svg.getAttribute('aria-label')).toBe( + 'Flow chart: Solar to House 3.0 kW, Grid to House 0.9 kW, House to Heat pump 1.6 kW', + ); + const labels = qa(card, 'text.label').map((t) => t.textContent!.replace(/\s+/g, ' ').trim()); + expect(labels).toEqual(['Solar 3.0 kW', 'House 3.9 kW', 'Grid 0.9 kW', 'Heat pump 1.6 kW']); + }); + + it('raises a band and shows its value while it is hovered or focused', async () => { + const card = await mount({}); + const flow = qa(card, 'g.flow')[2]; + flow.dispatchEvent(new Event('pointerenter')); + await card.updateComplete; + expect(q(card, 'svg.sankey')!.classList.contains('hovering')).toBe(true); + expect(qa(card, 'g.flow')[2].classList.contains('active')).toBe(true); + expect(q(card, '.sankey-tip')!.textContent!.replace(/\s+/g, ' ').trim()).toBe('House → Heat pump 1.6 kW'); + flow.dispatchEvent(new Event('pointerleave')); + await card.updateComplete; + expect(q(card, '.sankey-tip')).toBeNull(); + flow.dispatchEvent(new Event('focus')); + await card.updateComplete; + expect(q(card, '.sankey-tip')).not.toBeNull(); + flow.dispatchEvent(new Event('blur')); + await card.updateComplete; + expect(q(card, '.sankey-tip')).toBeNull(); + }); + + it('treats a negative flow as nothing, and says so when nothing flows at all', async () => { + const card = await mount({}, hass({ heat: '-2' })); + expect(qa(card, 'g.flow')).toHaveLength(2); + document.body.innerHTML = ''; + const idle = await mount({}, hass({ solar: '0', grid: '-1', heat: '0' })); + expect(q(idle, '.chart-message')!.textContent).toBe('Nothing is flowing right now.'); + }); + + it('asks for from and to when no entity has them', async () => { + const card = await mount({ entities: ['sensor.solar'] }); + expect(q(card, '.chart-message')!.textContent).toContain('"Flows from"'); + }); + + it('hides names or values with the Display switches', async () => { + const card = await mount({ show: { value: false } } as Partial); + expect(qa(card, 'text.label tspan.value')).toHaveLength(0); + expect(qa(card, 'text.label tspan.name')).toHaveLength(4); + }); +}); + +describe('sankey editor', () => { + async function editor(config: Partial) { + const ed = document.createElement('chartdeck-card-editor') as HTMLElement & { + setConfig(c: CardConfig): void; + hass?: unknown; + updateComplete: Promise; + }; + ed.hass = makeHass({}); + ed.setConfig({ type: 'custom:chartdeck-card', style: 'sankey', ...config } as CardConfig); + document.body.appendChild(ed); + await ed.updateComplete; + return ed; + } + + it('sets the link colouring in the Chart section, dropping the default', async () => { + const ed = await editor({ entities: ENTITIES } as Partial); + const form = ed.shadowRoot!.querySelector('.section-chart ha-form') as Stub; + expect((form.schema as { name: string }[]).map((s) => s.name)).toEqual(['link_color']); + expect(form.data).toEqual({ link_color: 'auto' }); + const changed: CardConfig[] = []; + ed.addEventListener('config-changed', (ev) => changed.push((ev as CustomEvent).detail.config)); + form.dispatchEvent(new CustomEvent('value-changed', { detail: { value: { link_color: 'target' } } })); + expect(changed.at(-1)!.link_color).toBe('target'); + ed.setConfig(changed.at(-1)!); + await ed.updateComplete; + form.dispatchEvent(new CustomEvent('value-changed', { detail: { value: { link_color: 'auto' } } })); + expect('link_color' in changed.at(-1)!).toBe(false); + }); + + it("puts from, to and the flow's colour in each entity's options", async () => { + const ed = await editor({ entities: ENTITIES } as Partial); + const list = ed.shadowRoot!.querySelector('cd-entities-editor') as Stub; + const fields = list.chartFields as { schema: unknown[]; fields: { name: string; path: string[] }[] }; + expect(fields.fields.map((f) => [f.name, f.path.join('.')])).toEqual([ + ['from', 'from'], + ['to', 'to'], + ['flow_color', 'color'], + ]); + }); +}); diff --git a/test/slope.test.ts b/test/slope.test.ts new file mode 100644 index 0000000..989f651 --- /dev/null +++ b/test/slope.test.ts @@ -0,0 +1,308 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import '../src/chartdeck-card'; +import { directionColor, slopeEnds, spreadLabels } from '../src/styles/slope'; +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; + +describe('slope maths', () => { + it('spreads labels apart, keeping order and staying in bounds', () => { + expect(spreadLabels([50, 52, 100], 10, 0, 200)).toEqual([50, 60, 100]); + // Input order is kept in the output; ties keep their input order. + expect(spreadLabels([52, 50], 10, 0, 200)).toEqual([60, 50]); + expect(spreadLabels([10, 10], 10, 0, 200)).toEqual([10, 20]); + // Pushed past the bottom: the stack moves up. + expect(spreadLabels([195, 198, 199], 10, 0, 200)).toEqual([180, 190, 200]); + // Below the top edge: clamped. + expect(spreadLabels([-20], 10, 0, 200)).toEqual([0]); + // More than fit: start at the top and run on. + expect(spreadLabels([5, 5, 5], 10, 0, 15)).toEqual([0, 10, 20]); + expect(spreadLabels([], 10, 0, 100)).toEqual([]); + }); + + it('takes the reading then and now, or the rise over each period', () => { + const points = [ + { t: 0, v: 10 }, + { t: 50, v: 16 }, + { t: 70, v: null }, + ]; + const times = { start: 0, middle: 60 }; + expect(slopeEnds('value', points, times, 30)).toEqual([10, 30]); + expect(slopeEnds('value', [], times, 30)).toEqual([null, 30]); + expect(slopeEnds('change', points, times, 30)).toEqual([6, 14]); + expect(slopeEnds('change', points, times, null)).toEqual([6, null]); + expect(slopeEnds('change', points, { start: -5, middle: 60 }, 30)).toEqual([null, 14]); + }); + + it('colours by direction', () => { + expect(directionColor(1, 2, 'bad')).toBe('red'); + expect(directionColor(2, 1, 'bad')).toBe('green'); + expect(directionColor(1, 2, 'good')).toBe('green'); + expect(directionColor(2, 1, 'good')).toBe('red'); + expect(directionColor(2, 2, 'good')).toBe('grey'); + }); +}); + +const history = { + 'sensor.a': [ + { s: '10', lu: sec(1700 * H) }, + { s: '10', lu: sec(30 * H) }, + { s: '16', lu: sec(18 * H) }, + ], + 'sensor.b': [{ s: '50', lu: sec(1700 * H) }], + 'sensor.u': [{ s: '5', lu: sec(30 * H) }], +}; + +function hassWith(data: Record = history, fail = false) { + const hass = makeHass({ + 'sensor.a': ['30', { friendly_name: 'Alpha', unit_of_measurement: 'kWh' }], + 'sensor.b': ['40', { friendly_name: 'Beta', unit_of_measurement: 'kWh' }], + 'sensor.u': ['unavailable', { friendly_name: 'Gone quiet', unit_of_measurement: 'kWh' }], + }); + hass.callWS = vi.fn(async () => { + if (fail) throw new Error('boom'); + return 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: 'slope', + entities: ['sensor.a', 'sensor.b'], + ...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 text = (el: Element | null) => el!.textContent!.replace(/\s+/g, ' ').trim(); +/** A label's parts (name, value, change), spaced the way the flex gap shows them. */ +const lbl = (el: Element | null) => [...el!.children].map((c) => c.textContent!.trim()).join(' '); + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('slope chart', () => { + it('joins the reading a period ago to now, coloured by which way it went', async () => { + const card = await mount({}); + const entities = qa(card, '.slope-plot .entity'); + expect(entities.map((e) => e.getAttribute('data-index'))).toEqual(['0', '1']); + const [a, b] = entities.map((e) => e.querySelector('line')!); + expect(a.getAttribute('stroke')).toContain('--error-color'); + expect(b.getAttribute('stroke')).toContain('--success-color'); + expect(Number(a.getAttribute('y1'))).toBeGreaterThan(Number(a.getAttribute('y2'))); + expect(lbl(entities[0].querySelector('.lbl.from'))).toBe('Alpha 10.0'); + expect(lbl(entities[0].querySelector('.lbl.to'))).toBe('30.0 Alpha +200 %'); + expect(lbl(entities[1].querySelector('.lbl.to'))).toBe('40.0 Beta −20 %'); + expect(entities[0].getAttribute('aria-label')).toBe('Alpha, 10.0 to 30.0'); + expect(text(q(card, '.slope-head'))).toBe('24 hours ago kWh Now'); + expect(q(card, '.slope-plot')!.getAttribute('aria-label')).toBe('Slope chart of Alpha, Beta'); + expect(q(card, '.slope-legend')).toBeNull(); + }); + + it('turns the colours round when a rise is good, and hides names or values when asked', async () => { + const card = await mount({ increase: 'good', show: { name: false }, period: { last: 1, unit: 'week' } }); + const [a, b] = qa(card, '.slope-plot line'); + expect(a.getAttribute('stroke')).toContain('--success-color'); + expect(b.getAttribute('stroke')).toContain('--error-color'); + expect(qa(card, '.lbl .name')).toHaveLength(0); + expect(text(q(card, '.slope-head .from'))).toBe('A week ago'); + const plain = await mount({ show: { value: false } }); + expect(qa(plain, '.lbl .value')).toHaveLength(0); + expect(qa(plain, '.lbl .pct')).toHaveLength(0); + }); + + it('colours by group, sharing a colour within a group, with a legend', async () => { + const card = await mount({ + color_by: 'group', + entities: [ + { entity: 'sensor.a', group: 'Kitchen' }, + { entity: 'sensor.b', group: 'Kitchen' }, + { entity: 'sensor.u', group: 'Garage', color: '#123456' }, + ], + } as Partial); + const [a, b] = qa(card, '.slope-plot line'); + expect(a.getAttribute('stroke')).toBe(b.getAttribute('stroke')); + expect(a.getAttribute('stroke')).toContain('--primary-color'); + expect(qa(card, '.slope-legend-item').map(text)).toEqual(['Kitchen', 'Garage']); + // Ungrouped entities each get their own colour and no legend. + const solo = await mount({ color_by: 'group' }); + const [x, y] = qa(solo, '.slope-plot line'); + expect(x.getAttribute('stroke')).not.toBe(y.getAttribute('stroke')); + expect(q(solo, '.slope-legend')).toBeNull(); + }); + + it('compares the rise over the previous period with the latest one', async () => { + const card = await mount({ measure: 'change', period: { last: 12, unit: 'hour' } }); + const first = q(card, '.slope-plot .entity')!; + // 10 → 16 in the previous 12 hours, 16 → 30 in the last 12. + expect(lbl(first.querySelector('.lbl.from'))).toBe('Alpha 6.0'); + expect(lbl(first.querySelector('.lbl.to'))).toBe('14.0 Alpha +133 %'); + expect(text(q(card, '.slope-head'))).toBe('Previous 12 hours kWh Last 12 hours'); + const call = (card.hass!.callWS as ReturnType).mock.calls[0][0]; + expect(Date.parse(call.end_time) - Date.parse(call.start_time)).toBeCloseTo(24 * H, -4); + const monthly = await mount({ measure: 'change', period: { last: 1, unit: 'month' } }); + expect(text(q(monthly, '.slope-head'))).toBe('Previous month kWh Last month'); + }); + + it('draws a lone dot for an entity without a reading at one end, and leaves out ones with none', async () => { + const card = await mount({ entities: ['sensor.a', 'sensor.u', 'sensor.missing'] }); + const entities = qa(card, '.slope-plot .entity'); + expect(entities.map((e) => e.getAttribute('data-index'))).toEqual(['0', '1']); + const quiet = entities[1]; + expect(quiet.querySelector('line')).toBeNull(); + expect(quiet.querySelector('.dot.from')).not.toBeNull(); + expect(quiet.querySelector('.dot.to')).toBeNull(); + expect(quiet.getAttribute('aria-label')).toBe('Gone quiet, 5.0 to no reading'); + }); + + it('adds units to values when the entities differ', async () => { + const hass = hassWith(); + hass.states['sensor.b'].attributes.unit_of_measurement = 'W'; + const card = await mount({}, hass); + expect(q(card, '.slope-head .unit')).toBeNull(); + expect(lbl(q(card, '.lbl.from'))).toBe('Alpha 10.0 kWh'); + }); + + it('says when history is loading, failed, or empty', async () => { + const card = document.createElement('chartdeck-card') as Card; + card.setConfig({ type: 'custom:chartdeck-card', style: 'slope', entities: ['sensor.a'] } as CardConfig); + card.hass = hassWith(); + document.body.appendChild(card); + await card.updateComplete; + expect(text(q(card, '.chart-message'))).toBe('Loading history…'); + const failed = await mount({}, hassWith(history, true)); + expect(text(q(failed, '.chart-message.error'))).toBe("Couldn't load history: boom"); + const empty = await mount({ entities: ['sensor.u', 'sensor.missing'] }, hassWith({})); + expect(text(q(empty, '.chart-message'))).toBe('No readings in this period.'); + }); + + it('runs the entity actions from any part of a slope', async () => { + const card = await mount({}); + const fired: string[] = []; + card.addEventListener('hass-action', (e) => fired.push((e as CustomEvent).detail.config.entity)); + const dot = qa(card, '.slope-plot .entity')[1].querySelector('.dot.to') as HTMLElement; + dot.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true })); + dot.dispatchEvent(new MouseEvent('pointerup', { bubbles: true })); + expect(fired).toEqual(['sensor.b']); + }); + + it('checks its options', () => { + const card = document.createElement('chartdeck-card') as Card; + const set = (extra: Record) => () => + card.setConfig({ type: 'x', style: 'slope', entities: ['sensor.a'], ...extra } as CardConfig); + expect(set({ measure: 'ratio' })).toThrow(/"measure" must be one of: value, change/); + expect(set({ color_by: 'series' })).toThrow(/"color_by" must be one of: direction, group/); + expect(set({ increase: 'meh' })).toThrow(/"increase"/); + expect(set({ entities: [{ entity: 'sensor.a', group: 3 }] })).toThrow(/"group" must be text/); + expect(set({ period: { last: 0 } })).toThrow(/period/); + expect(set({ measure: 'change', color_by: 'group', increase: 'good' })).not.toThrow(); + }); +}); + +describe('slope chart editor', () => { + async function editor(raw: CardConfig) { + 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({}); + ed.setConfig(raw); + document.body.appendChild(ed); + await ed.updateComplete; + return { ed, events }; + } + + it('writes period, measure and colouring from the Chart section', async () => { + const raw = { type: 'custom:chartdeck-card', style: 'slope', entities: ['sensor.a'] } as CardConfig; + const { ed, events } = await editor(raw); + const form = ed.shadowRoot!.querySelector('.section-chart ha-form') as Stub; + expect(form.data).toEqual({ + period_last: 24, + period_unit: 'hour', + source: 'history', + measure: 'value', + color_by: 'direction', + increase: 'bad', + }); + const names = JSON.stringify(form.schema); + for (const name of ['measure', 'color_by', 'increase', 'period_last']) expect(names).toContain(`"${name}"`); + form.dispatchEvent( + new CustomEvent('value-changed', { + detail: { value: { ...(form.data as object), measure: 'change', color_by: 'group', period_unit: 'month' } }, + }), + ); + expect(events.at(-1)).toEqual({ ...raw, measure: 'change', color_by: 'group', period: { unit: 'month' } }); + }); + + it("puts each entity's group in its own options", async () => { + const raw = { + type: 'custom:chartdeck-card', + style: 'slope', + entities: [{ entity: 'sensor.a', group: 'Kitchen' }], + } as CardConfig; + const { ed, events } = await editor(raw); + const list = ed.shadowRoot!.querySelector('cd-entities-editor') as HTMLElement & { + updateComplete: Promise; + shadowRoot: ShadowRoot; + }; + await list.updateComplete; + (list.shadowRoot.querySelector('.expand') as HTMLElement).click(); + await list.updateComplete; + const form = list.shadowRoot.querySelector('ha-form[data-section="entity-chart"]') as Stub; + expect(form).not.toBeNull(); + expect(form.data).toEqual({ group: 'Kitchen' }); + form.dispatchEvent(new CustomEvent('value-changed', { detail: { value: { group: 'Garage' } } })); + expect(events.at(-1)!.entities).toEqual([{ entity: 'sensor.a', group: 'Garage' }]); + }); + + it('shows no per-entity chart options for a style without any', async () => { + const { ed } = await editor({ type: 'custom:chartdeck-card', style: 'bar', entities: ['sensor.a'] } as CardConfig); + const list = ed.shadowRoot!.querySelector('cd-entities-editor') as Stub; + expect(list.chartFields).toBeUndefined(); + }); + + it('lets the first statistics row stand in for the start when it begins just after it', () => { + const points = [ + { t: 1_800_000, v: 10 }, + { t: 5_400_000, v: 12 }, + ]; + expect(slopeEnds('value', points, { start: 0, middle: 0 }, 20)).toEqual([null, 20]); + expect(slopeEnds('value', points, { start: 0, middle: 0 }, 20, 3_600_000)).toEqual([10, 20]); + expect(slopeEnds('value', points, { start: 0, middle: 0 }, 20, 1_000_000)).toEqual([null, 20]); + }); +}); diff --git a/test/sparkline.test.ts b/test/sparkline.test.ts new file mode 100644 index 0000000..e9a4a9f --- /dev/null +++ b/test/sparkline.test.ts @@ -0,0 +1,147 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import '../src/chartdeck-card'; +import { sparkDomain, sparkKind, sparkRaw } from '../src/styles/sparkline'; +import type { ResolvedEntity } from '../src/resolve'; +import type { CardConfig } from '../src/types'; +import { H, ago, chartHass, mountCard, mountEditor, q, qa } from './helpers/chart'; +import type { Card } from './helpers/chart'; + +const now = Date.now(); +const sec = (ms: number) => ago(now, ms); + +const states = { + 'sensor.temp': ['21', { friendly_name: 'Temp', unit_of_measurement: '°C' }], + 'sensor.power': ['-200', { friendly_name: 'Power', unit_of_measurement: 'W' }], + 'sensor.off': ['unavailable', { friendly_name: 'Off' }], +} as const; + +const history = { + 'sensor.temp': [ + { s: '18', lu: sec(20 * H) }, + { s: '22', lu: sec(12 * H) }, + { s: 'unavailable', lu: sec(8 * H) }, + { s: '21', lu: sec(4 * H) }, + ], + 'sensor.power': [ + { s: '400', lu: sec(20 * H) }, + { s: '-600', lu: sec(10 * H) }, + ], +}; + +const mount = (config: Record, hass = chartHass(states, history)) => + mountCard({ style: 'sparkline', entities: ['sensor.temp', 'sensor.power'], ...config } as Partial, hass); + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('sparkline maths', () => { + it('takes the kind from the entity, then the card, then a line', () => { + const e = (spark?: string) => ({ config: { entity: 'x', spark } }) as unknown as ResolvedEntity; + const raw = { type: 'x', spark: 'area' } as CardConfig; + expect(sparkKind(e('column'), raw)).toBe('column'); + expect(sparkKind(e(), raw)).toBe('area'); + expect(sparkKind(e(), { type: 'x' })).toBe('line'); + }); + + it('groups column sparklines that are not grouped', () => { + const raw = { type: 'x' } as CardConfig; + expect(sparkRaw(raw, ['line'])).toBe(raw); + expect(sparkRaw(raw, ['column'])).toMatchObject({ group_by: 'hour', aggregate: 'mean' }); + expect(sparkRaw({ ...raw, period: { last: 7, unit: 'day' } }, ['column']).group_by).toBe('day'); + expect(sparkRaw({ ...raw, period: { calendar: 'day' } }, ['column']).group_by).toBe('hour'); + expect(sparkRaw({ ...raw, group_by: 'week' }, ['column']).group_by).toBe('week'); + expect(sparkRaw({ ...raw, aggregate: 'max' }, ['column']).aggregate).toBe('max'); + }); + + it('fits a sparkline to its own readings, with zero in for columns', () => { + expect(sparkDomain([], false)).toBeUndefined(); + const [lo, hi] = sparkDomain([10, 20], false)!; + expect(lo).toBeLessThan(10); + expect(hi).toBeGreaterThan(20); + expect(sparkDomain([10, 20], true)).toEqual([0, 20]); + expect(sparkDomain([0], true)).toEqual([0, 1]); + }); +}); + +describe('sparklines', () => { + it('draws a row per entity: name, a tiny line broken at gaps, and the value', async () => { + const card = await mount({}); + const rows = qa(card, '.spark-row'); + expect(rows).toHaveLength(2); + expect(rows[0].getAttribute('data-index')).toBe('0'); + expect(rows[0].querySelector('.name')!.textContent).toBe('Temp'); + expect(rows[0].querySelector('.value')!.textContent).toBe('21.0 °C'); + expect(rows[0].querySelector('.spark-line')!.getAttribute('d')!.match(/M/g)).toHaveLength(2); + expect(rows[0].querySelector('svg')!.getAttribute('aria-label')).toBe('Trend of Temp'); + expect(q(card, '.axis-label')).toBeNull(); + expect(card.hass!.callWS).toHaveBeenCalledTimes(1); + }); + + it('fills areas and draws columns, per card or per entity', async () => { + const card = await mount({ spark: 'area', entities: ['sensor.temp', { entity: 'sensor.power', spark: 'column' }] }); + expect(qa(card, '.spark-fill')).toHaveLength(1); + expect(qa(card, '.spark-columns rect').length).toBeGreaterThan(5); + // The power column sparkline goes below zero. + const rects = qa(card, '.spark-columns rect'); + expect(new Set(rects.map((r) => r.getAttribute('y'))).size).toBeGreaterThan(1); + }); + + it('puts the 1.0 thin bar under the name and value, with the target', async () => { + const card = await mount({ + bar: true, + min: 0, + max: 40, + target: 20, + entities: [{ entity: 'sensor.temp', color: 'red' }], + }); + const row = q(card, '.spark-row')!; + expect(row.classList.contains('with-bar')).toBe(true); + expect(row.querySelector('.spark-body .top .value')!.textContent).toBe('21.0 °C'); + expect(row.querySelector('.spark-track')!.getAttribute('style')).toContain('--end:0.525'); + expect(row.querySelector('.spark-target')).not.toBeNull(); + expect(row.querySelector('.spark-line')!.getAttribute('stroke')).toContain('--error-color'); + }); + + it('keeps a row for unavailable and missing entities, and says when loading failed', async () => { + const card = await mount({ entities: ['sensor.off', 'sensor.nope'] }); + const rows = qa(card, '.spark-row'); + expect(rows).toHaveLength(2); + expect(rows[0].classList.contains('unavailable')).toBe(true); + expect(rows[0].querySelector('.spark-line')).toBeNull(); + const failed = await mount({}, chartHass(states, history, {}, true)); + expect(q(failed, '.spark-message.error')!.textContent).toContain('boom'); + }); + + it('opens more-info from a row', async () => { + const card = await mount({}); + const fired: string[] = []; + card.addEventListener('hass-action', (e) => fired.push((e as CustomEvent).detail.config.entity)); + const row = qa(card, '.spark-row')[1] as HTMLElement; + row.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true })); + row.dispatchEvent(new MouseEvent('pointerup', { bubbles: true })); + expect(fired).toEqual(['sensor.power']); + }); + + it('checks its options', () => { + const card = document.createElement('chartdeck-card') as Card; + const set = (extra: Record) => () => + card.setConfig({ type: 'x', style: 'sparkline', entities: ['sensor.temp'], ...extra } as CardConfig); + expect(set({ spark: 'pie' })).toThrow(/spark/); + expect(set({ entities: [{ entity: 'sensor.temp', spark: 'pie' }] })).toThrow(/spark/); + expect(set({ bar: 'yes' })).toThrow(/bar/); + expect(set({ line_width: 40 })).toThrow(/line_width/); + expect(set({ spark: 'column', bar: true, line_width: 2 })).not.toThrow(); + }); +}); + +describe('sparkline editor', () => { + it('edits the kind, shape, width and bar, and a kind and colour per entity', async () => { + const raw = { type: 'custom:chartdeck-card', style: 'sparkline', entities: ['sensor.temp'] } as CardConfig; + const { form, change, events, entityFields } = await mountEditor(raw, chartHass(states, {})); + expect(form().data).toMatchObject({ spark: 'line', curve: 'smooth', line_width: 1.5, spark_bar: false }); + change({ spark: 'column', spark_bar: true }); + expect(events.at(-1)).toEqual({ ...raw, spark: 'column', bar: true }); + expect(entityFields().fields.map((f) => f.name)).toEqual(['spark', 'spark_color']); + }); +}); diff --git a/test/streamgraph.test.ts b/test/streamgraph.test.ts new file mode 100644 index 0000000..1cba887 --- /dev/null +++ b/test/streamgraph.test.ts @@ -0,0 +1,344 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import '../src/chartdeck-card'; +import { + baseline, + binSeries, + insideOut, + layersExtent, + placeLabels, + sampleBounds, + stack, + stackable, + toShares, + widest, +} from '../src/chart/stream'; +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'); + +describe('stream maths', () => { + it('cuts the range into even bins up to now', () => { + expect(sampleBounds({ start: 0, end: 100 }, 1000, 4)).toEqual([0, 25, 50, 75, 100]); + expect(sampleBounds({ start: 0, end: 100 }, 50, 2)).toEqual([0, 25, 50]); + // Not begun yet: one bin, so nothing divides by zero. + expect(sampleBounds({ start: 100, end: 200 }, 50, 3)).toHaveLength(4); + }); + + it('bins history by time-weighted mean, statistics by the mean of their rows', () => { + const history = [ + { t: 0, v: 10 }, + { t: 90, v: 0 }, + { t: 100, v: null }, + ]; + expect(binSeries(history, [0, 100, 200], 'history', 200)).toEqual([9, null]); + const rows = [ + { t: 0, v: 2 }, + { t: 50, v: 4 }, + { t: 100, v: 6 }, + ]; + expect(binSeries(rows, [0, 100, 200], 'statistics', 200)).toEqual([3, 6]); + }); + + it('stacks gaps and negative readings as nothing', () => { + expect(stackable([1, null, -2, Number.NaN, 3])).toEqual([1, 0, 0, 0, 3]); + }); + + it('orders inside out: early peaks first, each to the lighter side', () => { + const values = [ + [5, 1, 1], + [1, 5, 1], + [1, 1, 5], + [9, 9, 9], + ]; + // By peak: 0, 3 (a tie keeps the listed order), 1, 2; each goes to the lighter side. + const order = insideOut(values); + expect(order).toHaveLength(4); + expect([...order].sort()).toEqual([0, 1, 2, 3]); + expect(order).toEqual([2, 1, 0, 3]); + }); + + it('puts the baseline at zero, centred, or where the layers wiggle least', () => { + const rows = [ + [1, 2], + [3, 4], + ]; + expect(baseline(rows, 'zero')).toEqual([0, 0]); + expect(baseline(rows, 'share')).toEqual([0, 0]); + expect(baseline(rows, 'centre')).toEqual([-2, -3]); + // One flat layer: no wiggle to remove, and it is centred on zero. + expect(baseline([[2, 2, 2]], 'wiggle')).toEqual([-1, -1, -1]); + // A layer growing under a flat one: the baseline moves down so the flat one stays flatter. + const g = baseline( + [ + [0, 4], + [2, 2], + ], + 'wiggle', + ); + expect(g[1]).toBeLessThan(g[0]); + expect(baseline([], 'wiggle')).toEqual([]); + }); + + it('turns columns into shares, leaving empty columns empty', () => { + expect( + toShares([ + [1, 0], + [3, 0], + ]), + ).toEqual([ + [25, 0], + [75, 0], + ]); + }); + + it('stacks layers in order, returned in series order', () => { + const layers = stack( + [ + [1, 1], + [2, 2], + ], + [1, 0], + 'zero', + ); + expect(layers[1]).toEqual({ lower: [0, 0], upper: [2, 2] }); + expect(layers[0]).toEqual({ lower: [2, 2], upper: [3, 3] }); + expect(layersExtent(layers)).toEqual([0, 3]); + expect(layersExtent([])).toBeUndefined(); + const shares = stack([[1], [3]], [0, 1], 'share'); + expect(shares[1].upper).toEqual([100]); + expect(widest({ lower: [0, 0, 1], upper: [1, 3, 2] })).toEqual({ at: 1, thickness: 3 }); + }); + + it('places labels thickest first, inside the plot, never overlapping', () => { + const placed = placeLabels( + [ + { key: 0, x: 50, y: 50, width: 60, thickness: 20 }, + { key: 1, x: 60, y: 55, width: 60, thickness: 30 }, + { key: 2, x: 5, y: 10, width: 40, thickness: 20 }, + { key: 3, x: 50, y: 90, width: 500, thickness: 40 }, + ], + 200, + ); + expect(placed.get(1)).toBe(30); + expect(placed.has(0)).toBe(false); + expect(placed.get(2)).toBe(2); + expect(placed.has(3)).toBe(false); + }); +}); + +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; + +function hassWith(history: Record) { + const hass = makeHass({ + 'sensor.heat': ['1200', { friendly_name: 'Heat pump', unit_of_measurement: 'W' }], + 'sensor.ev': ['0', { friendly_name: 'EV', unit_of_measurement: 'W' }], + 'sensor.gone': ['unavailable', { friendly_name: 'Gone', unit_of_measurement: 'W' }], + }); + hass.callWS = vi.fn(async () => history) as HomeAssistant['callWS']; + return hass; +} + +const history = { + 'sensor.heat': [ + { s: '1000', lu: sec(20 * H) }, + { s: '2000', lu: sec(10 * H) }, + { s: '1200', lu: sec(2 * H) }, + ], + 'sensor.ev': [ + { s: '7000', lu: sec(20 * H) }, + { s: '-50', lu: sec(12 * H) }, + { s: 'unavailable', lu: sec(8 * H) }, + { s: '0', lu: sec(6 * H) }, + ], + 'sensor.gone': [{ 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: 'streamgraph', + entities: ['sensor.heat', 'sensor.ev', 'sensor.gone'], + ...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, frac: number) { + el.getBoundingClientRect = () => ({ left: 0, top: 0, width: 1000, height: 160, right: 1000, bottom: 160 }) as DOMRect; + const ev = new MouseEvent(type, { bubbles: true, clientX: frac * 1000 }); + Object.defineProperty(ev, 'pointerType', { value: 'mouse' }); + el.dispatchEvent(ev); +} + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('streamgraph', () => { + it('draws one closed stream per entity with no value labels, an unavailable one flat', async () => { + const card = await mount({}); + const streams = qa(card, 'path.stream'); + expect(streams).toHaveLength(3); + for (const s of streams) expect(s.getAttribute('d')).toMatch(/^M.*Z$/); + expect(streams.find((s) => s.getAttribute('data-index') === '0')!.getAttribute('fill')).toContain( + '--primary-color', + ); + expect(q(card, '.y-axis')).toBeNull(); + expect(q(card, '.x-axis')).not.toBeNull(); + expect(q(card, 'svg.plot')!.getAttribute('aria-label')).toBe('Streamgraph chart of Heat pump, EV, Gone'); + expect(qa(card, '.series-item')).toHaveLength(3); + // The heat pump is the thickest stream: it gets its name on it. + expect(qa(card, '.stream-label').map((l) => l.textContent)[0]).toMatch(/^Heat pump [\d,.]+ W$/); + }); + + it('stacks shares of the total from 0 to 100, and from zero when asked', async () => { + const card = await mount({ baseline: 'share', stream_order: 'listed', labels: false } as Partial); + const ticks = qa(card, '.y-axis .axis-label').map((l) => l.textContent); + expect(ticks[0]).toBe('0'); + expect(ticks.at(-1)).toBe('100'); + expect(q(card, '.stream-label')).toBeNull(); + const zero = await mount({ baseline: 'zero', curve: 'step' } as Partial); + expect(qa(zero, '.y-axis .axis-label')[0].textContent).toBe('0'); + expect(q(zero, 'path.stream')!.getAttribute('d')).toMatch(/H/); + }); + + it('streams grouped readings one value per group', async () => { + const card = await mount({ group_by: 'hour', baseline: 'centre' } as Partial); + expect(qa(card, 'path.stream')).toHaveLength(3); + }); + + it('lists every value, its share and the total on hover', async () => { + const card = await mount({ baseline: 'share' } as Partial); + point(q(card, '.plot-area')!, 'pointermove', 0.3); + await card.updateComplete; + const rows = qa(card, '.tooltip-row').map((r) => r.textContent!.replace(/\s+/g, ' ').trim()); + expect(rows).toHaveLength(4); + expect(rows.find((r) => r.startsWith('EV'))).toMatch(/W · \d+ %$/); + expect(rows.at(-1)).toMatch(/^Total /); + point(q(card, '.plot-area')!, 'pointerleave', 0.1); + await card.updateComplete; + expect(q(card, '.tooltip')).toBeNull(); + }); + + it('opens more-info from a stream', async () => { + const card = await mount({}); + const fired: string[] = []; + card.addEventListener('hass-action', (e) => fired.push((e as CustomEvent).detail.config.entity)); + const stream = q(card, 'path.stream[data-index="1"]')!; + stream.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true })); + stream.dispatchEvent(new MouseEvent('pointerup', { bubbles: true })); + expect(fired).toEqual(['sensor.ev']); + }); + + it('says when there is nothing to draw', async () => { + const card = await mount({}, hassWith({})); + 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: 'streamgraph', entities: ['sensor.heat'], ...extra } as CardConfig); + expect(set({ baseline: 'flat' })).toThrow(/"baseline" must be one of: wiggle, centre, share, zero/); + expect(set({ stream_order: 'random' })).toThrow(/stream_order/); + expect(set({ labels: 'yes' })).toThrow(/labels/); + expect(set({ source: 'cloud' })).toThrow(/source/); + expect(set({ baseline: 'share', stream_order: 'listed', labels: false })).not.toThrow(); + }); +}); + +describe('streamgraph editor', () => { + async function editor(config: Partial) { + 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: 'streamgraph', + entities: ['sensor.heat'], + ...config, + } as CardConfig; + ed.setConfig(raw); + document.body.appendChild(ed); + await ed.updateComplete; + return { ed, events, raw, form: ed.shadowRoot!.querySelector('.section-chart ha-form') as Stub }; + } + + const names = (schema: unknown[]): string[] => + schema.flatMap((s) => { + const item = s as { name: string; schema?: unknown[] }; + return item.schema ? names(item.schema) : [item.name]; + }); + + it('writes the shape, order, edges and labels, and hides the value scale on a floating stream', async () => { + const { ed, events, raw, form } = await editor({}); + expect(form.data).toMatchObject({ + baseline: 'wiggle', + stream_order: 'inside_out', + stream_curve: 'smooth', + stream_labels: true, + }); + expect(names(form.schema as unknown[])).not.toContain('y_axis'); + expect(names(form.schema as unknown[])).not.toContain('y_min'); + form.dispatchEvent( + new CustomEvent('value-changed', { + detail: { + value: { + ...(form.data as object), + baseline: 'share', + stream_order: 'listed', + stream_curve: 'straight', + stream_labels: false, + }, + }, + }), + ); + expect(events.at(-1)).toEqual({ + ...raw, + baseline: 'share', + stream_order: 'listed', + curve: 'straight', + labels: false, + }); + const list = ed.shadowRoot!.querySelector('cd-entities-editor') as Stub; + expect((list.chartFields as { fields: { name: string }[] }).fields.map((f) => f.name)).toEqual(['fill_color']); + }); + + it('shows value labels for a stack from zero', async () => { + const { form } = await editor({ baseline: 'zero' } as Partial); + expect(names(form.schema as unknown[])).toContain('y_axis'); + }); +}); diff --git a/test/sunburst.test.ts b/test/sunburst.test.ts new file mode 100644 index 0000000..0746935 --- /dev/null +++ b/test/sunburst.test.ts @@ -0,0 +1,277 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import '../src/chartdeck-card'; +import { buildTree, partition } from '../src/chart/hierarchy'; +import { normalizeConfig } from '../src/config'; +import { resolveEntities } from '../src/resolve'; +import { levelStrength, paintSpans, sharedUnit, sunburstGeometry } from '../src/styles/sunburst'; +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 }; + +function hass(): HomeAssistant { + const h = makeHass({ + 'sensor.ev': ['1.6', { friendly_name: 'EV', unit_of_measurement: 'kW' }], + 'sensor.oven': ['1', { friendly_name: 'Oven', unit_of_measurement: 'kW' }], + 'sensor.hob': ['0.8', { friendly_name: 'Hob', unit_of_measurement: 'kW' }], + 'sensor.desk': ['0.4', { friendly_name: 'Desk', unit_of_measurement: 'kW' }], + 'sensor.idle': ['0', { friendly_name: 'Idle', unit_of_measurement: 'kW' }], + 'sensor.off': ['unavailable', { friendly_name: 'Fridge', unit_of_measurement: 'kW' }], + }); + Object.assign(h, { + entities: { + 'sensor.oven': { entity_id: 'sensor.oven', area_id: 'kitchen' }, + 'sensor.hob': { entity_id: 'sensor.hob', area_id: 'kitchen' }, + 'sensor.desk': { entity_id: 'sensor.desk', area_id: 'office' }, + }, + areas: { + kitchen: { area_id: 'kitchen', name: 'Kitchen', floor_id: 'ground' }, + office: { area_id: 'office', name: 'Office', floor_id: 'first' }, + }, + floors: { ground: { name: 'Ground' }, first: { name: 'First' } }, + }); + return h; +} + +const ENTITIES = ['sensor.ev', 'sensor.oven', 'sensor.hob', 'sensor.desk']; + +async function mount(config: Partial, h = hass()) { + const card = document.createElement('chartdeck-card') as Card; + card.setConfig({ type: 'custom:chartdeck-card', style: 'sunburst', entities: ENTITIES, ...config } as CardConfig); + card.hass = h; + document.body.appendChild(card); + 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 text = (el: Element | null) => el!.textContent!.replace(/\s+/g, ' ').trim(); + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('sunburst maths', () => { + const items = (entities: CardConfig['entities']) => + resolveEntities(normalizeConfig({ type: 'x', entities } as CardConfig), hass()); + + it('fades deeper levels, never below 45 %', () => { + expect([1, 2, 3, 4, 9].map(levelStrength)).toEqual([100, 80, 60, 45, 45]); + }); + + it('gives each top-level node a series colour that its children fade from', () => { + const list = items(['sensor.ev', 'sensor.oven', { entity: 'sensor.hob', color: 'red' }]); + const root = buildTree(list, [['Out'], ['In'], []], 'All'); + const painted = paintSpans(partition(root), 'group'); + const byName = (name: string) => [...painted.entries()].find(([n]) => n.name === name)![1]; + expect(byName('Out').fill).toBe('var(--primary-color, #2f6fe4)'); + expect(byName('Out').text).toBe('light'); + expect(byName('In').fill).toContain('--warning-color'); + expect(byName('EV').fill).toMatch(/color-mix\(in srgb, var\(--primary-color, #2f6fe4\) 80%/); + expect(byName('EV').text).toBe('theme'); + // An entity at the top level keeps its own colour. + expect(byName('Hob').fill).toContain('--error-color'); + }); + + it('colours entities by band and groups neutral when asked', () => { + const list = items(['sensor.ev', 'sensor.oven']); + const root = buildTree(list, [['G'], ['G']], 'All'); + const painted = paintSpans(partition(root), 'bands'); + const fills = [...painted.entries()].map(([n, p]) => [n.name, p.fill]); + expect(fills).toEqual([ + ['G', 'var(--cd-track)'], + ['EV', 'var(--primary-color, #2f6fe4)'], + ['Oven', 'var(--primary-color, #2f6fe4)'], + ]); + }); + + it('sweeps a full turn or the top half, with rings sharing the radius', () => { + const full = sunburstGeometry('full', 2); + const half = sunburstGeometry('half', 3); + expect([full.start, full.sweep]).toEqual([0, Math.PI * 2]); + expect([half.start, half.sweep]).toEqual([-Math.PI / 2, Math.PI]); + expect(full.radii(1)).toEqual([40, 76]); + expect(full.radii(2)).toEqual([76, 112]); + expect(sunburstGeometry('full', 0).radii(1)).toEqual([40, 112]); + expect(half.viewBox.split(' ').map(Number)[3]).toBeLessThan(Number(full.viewBox.split(' ')[3])); + }); + + it('finds the unit the items with a value share', () => { + expect(sharedUnit(items(['sensor.ev', 'sensor.off']))).toBe('kW'); + expect(sharedUnit(items(['sensor.ev', { entity: 'sensor.oven', unit: 'W' }]))).toBeUndefined(); + }); +}); + +describe('sunburst chart', () => { + it('draws a ring per level, the total in the middle and a legend of the top level', async () => { + const card = await mount({ group_by: ['floor', 'area'] } as Partial); + const groups = qa(card, 'path.arc.group').map((p) => p.dataset.path); + expect(groups).toEqual(['Ground', 'Ground/Kitchen', 'Other', 'Other/Other', 'First', 'First/Office']); + const leaves = qa(card, 'path.arc.leaf'); + expect(leaves.map((p) => p.dataset.index)).toEqual(['1', '2', '0', '3']); + expect(leaves[0].getAttribute('aria-label')).toBe('Oven, 1.0 kW'); + expect(leaves.every((p) => p.classList.contains('entity'))).toBe(true); + expect(text(q(card, '.centre-value'))).toBe('3.8 kW'); + expect(q(card, 'svg.sunburst')!.getAttribute('aria-label')).toBe('Sunburst of Ground, Other, First'); + expect(qa(card, '.hierarchy-legend .legend-item').map(text)).toEqual([ + 'Ground 1.8 kW', + 'Other 1.6 kW', + 'First 0.4 kW', + ]); + expect(q(card, 'path.arc.group title')!.textContent).toBe('Ground, 1.8 kW'); + }); + + it('opens more-info from an arc', async () => { + const card = await mount({}); + const fired: string[] = []; + card.addEventListener('hass-action', (e) => fired.push((e as CustomEvent).detail.config.entity)); + const arc = qa(card, 'path.arc.leaf').find((p) => p.dataset.index === '3')!; + arc.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true })); + arc.dispatchEvent(new MouseEvent('pointerup', { bubbles: true })); + expect(fired).toEqual(['sensor.desk']); + }); + + it('draws a half circle, and flat entities straight round the middle with tappable legend items', async () => { + const card = await mount({ shape: 'half' } as Partial); + expect(q(card, '.sunburst-wrap.half')).not.toBeNull(); + expect(qa(card, 'path.arc.group')).toHaveLength(0); + const legend = qa(card, '.hierarchy-legend .entity'); + expect(legend.map((l) => l.dataset.index)).toEqual(['0', '1', '2', '3']); + }); + + it('lists entities with no reading and says so when nothing has a value', async () => { + const card = await mount({ entities: ['sensor.ev', 'sensor.off', 'sensor.gone', 'sensor.idle'] }); + expect(qa(card, '.hierarchy-missing .entity').map(text)).toEqual([ + 'Fridge (Unavailable)', + 'sensor.gone (Entity not found)', + ]); + const empty = await mount({ entities: ['sensor.idle'] }); + expect(text(q(empty, '.hierarchy-message'))).toMatch(/Nothing to show/); + expect(q(empty, '.hierarchy-legend')).toBeNull(); + }); + + it('leaves totals out for mixed units or when values are hidden', async () => { + const mixed = await mount({ entities: ['sensor.ev', { entity: 'sensor.oven', unit: 'W' }] } as Partial); + expect(q(mixed, '.centre-value')).toBeNull(); + expect(qa(mixed, '.legend-value')).toHaveLength(0); + const quiet = await mount({ show: { value: false } } as Partial); + expect(q(quiet, '.centre-value')).toBeNull(); + }); + + it('checks its options', () => { + const card = document.createElement('chartdeck-card') as Card; + const set = (extra: Record) => () => + card.setConfig({ type: 'x', style: 'sunburst', entities: ['sensor.ev'], ...extra } as CardConfig); + expect(set({ shape: 'square' })).toThrow(/shape/); + expect(set({ color_by: 'mood' })).toThrow(/color_by/); + expect(set({ group_by: ['room'] })).toThrow(/group_by/); + const icicle = () => + card.setConfig({ type: 'x', style: 'icicle', entities: ['sensor.ev'], shape: 'square' } as CardConfig); + expect(icicle).not.toThrow(); + }); +}); + +describe('icicle chart', () => { + it('draws a column per level with the total on the left and labels where they fit', async () => { + const card = await mount({ + style: 'icicle', + group_by: 'area', + entities: [...ENTITIES, 'sensor.off'], + } as Partial); + const cells = qa(card, '.icicle .cell'); + expect(cells[0].classList.contains('text-root')).toBe(true); + expect(text(cells[0])).toBe('Total 3.8 kW'); + expect(cells[0].style.left).toBe('0%'); + const groups = qa(card, '.icicle .cell.group').map((c) => c.dataset.path); + expect(groups).toEqual(['', 'Kitchen', 'Other', 'Office']); + const leaves = qa(card, '.icicle .cell.leaf'); + expect(leaves.map((c) => c.dataset.index)).toEqual(['1', '2', '0', '3']); + expect(leaves[0].style.left).toBe(`${(2 / 3) * 100}%`); + expect(leaves[0].getAttribute('aria-label')).toBe('Oven, 1.0 kW'); + expect(text(leaves[0])).toBe('Oven 1.0 kW'); + expect(q(card, '.icicle')!.getAttribute('aria-label')).toBe('Icicle chart of Kitchen, Other, Office'); + expect(text(q(card, '.hierarchy-missing'))).toBe('No reading: Fridge (Unavailable)'); + }); + + it('colours by band and hides names when asked', async () => { + const card = await mount({ + style: 'icicle', + color_by: 'bands', + show: { name: false }, + severity: [ + { from: 0, color: 'green' }, + { from: 1, color: 'red' }, + ], + } as Partial); + const leaves = qa(card, '.icicle .cell.leaf .cell-fill'); + expect(leaves[0].style.background).toContain('--error-color'); + expect(leaves.at(-1)!.style.background).toContain('--success-color'); + expect(qa(card, '.icicle .cell.leaf .cell-name')).toHaveLength(0); + // The total keeps its name: without it the left column says nothing. + expect(qa(card, '.icicle .text-root .cell-name')).toHaveLength(1); + const empty = await mount({ style: 'icicle', entities: ['sensor.idle'] } as Partial); + expect(q(empty, '.hierarchy-message')).not.toBeNull(); + }); +}); + +describe('sunburst and icicle editors', () => { + async function editor(raw: CardConfig) { + 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 = hass(); + ed.setConfig(raw); + document.body.appendChild(ed); + await ed.updateComplete; + const form = ed.shadowRoot!.querySelector('.section-chart ha-form[data-section="chart"]') as Stub; + return { ed, events, form }; + } + + it('writes the shape, colour and group levels for a sunburst', async () => { + const raw = { type: 'custom:chartdeck-card', style: 'sunburst', entities: ['sensor.ev'] } as CardConfig; + const { events, form, ed } = await editor(raw); + expect(form.data).toEqual({ sunburst_shape: 'full', hierarchy_color_by: 'group', group_by: [], drill_down: true }); + form.dispatchEvent( + new CustomEvent('value-changed', { + detail: { + value: { sunburst_shape: 'half', hierarchy_color_by: 'bands', group_by: ['floor', 'area'], drill_down: true }, + }, + }), + ); + expect(events.at(-1)).toEqual({ ...raw, shape: 'half', color_by: 'bands', group_by: ['floor', 'area'] }); + expect(ed.shadowRoot!.querySelector('.entity-options[data-section="entity_groups"]')).not.toBeNull(); + }); + + it('writes the colour and group levels for an icicle, without a shape', async () => { + const raw = { type: 'custom:chartdeck-card', style: 'icicle', entities: ['sensor.ev'] } as CardConfig; + const { events, form } = await editor(raw); + expect(form.data).toEqual({ hierarchy_color_by: 'group', group_by: [], drill_down: true }); + form.dispatchEvent( + new CustomEvent('value-changed', { + detail: { value: { hierarchy_color_by: 'group', group_by: ['label'], drill_down: false } }, + }), + ); + expect(events.at(-1)).toEqual({ ...raw, group_by: ['label'], drill_down: false }); + }); +}); diff --git a/test/sync.test.ts b/test/sync.test.ts new file mode 100644 index 0000000..2031983 --- /dev/null +++ b/test/sync.test.ts @@ -0,0 +1,191 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import '../src/chartdeck-card'; +import { SYNC_EVENT, onSync, syncGroup, syncSend, validateSyncOptions } from '../src/chart/sync'; +import type { CardConfig, HomeAssistant } from '../src/types'; +import { makeHass } from './helpers/hass'; + +describe('sync options', () => { + it('reads and checks the group name', () => { + expect(syncGroup({ type: 'x', sync: ' house ' } as CardConfig)).toBe('house'); + expect(syncGroup({ type: 'x' } as CardConfig)).toBeUndefined(); + expect(() => validateSyncOptions({ type: 'x', sync: 'a' } as CardConfig)).not.toThrow(); + expect(() => validateSyncOptions({ type: 'x', sync: 3 } as unknown as CardConfig)).toThrow( + '"sync" must be a group name', + ); + expect(() => validateSyncOptions({ type: 'x', sync: ' ' } as CardConfig)).toThrow('"sync" must be a group name'); + }); +}); + +type Card = HTMLElement & { setConfig(c: CardConfig): void; hass?: HomeAssistant; updateComplete: Promise }; +const now = Date.now(); +const H = 3_600_000; +const sec = (ago: number) => (now - ago) / 1000; + +function hass() { + const h = makeHass({ + 'sensor.in': ['21', { friendly_name: 'Inside', unit_of_measurement: '°C' }], + 'sensor.out': ['9', { friendly_name: 'Outside', unit_of_measurement: '°C' }], + }); + h.callWS = vi.fn(async () => ({ + 'sensor.in': [ + { s: '19', lu: sec(20 * H) }, + { s: '21', lu: sec(2 * H) }, + ], + 'sensor.out': [ + { s: '5', lu: sec(20 * H) }, + { s: '9', lu: sec(4 * H) }, + ], + })) as HomeAssistant['callWS']; + return h; +} + +async function mount(extra: Record) { + const card = document.createElement('chartdeck-card') as Card; + card.setConfig({ type: 'custom:chartdeck-card', style: 'line', entities: ['sensor.in'], ...extra } 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) as HTMLElement | null; + +function point(el: Element, type: string, frac: number) { + el.getBoundingClientRect = () => ({ left: 0, top: 0, width: 1000, height: 160, right: 1000, bottom: 160 }) as DOMRect; + const ev = new MouseEvent(type, { bubbles: true, clientX: frac * 1000 }); + Object.defineProperty(ev, 'pointerType', { value: 'mouse' }); + el.dispatchEvent(ev); +} + +const settle = async (...cards: Card[]) => { + for (let i = 0; i < 2; i++) for (const c of cards) await c.updateComplete; +}; + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('synced charts', () => { + it('shows the guide on every chart in the group while the pointer is over one', async () => { + const a = await mount({ sync: 'house', entities: ['sensor.in', 'sensor.out'] }); + const b = await mount({ sync: 'house' }); + const c = await mount({ sync: 'garden' }); + const d = await mount({}); + point(q(a, '.plot-area')!, 'pointermove', 0.5); + await settle(a, b, c, d); + // a snaps to its reading 4 h ago (five sixths across); b's guide is at that same moment. + expect(q(a, '.crosshair')!.getAttribute('style')).toMatch(/left:83.33%/); + expect(q(b, '.crosshair')!.getAttribute('style')).toMatch(/left:83.33%/); + expect(q(b, '.tooltip-value')!.textContent).toBe('21.0 °C'); + expect(q(c, '.crosshair')).toBeNull(); + expect(q(d, '.crosshair')).toBeNull(); + // b's own pointer wins over a's. + point(q(b, '.plot-area')!, 'pointermove', 0.9); + await settle(a, b); + expect(q(b, '.crosshair')!.getAttribute('style')).toMatch(/left:9\d/); + expect(q(a, '.crosshair')!.getAttribute('style')).toMatch(/left:83.33%/); + point(q(b, '.plot-area')!, 'pointerleave', 0.9); + point(q(a, '.plot-area')!, 'pointerleave', 0.5); + await settle(a, b); + expect(q(a, '.crosshair')).toBeNull(); + expect(q(b, '.crosshair')).toBeNull(); + }); + + it('steps charts with the same period together', async () => { + const period = { last: 24, unit: 'hour', navigate: true }; + const a = await mount({ sync: 'house', period }); + const b = await mount({ sync: 'house', period }); + const other = await mount({ sync: 'house', period: { last: 7, unit: 'day', navigate: true } }); + const fixed = await mount({ sync: 'house', period: { last: 24, unit: 'hour' } }); + q(a, '.period-prev')!.click(); + await settle(a, b, other, fixed); + expect(q(a, '.period-return')).not.toBeNull(); + expect(q(b, '.period-return')).not.toBeNull(); + expect(q(other, '.period-return')).toBeNull(); + // b stepping back again moves a with it. + q(b, '.period-prev')!.click(); + await settle(a, b); + expect(q(a, '.period-label')!.textContent).toBe(q(b, '.period-label')!.textContent); + q(a, '.period-return')!.click(); + await settle(a, b); + expect(q(b, '.period-return')).toBeNull(); + }); + + it('passes other messages to a feature that listens, and leaves the group when it should', async () => { + const a = await mount({ sync: 'house' }); + const b = await mount({ sync: 'house' }); + const heard = vi.fn(); + onSync(b, 'zoom', heard); + syncSend(a, { type: 'zoom', period: '{}', from: 0.2, to: 0.4 }); + expect(heard).toHaveBeenCalledWith({ type: 'zoom', period: '{}', from: 0.2, to: 0.4 }); + // Messages from other groups, and stray events, are ignored. + window.dispatchEvent(new CustomEvent(SYNC_EVENT)); + window.dispatchEvent( + new CustomEvent(SYNC_EVENT, { detail: { group: 'other', source: a, message: { type: 'zoom' } } }), + ); + expect(heard).toHaveBeenCalledTimes(1); + // A new group name, then none at all, then leaving the page. + b.setConfig({ + type: 'custom:chartdeck-card', + style: 'line', + entities: ['sensor.in'], + sync: 'garden', + } as CardConfig); + await settle(b); + point(q(a, '.plot-area')!, 'pointermove', 0.5); + await settle(a, b); + expect(q(b, '.crosshair')).toBeNull(); + b.setConfig({ type: 'custom:chartdeck-card', style: 'line', entities: ['sensor.in'] } as CardConfig); + await settle(b); + syncSend(b, { type: 'hover', t: now }); + a.remove(); + point(q(b, '.plot-area')!, 'pointermove', 0.5); + await settle(b); + expect(q(b, '.crosshair')).not.toBeNull(); + }); +}); + +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 {}); +} + +describe('sync in the editor', () => { + it('sets and clears the group name', async () => { + await import('../src/editor/chartdeck-card-editor'); + 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: 'line', 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(JSON.stringify(form.schema)).toContain('"sync"'); + form.dispatchEvent( + new CustomEvent('value-changed', { detail: { value: { ...(form.data as object), sync: 'house' } } }), + ); + expect(events.at(-1)).toEqual({ ...raw, sync: 'house' }); + ed.setConfig({ ...raw, sync: 'house' }); + await ed.updateComplete; + form.dispatchEvent(new CustomEvent('value-changed', { detail: { value: { ...(form.data as object), sync: '' } } })); + expect(events.at(-1)).toEqual(raw); + }); +}); diff --git a/test/timeline.test.ts b/test/timeline.test.ts new file mode 100644 index 0000000..876b3c0 --- /dev/null +++ b/test/timeline.test.ts @@ -0,0 +1,477 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import '../src/chartdeck-card'; +import { + DEFAULT_STATE_COLORS, + formatDuration, + isGap, + isMeasurement, + labelWidth, + spanAt, + spanBox, + stateColor, + stateSpans, + timelineRows, + wordHash, +} from '../src/chart/timeline'; +import { key, loadHistory, parseHistory } from '../src/history/fetch'; +import { HistoryStore } from '../src/history/store'; +import { suggestState, timelineEntityFields, withStateColors } from '../src/editor/timeline-editor'; +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 H = 3_600_000; +const now = Date.now(); +const sec = (msAgo: number) => (now - msAgo) / 1000; + +const pts = (...rows: [number, string | undefined][]) => rows.map(([t, s]) => ({ t, v: null, s })); + +describe('state spans', () => { + it('lasts each state until the next, joins repeats, and stops at the present', () => { + expect(stateSpans(pts([0, 'off'], [10, 'on'], [20, 'on'], [30, 'off'], [50, 'on']), 40)).toEqual([ + { start: 0, end: 10, state: 'off' }, + { start: 10, end: 30, state: 'on' }, + { start: 30, end: 40, state: 'off' }, + ]); + expect(stateSpans(pts([0, 'on'], [0, 'off']), 5)).toEqual([{ start: 0, end: 5, state: 'off' }]); + expect(stateSpans([], 5)).toEqual([]); + }); + + it('finds the span at a time, the last one including its end', () => { + const spans = stateSpans(pts([0, 'off'], [10, 'on']), 20); + expect(spanAt(spans, 5)?.state).toBe('off'); + expect(spanAt(spans, 10)?.state).toBe('on'); + expect(spanAt(spans, 20)?.state).toBe('on'); + expect(spanAt(spans, 25)).toBeUndefined(); + }); + + it('places a span across the range, clamped to it', () => { + expect(spanBox({ start: 0, end: 50, state: 'on' }, { start: 0, end: 100 })).toEqual({ left: 0, width: 0.5 }); + expect(spanBox({ start: -50, end: 150, state: 'on' }, { start: 0, end: 100 })).toEqual({ left: 0, width: 1 }); + expect(spanBox({ start: 0, end: 5, state: 'on' }, { start: 10, end: 10 })).toEqual({ left: 0, width: 0 }); + }); + + it('treats unavailable, unknown and nothing as gaps', () => { + expect(['unavailable', 'unknown', '', undefined].every(isGap)).toBe(true); + expect(isGap('off')).toBe(false); + }); + + it('spots a measurement: many different numbers, and nothing else', () => { + const many = stateSpans( + pts(...Array.from({ length: 12 }, (_, i): [number, string] => [i, String(20 + i / 10)])), + 12, + ); + expect(isMeasurement(many)).toBe(true); + expect(isMeasurement(stateSpans(pts([0, '1'], [1, '2'], [2, '3']), 3))).toBe(false); + expect(isMeasurement([...many, { start: 12, end: 13, state: 'on' }])).toBe(false); + expect(isMeasurement([...many, { start: 12, end: 13, state: 'unavailable' }])).toBe(true); + }); +}); + +describe('state colours', () => { + it('follows the theme for a known state, falling back to its default', () => { + expect(stateColor('heat', 'climate', undefined)).toEqual({ + color: 'var(--state-climate-heat-color, #ff8100)', + quiet: false, + text: 'light', + }); + expect(stateColor('on', 'switch', undefined).color).toBe( + 'var(--state-switch-on-color, var(--warning-color, #f59e0b))', + ); + expect(stateColor('on', 'switch', undefined).text).toBe('dark'); + }); + + it('draws quiet states faint, and the card’s own colours win', () => { + expect(stateColor('off', 'light', undefined)).toMatchObject({ quiet: true, text: 'theme' }); + expect(stateColor('off', 'light', { off: 'red' })).toEqual({ + color: 'var(--error-color, #ef3b5a)', + quiet: false, + text: 'light', + }); + }); + + it('gives an unknown word a stable colour of its own, and skips the theme lookup for odd words', () => { + const a = stateColor('rinse', 'sensor', undefined); + expect(a).toEqual(stateColor('rinse', 'sensor', undefined)); + expect(a.quiet).toBe(false); + expect(wordHash('rinse')).toBe(wordHash('rinse')); + expect(stateColor('Mode 2', 'sensor', undefined).color).not.toContain('--state-'); + expect(Object.keys(DEFAULT_STATE_COLORS)).toContain('home'); + }); +}); + +describe('durations and rows', () => { + it('writes a length of time short', () => { + expect(formatDuration(45_000)).toBe('45 s'); + expect(formatDuration(12 * 60_000)).toBe('12 min'); + expect(formatDuration(3.5 * H)).toBe('3 h 30'); + expect(formatDuration(2 * H)).toBe('2 h'); + expect(formatDuration(52 * H)).toBe('2 d 4 h'); + expect(formatDuration(48 * H)).toBe('2 d'); + expect(formatDuration(-5)).toBe('0 s'); + expect(labelWidth('Heat')).toBeGreaterThan(30); + }); + + it('brings a group together under a heading where it is first listed', () => { + const items = [ + { id: 'a' }, + { id: 'b', g: 'People' }, + { id: 'c' }, + { id: 'd', g: 'People' }, + { id: 'e', g: 'Cars' }, + ]; + const rows = timelineRows(items, (i) => i.g).map((r) => ('heading' in r ? `#${r.heading}` : r.item.id)); + expect(rows).toEqual(['a', '#People', 'b', 'd', 'c', '#Cars', 'e']); + }); +}); + +describe('loading states as words', () => { + it('keeps the words for a text series and numbers for the rest, in one call', async () => { + const hass = makeHass({}); + hass.callWS = vi.fn(async () => ({ + 'switch.a': [ + { s: 'on', lu: 1 }, + { s: 'off', lu: 2 }, + ], + 'sensor.t': [{ s: '21.5', lu: 1 }], + })) as HomeAssistant['callWS']; + const out = await loadHistory(hass, [{ entity: 'switch.a', text: true }, { entity: 'sensor.t' }], { + start: 0, + end: 5000, + }); + expect(hass.callWS).toHaveBeenCalledTimes(1); + expect(out.get('switch.a|text')).toEqual([ + { t: 1000, v: null, s: 'on' }, + { t: 2000, v: null, s: 'off' }, + ]); + expect(out.get('sensor.t')).toEqual([{ t: 1000, v: 21.5 }]); + expect(key({ entity: 'switch.a', text: true })).toBe('switch.a|text'); + // A number series reads on/off as 1/0 (see toReading), a text series keeps the words. + expect(parseHistory([{ s: 'on', lu: 1 }])).toEqual([{ t: 1000, v: 1 }]); + }); + + it('appends live states as words', async () => { + const hass = makeHass({ 'switch.a': 'on' }); + hass.states['switch.a'].last_updated = new Date(now - H).toISOString(); + hass.callWS = vi.fn(async () => ({ 'switch.a': [{ s: 'off', lu: sec(5 * H) }] })) as HomeAssistant['callWS']; + const store = new HistoryStore(() => undefined); + const req = [{ entity: 'switch.a', text: true }]; + const range = { start: now - 24 * H, end: now }; + store.get(hass, req, range, 'history'); + await new Promise((r) => setTimeout(r, 0)); + hass.states = { ...hass.states, 'switch.a': { ...hass.states['switch.a'], state: 'on' } }; + const points = store.get(hass, req, range, 'history').data.get('switch.a|text')!; + expect(points.map((p) => p.s)).toEqual(['off', 'on']); + }); +}); + +/* ------------------------------------------------------------------ card ------------------------------------------------------------------ */ + +const history = { + 'switch.heater': [ + { s: 'off', lu: sec(20 * H) }, + { s: 'on', lu: sec(12 * H) }, + { s: 'off', lu: sec(8 * H) }, + ], + 'person.alex': [ + { s: 'home', lu: sec(30 * H) }, + { s: 'not_home', lu: sec(10 * H) }, + { s: 'unavailable', lu: sec(6 * H) }, + { s: 'home', lu: sec(2 * H) }, + ], + 'sensor.temp': Array.from({ length: 20 }, (_, i) => ({ s: String(18 + i / 10), lu: sec((20 - i) * H) })), +}; + +function hassWith(rows: Record = history, fail = false) { + const hass = makeHass({ + 'switch.heater': ['off', { friendly_name: 'Heater' }], + 'person.alex': ['home', { friendly_name: 'Alex' }], + 'sensor.temp': ['19.9', { friendly_name: 'Temp', unit_of_measurement: '°C' }], + }); + hass.formatEntityState = (s, state) => { + const v = state ?? s.state; + return ( + ({ on: 'On', off: 'Off', home: 'Home', not_home: 'Away', unavailable: 'Unavailable' } as Record)[ + v + ] ?? v + ); + }; + hass.callWS = vi.fn(async () => { + if (fail) throw new Error('boom'); + return rows; + }) 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: 'timeline', + entities: ['switch.heater', 'person.alex'], + ...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 text = (e: Element | null) => e!.textContent!.replace(/\s+/g, ' ').trim(); + +function point(el: Element, type: string, frac: number) { + el.getBoundingClientRect = () => ({ left: 0, top: 0, width: 1000, height: 160, right: 1000, bottom: 160 }) as DOMRect; + const ev = new MouseEvent(type, { bubbles: true, clientX: frac * 1000 }); + Object.defineProperty(ev, 'pointerType', { value: 'mouse' }); + el.dispatchEvent(ev); +} + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('state timeline', () => { + it('draws a row of state bars per entity, with names, a legend and time labels', async () => { + const card = await mount(); + expect(card.hass!.callWS).toHaveBeenCalledTimes(1); + expect(qa(card, '.tl-name').map(text)).toEqual(['Heater', 'Alex']); + const rows = qa(card, '.tl-row'); + expect(rows).toHaveLength(2); + // Heater: off (carried in), on, off. Alex: away, a gap while unavailable, home. + expect(rows[0].querySelectorAll('.tl-bar')).toHaveLength(3); + expect(rows[1].querySelectorAll('.tl-bar')).toHaveLength(3); + const on = rows[0].querySelectorAll('.tl-bar')[1] as HTMLElement; + expect(on.getAttribute('style')).toContain('--state-switch-on-color'); + expect(on.classList.contains('quiet')).toBe(false); + expect(rows[0].querySelector('.tl-bar')!.classList.contains('quiet')).toBe(true); + expect(qa(card, '.tl-key').map(text)).toEqual(['On', 'Home', 'Off', 'Away']); + expect(qa(card, '.x-axis .axis-label').length).toBeGreaterThan(1); + expect(qa(card, '.tl-grid').length).toBeGreaterThan(1); + expect(q(card, '.tl-plot')!.getAttribute('aria-label')).toBe('State timeline chart of Heater, Alex'); + expect(q(card, '.tl-plot')!.getAttribute('role')).toBe('img'); + }); + + it('labels the bars that have room with the state, how long, or both', async () => { + const labels = async (bar_labels?: string) => + Array.from(qa(await mount(bar_labels ? { bar_labels } : {}), '.tl-row')[0].querySelectorAll('.tl-label')).map( + text, + ); + expect(await labels()).toContain('On'); + expect(await labels('duration')).toContain('4 h'); + expect(await labels('both')).toContain('Off · 8 h'); + expect(await labels('none')).toEqual([]); + }); + + it('groups rows under headings, and uses the card’s own colours', async () => { + const card = await mount({ + state_colors: { on: 'red' }, + entities: ['switch.heater', { entity: 'person.alex', group: 'People' }], + } as Partial); + expect(qa(card, '.tl-heading').map(text)).toEqual(['People']); + expect(qa(card, '.tl-gap')).toHaveLength(1); + expect(qa(card, '.tl-bar')[1].getAttribute('style')).toContain('--tl-color:var(--error-color'); + }); + + it('shows a hint for a measurement, and hides names and legend when asked', async () => { + const card = await mount({ + entities: ['switch.heater', 'sensor.temp'], + header: 'hidden', + show: { name: false }, + compact: true, + x_axis: false, + gridlines: { vertical: false }, + } as Partial); + expect(text(q(card, '.tl-hint'))).toBe('Readings, not states: a line chart suits this one'); + expect(q(card, '.tl-legend')).toBeNull(); + expect(q(card, '.tl-names')).toBeNull(); + expect(q(card, '.tl-wrap')!.classList.contains('no-names')).toBe(true); + expect(q(card, '.x-axis')).toBeNull(); + expect(qa(card, '.tl-grid')).toHaveLength(0); + expect(q(card, '.frame.compact')).not.toBeNull(); + }); + + it('marks now when the period runs on past it', async () => { + const card = await mount({ period: { calendar: 'day', until: 'end' } } as Partial); + expect(q(card, '.tl-now')).not.toBeNull(); + const rolling = await mount(); + expect(q(rolling, '.tl-now')).toBeNull(); + }); + + it('shows each row’s state, and for how long, under the pointer', async () => { + const card = await mount(); + const area = q(card, '.tl-plot')!; + expect(area.classList.contains('hoverable')).toBe(true); + // 18 hours ago: the heater was on (12 h to 8 h ago is 4 h), Alex home. + point(area, 'pointermove', 1 - 10 / 24); + await card.updateComplete; + const rows = qa(card, '.tooltip-row').map(text); + expect(rows).toEqual(['Heater On for 4 h', 'Alex Away for 4 h']); + point(area, 'pointermove', 1 - 4 / 24); + await card.updateComplete; + expect(qa(card, '.tooltip-row').map(text)[1]).toBe('Alex Unavailable'); + area.dispatchEvent(Object.assign(new MouseEvent('pointerleave', { bubbles: true }), { pointerType: 'mouse' })); + await card.updateComplete; + expect(q(card, '.tooltip')).toBeNull(); + const off = await mount({ tooltip: false }); + expect(q(off, '.tl-plot')!.classList.contains('hoverable')).toBe(false); + }); + + it('says when history is loading, failed, or holds no states', async () => { + const card = document.createElement('chartdeck-card') as Card; + card.setConfig({ type: 'custom:chartdeck-card', style: 'timeline', entities: ['switch.heater'] } as CardConfig); + card.hass = hassWith(); + document.body.appendChild(card); + await card.updateComplete; + expect(text(q(card, '.chart-message'))).toBe('Loading history…'); + const failed = await mount({}, hassWith(history, true)); + expect(text(q(failed, '.chart-message.error'))).toBe("Couldn't load history: boom"); + const empty = await mount({}, hassWith({ 'switch.heater': [{ s: 'unavailable', lu: sec(H) }] })); + expect(text(q(empty, '.chart-message'))).toBe('No states recorded in this period.'); + }); + + it('opens more-info from a name or a row', async () => { + const card = await mount(); + const fired: string[] = []; + card.addEventListener('hass-action', (e) => fired.push((e as CustomEvent).detail.config.entity)); + for (const el of [qa(card, '.tl-name')[1], qa(card, '.tl-row')[0]] as HTMLElement[]) { + el.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true })); + el.dispatchEvent(new MouseEvent('pointerup', { bubbles: true })); + } + expect(fired).toEqual(['person.alex', 'switch.heater']); + }); + + it('checks its options', () => { + const card = document.createElement('chartdeck-card') as Card; + const set = (extra: Record) => () => + card.setConfig({ type: 'x', style: 'timeline', entities: ['switch.heater'], ...extra } as CardConfig); + expect(set({ source: 'statistics' })).toThrow(/source/); + expect(set({ group_by: 'day' })).toThrow(/group_by/); + expect(set({ bar_labels: 'loud' })).toThrow(/bar_labels/); + expect(set({ x_axis: 'yes' })).toThrow(/x_axis/); + expect(set({ gridlines: 'yes' })).toThrow(/gridlines/); + expect(set({ gridlines: { vertical: 1 } })).toThrow(/gridlines.vertical/); + expect(set({ state_colors: ['red'] })).toThrow(/state_colors/); + expect(set({ state_colors: { on: 3 } })).toThrow(/"on"/); + expect(set({ entities: [{ entity: 'switch.heater', group: 3 }] })).toThrow(/group/); + expect(set({ tooltip: 'no' })).toThrow(/tooltip/); + expect(set({ compact: 'no' })).toThrow(/compact/); + expect(set({ state_colors: { on: 'red' }, bar_labels: 'both', source: 'history' })).not.toThrow(); + }); +}); + +/* ----------------------------------------------------------------- editor ----------------------------------------------------------------- */ + +describe('state timeline editor', () => { + async function editor(config: Partial = {}) { + 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({ 'switch.heater': 'on' }); + const raw = { + type: 'custom:chartdeck-card', + style: 'timeline', + entities: ['switch.heater'], + ...config, + } as CardConfig; + ed.setConfig(raw); + document.body.appendChild(ed); + await ed.updateComplete; + return { ed, events, raw }; + } + + it('writes the Chart section, without source or grouping', async () => { + const { ed, events, raw } = await editor(); + const form = ed.shadowRoot!.querySelector('.section-chart ha-form') as Stub; + expect(form.data).toEqual({ + period_calendar: 'rolling', + period_last: 24, + period_unit: 'hour', + period_offset: 0, + period_until: 'now', + period_navigate: false, + bar_labels: 'state', + x_axis: true, + grid_vertical: true, + tooltip: true, + timeline_legend: 'above', + compact: false, + }); + const names = JSON.stringify(form.schema); + expect(names).not.toContain('"source"'); + expect(names).not.toContain('"group_by"'); + form.dispatchEvent( + new CustomEvent('value-changed', { + detail: { + value: { ...(form.data as object), bar_labels: 'both', grid_vertical: false, timeline_legend: 'hidden' }, + }, + }), + ); + expect(events.at(-1)).toEqual({ ...raw, bar_labels: 'both', gridlines: { vertical: false }, header: 'hidden' }); + const list = ed.shadowRoot!.querySelector('cd-entities-editor') as Stub; + expect((list.chartFields as { fields: { name: string }[] }).fields.map((f) => f.name)).toEqual(['timeline_group']); + }); + + it('adds, renames, recolours and removes state colours', async () => { + const { ed, events, raw } = await editor(); + const section = () => ed.shadowRoot!.querySelector('.state-colors')!; + (section().querySelector('.sc-add') as HTMLElement).click(); + expect(events.at(-1)).toEqual({ ...raw, state_colors: { on: 'primary' } }); + + const { ed: ed2, events: ev2, raw: raw2 } = await editor({ state_colors: { on: 'amber', off: 'grey' } }); + const rows = [...ed2.shadowRoot!.querySelectorAll('.state-colors .sc-row')]; + expect(rows).toHaveLength(2); + const fire = (el: Element, value: string) => + el.dispatchEvent(new CustomEvent('value-changed', { detail: { value }, bubbles: true })); + fire(rows[0].querySelector('[data-role="color"]')!, 'red'); + expect(ev2.at(-1)).toEqual({ ...raw2, state_colors: { on: 'red', off: 'grey' } }); + fire(rows[1].querySelector('[data-role="state"]')!, 'idle'); + expect(ev2.at(-1)).toEqual({ ...raw2, state_colors: { on: 'amber', idle: 'grey' } }); + const count = ev2.length; + fire(rows[1].querySelector('[data-role="color"]')!, ''); + expect(ev2).toHaveLength(count); + (rows[0].querySelector('.sc-remove') as HTMLElement).click(); + expect(ev2.at(-1)).toEqual({ ...raw2, state_colors: { off: 'grey' } }); + }); + + it('suggests a state for a new colour, and drops the key when the list empties', () => { + const hass = makeHass({ 'switch.a': 'on', 'person.b': 'not_home' }); + const config = { type: 'x', entities: ['switch.a', { entity: 'person.b' }] } as CardConfig; + expect(suggestState(config, hass)).toBe('on'); + expect(suggestState({ ...config, state_colors: { on: 'red' } }, hass)).toBe('not_home'); + const all = Object.fromEntries(['on', 'off', 'home', 'not_home', 'heat', 'idle'].map((s) => [s, 'red'])); + expect(suggestState({ type: 'x', state_colors: all }, undefined)).toBe('state_7'); + expect(withStateColors({ type: 'x', state_colors: { on: 'red' } }, [])).toEqual({ type: 'x' }); + expect( + withStateColors({ type: 'x' }, [ + [' on ', 'red'], + ['', 'blue'], + ]), + ).toEqual({ type: 'x', state_colors: { on: 'red' } }); + expect(timelineEntityFields(undefined).schema).toBe(timelineEntityFields(undefined).schema); + }); +}); diff --git a/test/treemap-layout.test.ts b/test/treemap-layout.test.ts new file mode 100644 index 0000000..033df43 --- /dev/null +++ b/test/treemap-layout.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; +import { inset, squarify } from '../src/chart/treemap-layout'; +import type { Rect } from '../src/chart/treemap-layout'; + +const area = (r: Rect) => r.w * r.h; + +describe('squarify', () => { + it('gives each value an area in proportion, filling the rectangle', () => { + const values = [6, 6, 4, 3, 2, 2, 1]; + const rect = { x: 0, y: 0, w: 600, h: 400 }; + const out = squarify(values, rect); + const total = values.reduce((a, b) => a + b, 0); + out.forEach((r, i) => expect(area(r)).toBeCloseTo((values[i] / total) * 600 * 400, 6)); + for (const r of out) { + expect(r.x).toBeGreaterThanOrEqual(-1e-9); + expect(r.y).toBeGreaterThanOrEqual(-1e-9); + expect(r.x + r.w).toBeLessThanOrEqual(600 + 1e-9); + expect(r.y + r.h).toBeLessThanOrEqual(400 + 1e-9); + } + // The classic example's first two land side by side as squares-ish columns. + expect(Math.max(out[0].w / out[0].h, out[0].h / out[0].w)).toBeLessThan(2); + }); + + it('keeps the input order and gives nothing to zero, negative and missing values', () => { + const out = squarify([0, 5, -2, Number.NaN, 5], { x: 10, y: 20, w: 100, h: 50 }); + expect(out).toHaveLength(5); + expect(area(out[0])).toBe(0); + expect(area(out[2])).toBe(0); + expect(area(out[3])).toBe(0); + expect(area(out[1])).toBeCloseTo(2500); + expect(area(out[4])).toBeCloseTo(2500); + expect(out[1].x).toBe(10); + }); + + it('lays rows along the top of a tall rectangle', () => { + const out = squarify([1, 1], { x: 0, y: 0, w: 10, h: 40 }); + expect(out[0]).toEqual({ x: 0, y: 0, w: 10, h: 20 }); + expect(out[1]).toEqual({ x: 0, y: 20, w: 10, h: 20 }); + }); + + it('returns empty rectangles when there is nothing to lay out', () => { + expect(squarify([], { x: 0, y: 0, w: 1, h: 1 })).toEqual([]); + expect(squarify([1], { x: 0, y: 0, w: 0, h: 1 })[0].w).toBe(0); + }); + + it('insets without going negative', () => { + expect(inset({ x: 0, y: 0, w: 10, h: 10 }, 2)).toEqual({ x: 2, y: 2, w: 6, h: 6 }); + expect(inset({ x: 0, y: 0, w: 2, h: 10 }, 4)).toEqual({ x: 1, y: 1, w: 0, h: 8 }); + }); +}); diff --git a/test/treemap.test.ts b/test/treemap.test.ts new file mode 100644 index 0000000..63002d8 --- /dev/null +++ b/test/treemap.test.ts @@ -0,0 +1,279 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import '../src/chartdeck-card'; +import { buildTree } from '../src/chart/hierarchy'; +import { normalizeConfig } from '../src/config'; +import { resolveEntities } from '../src/resolve'; +import { sharedUnit, treemapLayout } from '../src/styles/treemap'; +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 }; + +function hass(): HomeAssistant { + const h = makeHass({ + 'sensor.ev': ['3.6', { friendly_name: 'EV charger', unit_of_measurement: 'kW' }], + 'sensor.oven': ['1.2', { friendly_name: 'Oven', unit_of_measurement: 'kW' }], + 'sensor.kettle': ['0.6', { friendly_name: 'Kettle', unit_of_measurement: 'kW' }], + 'sensor.tv': ['0.5', { friendly_name: 'TV', unit_of_measurement: 'kW' }], + 'sensor.idle': ['0', { friendly_name: 'Idle', unit_of_measurement: 'kW' }], + 'sensor.off': ['unavailable', { friendly_name: 'Fridge', unit_of_measurement: 'kW' }], + }); + Object.assign(h, { + entities: { + 'sensor.ev': { entity_id: 'sensor.ev', area_id: 'garage' }, + 'sensor.oven': { entity_id: 'sensor.oven', area_id: 'kitchen' }, + 'sensor.kettle': { entity_id: 'sensor.kettle', area_id: 'kitchen' }, + }, + areas: { garage: { area_id: 'garage', name: 'Garage' }, kitchen: { area_id: 'kitchen', name: 'Kitchen' } }, + }); + return h; +} + +async function mount(config: Partial, h = hass()) { + const card = document.createElement('chartdeck-card') as Card; + card.setConfig({ + type: 'custom:chartdeck-card', + style: 'treemap', + entities: ['sensor.ev', 'sensor.oven', 'sensor.kettle', 'sensor.tv'], + ...config, + } as CardConfig); + card.hass = h; + document.body.appendChild(card); + 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 text = (el: Element | null) => el!.textContent!.replace(/\s+/g, ' ').trim(); + +afterEach(() => { + document.body.innerHTML = ''; + vi.unstubAllGlobals(); +}); + +describe('treemap layout', () => { + const items = (entities: CardConfig['entities']) => + resolveEntities(normalizeConfig({ type: 'x', entities } as CardConfig), hass()); + + it('sizes groups by their totals and gives groups with room a name strip', () => { + const list = items(['sensor.ev', 'sensor.oven', 'sensor.kettle', 'sensor.idle']); + const root = buildTree(list, [['Garage'], ['Kitchen'], ['Kitchen'], ['Kitchen']], ''); + const { groups, leaves } = treemapLayout(root, 400, 240); + expect(groups.map((g) => [g.node.name, g.series, g.head])).toEqual([ + ['Garage', 0, true], + ['Kitchen', 1, true], + ]); + // The idle entity has nothing to show, so it gets no tile. + expect(leaves.map((l) => [l.node.name, l.series, l.rank])).toEqual([ + ['EV charger', 0, 0], + ['Oven', 1, 0], + ['Kettle', 1, 1], + ]); + const area = (r: { w: number; h: number }) => r.w * r.h; + expect(area(groups[0].rect) / area(groups[1].rect)).toBeCloseTo(2, 0); + // A leaf sits inside its group, below the name strip. + expect(leaves[1].rect.y).toBeGreaterThan(groups[1].rect.y + 18); + }); + + it('lays flat entities straight into the box, one colour each', () => { + const list = items(['sensor.ev', 'sensor.oven']); + const { groups, leaves } = treemapLayout(buildTree(list, [[], []], ''), 300, 100); + expect(groups).toEqual([]); + expect(leaves.map((l) => l.series)).toEqual([0, 1]); + }); + + it('skips the name strip on a group too small for it', () => { + const list = items(['sensor.ev', 'sensor.tv']); + const { groups } = treemapLayout(buildTree(list, [['A'], ['B']], ''), 300, 30); + expect(groups.every((g) => !g.head)).toBe(true); + }); + + it('finds the unit the items with a value share', () => { + expect(sharedUnit(items(['sensor.ev', 'sensor.off']))).toBe('kW'); + expect(sharedUnit(items(['sensor.ev', { entity: 'sensor.tv', unit: 'W' }]))).toBeUndefined(); + }); +}); + +describe('treemap chart', () => { + it('draws one tile per entity with a value, a total, and opens more-info from a tile', async () => { + const card = await mount({}); + const tiles = qa(card, '.treemap .tile.entity'); + expect(tiles.map((t) => t.dataset.index)).toEqual(['0', '1', '2', '3']); + expect(tiles[0].style.background).toContain('--primary-color'); + expect(tiles[1].style.background).toContain('--warning-color'); + expect(tiles[0].getAttribute('aria-label')).toBe('EV charger, 3.6 kW'); + expect(text(q(card, '.treemap-total'))).toBe('5.9 kW in all'); + expect(q(card, '.treemap')!.getAttribute('role')).toBe('img'); + expect(q(card, '.treemap')!.getAttribute('aria-label')).toBe('Treemap of EV charger, Oven, Kettle, TV'); + expect(q(card, '.group')).toBeNull(); + const fired: string[] = []; + card.addEventListener('hass-action', (e) => fired.push((e as CustomEvent).detail.config.entity)); + tiles[2].dispatchEvent(new MouseEvent('pointerdown', { bubbles: true })); + tiles[2].dispatchEvent(new MouseEvent('pointerup', { bubbles: true })); + expect(fired).toEqual(['sensor.kettle']); + }); + + it('groups by area from the registries, with an own group winning and the rest in Other', async () => { + const card = await mount({ + group_by: 'area', + entities: ['sensor.ev', 'sensor.oven', 'sensor.kettle', 'sensor.tv', { entity: 'sensor.idle', group: 'Den' }], + } as Partial); + const names = qa(card, '.group-name').map((g) => g.textContent); + expect(names).toEqual(['Garage', 'Kitchen', 'Other']); + expect(qa(card, '.group-total').map((g) => g.textContent)).toContain('1.8 kW'); + // Kitchen's second tile is a fainter shade of the group's colour. + const kettle = qa(card, '.tile').find((t) => t.dataset.index === '2')!; + expect(kettle.style.background).toContain('color-mix'); + }); + + it('nests two levels from an own group with a slash', async () => { + const card = await mount({ + entities: [ + { entity: 'sensor.oven', group: 'Ground / Kitchen' }, + { entity: 'sensor.kettle', group: 'Ground / Kitchen' }, + { entity: 'sensor.ev', group: 'Outside' }, + ], + } as Partial); + expect(qa(card, '.group-name').map((g) => g.textContent)).toEqual(['Outside', 'Ground', 'Kitchen']); + }); + + it('colours by band, and lists entities with no reading under the map', async () => { + const card = await mount({ + color_by: 'bands', + severity: [ + { from: 0, color: 'green' }, + { from: 1, color: 'red' }, + ], + entities: ['sensor.ev', 'sensor.tv', 'sensor.off', 'sensor.gone'], + } as Partial); + const tiles = qa(card, '.tile'); + expect(tiles[0].style.background).toContain('--error-color'); + expect(tiles[1].style.background).toContain('--success-color'); + const missing = qa(card, '.treemap-missing .entity'); + expect(missing.map((m) => [m.dataset.index, text(m)])).toEqual([ + ['2', 'Fridge (Unavailable)'], + ['3', 'sensor.gone (Entity not found)'], + ]); + }); + + it('says so when nothing has a value, and leaves out the total for mixed units', async () => { + const empty = await mount({ entities: ['sensor.idle', 'sensor.off'] }); + expect(text(q(empty, '.treemap-message'))).toMatch(/Nothing to show/); + const mixed = await mount({ entities: ['sensor.ev', { entity: 'sensor.tv', unit: 'W' }] } as Partial); + expect(q(mixed, '.treemap-total')).toBeNull(); + const quiet = await mount({ show: { value: false, name: false } } as Partial); + expect(q(quiet, '.treemap-total')).toBeNull(); + expect(q(quiet, '.tile-name')).toBeNull(); + expect(q(quiet, '.tile-value')).toBeNull(); + }); + + it('lays out again for the measured width, and stops watching when it leaves', async () => { + let callback: () => void = () => {}; + const disconnect = vi.fn(); + vi.stubGlobal( + 'ResizeObserver', + class { + constructor(cb: () => void) { + callback = cb; + } + observe() {} + disconnect = disconnect; + }, + ); + const card = await mount({}); + const map = q(card, '.treemap') as HTMLElement; + Object.defineProperty(map, 'clientWidth', { value: 800, configurable: true }); + const layout = () => + qa(card, '.tile') + .map((t) => t.getAttribute('style')) + .join('|'); + const before = layout(); + callback(); + await card.updateComplete; + expect(layout()).not.toBe(before); + card.remove(); + expect(disconnect).toHaveBeenCalled(); + }); + + it('checks its options', () => { + const card = document.createElement('chartdeck-card') as Card; + const set = (extra: Record) => () => + card.setConfig({ type: 'x', style: 'treemap', entities: ['sensor.ev'], ...extra } as CardConfig); + expect(set({ color_by: 'mood' })).toThrow(/color_by/); + expect(set({ group_by: 'room' })).toThrow(/group_by/); + expect(set({ entities: [{ entity: 'sensor.ev', group: 4 }] })).toThrow(/group/); + }); +}); + +describe('treemap editor', () => { + async function editor(raw: CardConfig) { + 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 = hass(); + ed.setConfig(raw); + document.body.appendChild(ed); + await ed.updateComplete; + return { ed, events }; + } + + it('writes the colour and the group levels', async () => { + const raw = { + type: 'custom:chartdeck-card', + style: 'treemap', + group_by: 'area', + entities: ['sensor.ev'], + } as CardConfig; + const { ed, events } = await editor(raw); + const form = ed.shadowRoot!.querySelector('.section-chart ha-form[data-section="chart"]') as Stub; + expect(form.data).toEqual({ treemap_color_by: 'group', group_by: ['area'], drill_down: true }); + form.dispatchEvent( + new CustomEvent('value-changed', { + detail: { value: { treemap_color_by: 'bands', group_by: ['area', 'floor'], drill_down: false } }, + }), + ); + expect(events.at(-1)).toEqual({ ...raw, color_by: 'bands', group_by: ['area', 'floor'], drill_down: false }); + }); + + it('sets and clears each listed entity’s own group', async () => { + const raw = { + type: 'custom:chartdeck-card', + style: 'treemap', + entities: ['sensor.ev', { entity: 'sensor.oven', group: 'Kitchen' }], + } as CardConfig; + const { ed, events } = await editor(raw); + const list = ed.shadowRoot!.querySelector('.entity-options[data-section="entity_groups"] ha-form') as Stub & { + computeLabel(s: { name: string }): string; + }; + expect(list.data).toEqual({ e0: undefined, e1: 'Kitchen' }); + expect(list.computeLabel({ name: 'e0' })).toBe('EV charger'); + list.dispatchEvent(new CustomEvent('value-changed', { detail: { value: { e0: 'Garage', e1: '' } } })); + expect(events.at(-1)!.entities).toEqual([{ entity: 'sensor.ev', group: 'Garage' }, 'sensor.oven']); + }); + + it('asks for an entity before offering groups', async () => { + const { ed } = await editor({ type: 'custom:chartdeck-card', style: 'treemap' } as CardConfig); + expect(ed.shadowRoot!.querySelector('.entity-options')!.textContent).toMatch(/Add an entity above first/); + }); +}); diff --git a/test/trend-data.test.ts b/test/trend-data.test.ts new file mode 100644 index 0000000..4db0716 --- /dev/null +++ b/test/trend-data.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from 'vitest'; +import { + bandStops, + bucketBounds, + bucketSeries, + extrema, + parseBound, + placeBuckets, + trendDomain, +} from '../src/chart/trend-data'; + +const H = 3_600_000; + +describe('trend bounds', () => { + it('reads hard and soft bounds, and rejects anything else', () => { + expect(parseBound(undefined, 'y_min')).toBeUndefined(); + expect(parseBound('', 'y_min')).toBeUndefined(); + expect(parseBound(5, 'y_min')).toEqual({ value: 5, soft: false }); + expect(parseBound('5', 'y_min')).toEqual({ value: 5, soft: false }); + expect(parseBound('~-2.5', 'y_min')).toEqual({ value: -2.5, soft: true }); + expect(() => parseBound('~', 'y_min')).toThrow(/"y_min" must be a number/); + expect(() => parseBound('low', 'y_max')).toThrow(/"y_max"/); + expect(() => parseBound(true, 'y_max')).toThrow(/"y_max"/); + }); + + it('pads the readings, and keeps hard bounds exactly', () => { + expect(trendDomain([10, 20])).toEqual([9, 21]); + expect(trendDomain([10, 20], { min: { value: 0, soft: false } })).toEqual([0, 21]); + expect(trendDomain([10, 20], { max: { value: 15, soft: false } })).toEqual([9, 15]); + }); + + it('moves a soft bound only when a reading goes past it', () => { + // ~0 holds: every reading is above it. + expect(trendDomain([10, 20], { min: { value: 0, soft: true } })).toEqual([0, 21]); + // ~15 gives way to the 10 reading below it. + expect(trendDomain([10, 20], { min: { value: 15, soft: true } })).toEqual([9, 21]); + expect(trendDomain([10, 20], { max: { value: 30, soft: true } })).toEqual([9, 30]); + expect(trendDomain([10, 20], { max: { value: 18, soft: true } })).toEqual([9, 21]); + }); + + it('widens a narrow range to min_range about its middle', () => { + const [lo, hi] = trendDomain([18.6, 18.8], { minRange: 4 }); + expect(lo).toBeCloseTo(16.3); + expect(hi).toBeCloseTo(21.1); + }); + + it('stands bars on zero, and never returns an empty range', () => { + expect(trendDomain([10, 20], { fromZero: true })).toEqual([0, 22]); + expect(trendDomain([-20, -10], { fromZero: true })).toEqual([-22, 0]); + expect(trendDomain(undefined)).toEqual([-0.1, 1.1]); + expect(trendDomain([5, 5], { min: { value: 5, soft: false }, max: { value: 5, soft: false } })).toEqual([5, 6]); + }); +}); + +describe('trend buckets', () => { + const range = { start: 0, end: 24 * H }; + + it('splits the range into points_per_hour buckets, capped', () => { + const bounds = bucketBounds(range, 0.5); + expect(bounds).toHaveLength(13); + expect(bounds[1]).toBe(2 * H); + expect(bounds.at(-1)).toBe(24 * H); + expect(bucketBounds(range, 60, 96)).toHaveLength(97); + expect(bucketBounds(range, 0)).toEqual([]); + expect(bucketBounds({ start: 5, end: 5 }, 1)).toEqual([]); + }); + + it('averages each bucket by time, and drops the carried point before a sum', () => { + const points = [ + { t: 0, v: 10 }, + { t: 1.5 * H, v: 20 }, + { t: 4 * H, v: 20 }, + ]; + const bounds = [0, 2 * H, 4 * H]; + const mean = bucketSeries(points, bounds, 'mean', 'history', 4 * H); + expect(mean.map((p) => p.v)).toEqual([12.5, 20]); + const sum = bucketSeries(points, bounds, 'sum', 'history', 4 * H); + expect(sum.map((p) => p.v)).toEqual([30, null]); + expect(bucketSeries(points, [], 'mean', 'history', 4 * H)).toBe(points); + }); + + it('places each bucket at the middle of what has happened, carried out to both edges', () => { + const placed = placeBuckets( + [ + { t: 0, end: 2 * H, v: 1 }, + { t: 2 * H, end: 4 * H, v: 3 }, + { t: 4 * H, end: 6 * H, v: null }, + ], + { start: 0, end: 6 * H }, + 3 * H, + ); + expect(placed).toEqual([ + { t: 0, v: 1, carried: true }, + { t: H, start: 0, end: 2 * H, v: 1 }, + { t: 2.5 * H, start: 2 * H, end: 3 * H, v: 3 }, + { t: 3 * H, v: 3, carried: true }, + ]); + expect(placeBuckets([{ t: 0, end: H, v: null }], { start: 0, end: H }, H)).toEqual([ + { t: H / 2, start: 0, end: H, v: null }, + ]); + }); +}); + +describe('trend colours and extrema', () => { + const bands = [ + { from: -Infinity, color: 'blue' }, + { from: 20, color: 'red' }, + ]; + + it('changes colour at a band start, or blends towards it', () => { + const hard = bandStops(bands, [10, 30], 'hard'); + expect(hard.map((s) => s.offset)).toEqual([0, 0.5, 0.5, 1]); + expect(hard[1].color).toContain('--info-color'); + expect(hard[2].color).toContain('--error-color'); + const smooth = bandStops(bands, [10, 30], 'smooth'); + expect(smooth.map((s) => s.offset)).toEqual([0, 0.5, 1]); + }); + + it('finds the lowest, highest and average, skipping carried edges', () => { + const points = [ + { t: 0, v: 50, carried: true }, + { t: 1, v: 10 }, + { t: 2, v: null }, + { t: 3, v: 30 }, + { t: 5, v: 30, carried: true }, + ]; + expect(extrema(points, false)).toEqual({ min: { t: 1, v: 10 }, max: { t: 3, v: 30 }, mean: 20 }); + // Weighted: 50 held for 1, 10 for 1, 30 for 2; the unavailable stretch doesn't count → 120 / 4. + expect(extrema(points, true)!.mean).toBe(30); + expect(extrema([{ t: 0, v: null }], false)).toBeUndefined(); + }); +}); diff --git a/test/trend.test.ts b/test/trend.test.ts new file mode 100644 index 0000000..387d48e --- /dev/null +++ b/test/trend.test.ts @@ -0,0 +1,300 @@ +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; + getGridOptions(): Record; +}; + +const now = Date.now(); +const sec = (msAgo: number) => (now - msAgo) / 1000; +const H = 3_600_000; + +function hassWith(history: Record, fail = false) { + const hass = makeHass({ + 'sensor.in': ['18.8', { friendly_name: 'Inside', unit_of_measurement: '°C' }], + 'sensor.out': ['9', { friendly_name: 'Outside', unit_of_measurement: '°C' }], + }); + hass.callWS = vi.fn(async () => { + if (fail) throw new Error('boom'); + return history; + }) as HomeAssistant['callWS']; + return hass; +} + +const history = { + 'sensor.in': [ + { s: '17', lu: sec(23 * H) }, + { s: '19', lu: sec(16 * H) }, + { s: '22', lu: sec(8 * H) }, + { s: '18.8', lu: sec(2 * H) }, + ], + 'sensor.out': [ + { s: '5', lu: sec(23 * H) }, + { s: '9', lu: sec(4 * H) }, + ], +}; + +async function mount(config: Partial, hass = hassWith(history)) { + const card = document.createElement('chartdeck-card') as Card; + card.setConfig({ + type: 'custom:chartdeck-card', + style: 'trend', + entities: ['sensor.in'], + ...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 text = (el: Element | null) => el!.textContent!.replace(/\s+/g, ' ').trim(); + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('trend card', () => { + it('shows the name, icon and large value over a filled line, as a half-width tile', async () => { + const card = await mount({ name: 'Temperature' }); + expect(text(q(card, '.trend-name'))).toBe('Temperature'); + expect(q(card, '.trend-header ha-state-icon')).not.toBeNull(); + expect(text(q(card, '.trend-value'))).toBe('18.8'); + expect(text(q(card, '.trend-unit'))).toBe('°C'); + expect(qa(card, 'path.line')).toHaveLength(1); + expect(q(card, 'path.fill.fade')!.getAttribute('mask')).toBe('url(#cd-trend-fade)'); + // 24 hours at 0.5 points per hour is 12 buckets, drawn as one smooth run edge to edge. + const d = q(card, 'path.line')!.getAttribute('d')!; + expect(d.startsWith('M0,')).toBe(true); + expect(d.match(/C/g)!.length).toBeGreaterThanOrEqual(12); + expect(q(card, 'svg.plot')!.getAttribute('aria-label')).toBe('Trend chart of Temperature'); + expect(q(card, '.trend-graph')!.getAttribute('style')).toContain('aspect-ratio:500 / 150'); + expect(card.getGridOptions()).toMatchObject({ columns: 6, min_columns: 3, rows: 'auto' }); + }); + + it('draws every reading with points_per_hour 0, and fades or drops the fill', async () => { + const raw = await mount({ points_per_hour: 0, fill: 'solid', curve: 'straight' } as Partial); + expect(q(raw, 'path.line')!.getAttribute('d')!.match(/L/g)).toHaveLength(4); + expect(q(raw, 'path.fill.solid')!.hasAttribute('mask')).toBe(false); + const none = await mount({ fill: false } as Partial); + expect(q(none, 'path.fill')).toBeNull(); + }); + + it('colours the line by bands, and the icon, name and value by the current band', async () => { + const card = await mount({ + severity: [ + { from: 0, color: 'blue' }, + { from: 18, color: 'red' }, + ], + color_icon: true, + color_name: true, + color_value: true, + color_transition: 'hard', + } as Partial); + expect(q(card, 'path.line')!.getAttribute('stroke')).toBe('url(#cd-trend-0)'); + expect(qa(card, '#cd-trend-0 stop').length).toBe(4); + expect(q(card, '.trend-value')!.getAttribute('style')).toContain('--error-color'); + expect(q(card, '.trend-name')!.getAttribute('style')).toContain('--error-color'); + expect(q(card, '.trend-icon')!.getAttribute('style')).toContain('--error-color'); + // An entity's own colour wins over bands. + const own = await mount({ + entities: [{ entity: 'sensor.in', color: 'green' }], + severity: [{ from: 0, color: 'blue' }], + } as Partial); + expect(q(own, 'path.line')!.getAttribute('stroke')).toContain('--success-color'); + }); + + it('shows other entities small, in a legend, and on a right-hand axis', async () => { + const card = await mount({ + name: 'Temperatures', + entities: ['sensor.in', { entity: 'sensor.out', show_state: true, y_axis: 'secondary', show_legend_state: true }], + labels: true, + } as Partial); + expect(qa(card, 'path.line')).toHaveLength(2); + expect(text(q(card, '.trend-state.small'))).toBe('9.0°C'); + // The card's name is the header's; the legend uses each entity's friendly name. + expect(qa(card, '.trend-legend .series-item').map(text)).toEqual(['Inside', 'Outside 9.0 °C']); + expect(text(q(card, '.trend-labels.primary'))).toMatch(/°C/); + expect(text(q(card, '.trend-labels.secondary'))).toBe('9.0 °C5.0 °C'); + expect(q(card, '.trend-labels-wrap.labels-always')).not.toBeNull(); + }); + + it('leaves out an entity that is off the graph, and draws a fixed value flat', async () => { + const card = await mount({ + entities: [ + { entity: 'sensor.in', fixed_value: true }, + { entity: 'sensor.out', show_graph: false }, + ], + points_per_hour: 0, + curve: 'straight', + } as Partial); + const lines = qa(card, 'path.line'); + expect(lines).toHaveLength(1); + const ys = lines[0].getAttribute('d')!.match(/,([\d.]+)/g)!; + expect(new Set(ys).size).toBe(1); + expect(q(card, '.trend-legend')).toBeNull(); + }); + + it('draws bars from zero, coloured by band, one per day', async () => { + const card = await mount({ + graph: 'bar', + period: { last: 3, unit: 'day' }, + group_by: 'day', + severity: [ + { from: 0, color: 'blue' }, + { from: 18.5, color: 'red' }, + ], + } as Partial); + const bars = qa(card, '.trend-col'); + expect(bars.length).toBeGreaterThanOrEqual(1); + expect(bars.length).toBeLessThanOrEqual(4); + expect(qa(card, 'path.line')).toHaveLength(0); + }); + + it('shows min, average and max, and scales values by value_factor', async () => { + const card = await mount({ + extrema: true, + average: true, + value_factor: -1, + points_per_hour: 0, + } as Partial); + const items = qa(card, '.trend-chip').map(text); + expect(items[0]).toMatch(/^Low 1\.7 °C · /); + expect(items[1]).toMatch(/^Average 1\.\d °C$/); + expect(items[2]).toMatch(/^High 2\.2 °C · /); + expect(text(q(card, '.trend-value'))).toBe('1.9'); + }); + + it('shows the value under the pointer in place of the current one', async () => { + const card = await mount({ name: 'Temperature' }); + const graph = q(card, '.trend-graph') as HTMLElement; + graph.getBoundingClientRect = () => ({ left: 0, width: 100, top: 0, height: 30 }) as DOMRect; + graph.dispatchEvent(new PointerEvent('pointermove', { clientX: 50, pointerType: 'mouse' })); + await card.updateComplete; + expect(q(card, '.trend-when')).not.toBeNull(); + expect(text(q(card, '.trend-value'))).toMatch(/^(19|22)\.\d$/); + expect(q(card, '.hover-dot')).not.toBeNull(); + expect(q(card, '.trend-graph .crosshair')).not.toBeNull(); + graph.dispatchEvent(new PointerEvent('pointerleave', { pointerType: 'mouse' })); + await card.updateComplete; + expect(q(card, '.trend-when')).toBeNull(); + expect(text(q(card, '.trend-value'))).toBe('18.8'); + }); + + it('lays out the header and value as asked, and hides the graph', async () => { + const card = await mount({ + align_icon: 'state', + align_state: 'center', + align_header: 'center', + graph: 'none', + font_size: 80, + font_size_header: 18, + icon_image: '/local/me.png', + group: true, + } as Partial); + expect(q(card, '.trend-header .trend-icon')).toBeNull(); + expect(q(card, '.trend-states .trend-icon img')!.getAttribute('src')).toBe('/local/me.png'); + expect(q(card, '.trend-states.align-center')!.getAttribute('style')).toContain('font-size:80%'); + expect(q(card, '.trend-header.align-center')!.getAttribute('style')).toContain('font-size:18px'); + expect(q(card, '.trend-graph')).toBeNull(); + expect(q(card, '.trend.group.no-graph')).not.toBeNull(); + }); + + it('says when history is loading, failed or empty, under the value', async () => { + const failed = await mount({}, hassWith(history, true)); + expect(text(q(failed, '.trend-graph.message.error'))).toBe("Couldn't load history: boom"); + expect(text(q(failed, '.trend-value'))).toBe('18.8'); + const empty = await mount({}, hassWith({})); + expect(text(q(empty, '.trend-graph.message'))).toBe('No readings in this period.'); + }); + + it('opens the entity from its name, value and legend entry', async () => { + const card = await mount({ entities: ['sensor.in', 'sensor.out'] }); + expect(qa(card, '.entity').map((e) => (e as HTMLElement).dataset.index)).toEqual(['0', '0', '0', '1']); + }); + + it('checks its options', () => { + const card = document.createElement('chartdeck-card') as Card; + const set = (extra: Record) => () => + card.setConfig({ type: 'x', style: 'trend', entities: ['sensor.in'], ...extra } as CardConfig); + expect(set({ graph: 'pie' })).toThrow(/"graph" must be one of: line, bar, none/); + expect(set({ fill: 'striped' })).toThrow(/"fill"/); + expect(set({ points: 'sometimes' })).toThrow(/"points"/); + expect(set({ points_per_hour: -1 })).toThrow(/"points_per_hour"/); + expect(set({ value_factor: 1.5 })).toThrow(/whole number/); + expect(set({ y_min: 'low' })).toThrow(/"y_min"/); + expect(set({ extrema: 'yes' })).toThrow(/"extrema"/); + expect(set({ icon_image: 5 })).toThrow(/"icon_image"/); + expect(set({ entities: [{ entity: 'sensor.in', y_axis: 'left' }] })).toThrow(/y_axis/); + expect(set({ entities: [{ entity: 'sensor.in', aggregate: 'avg' }] })).toThrow(/aggregate/); + expect(set({ entities: [{ entity: 'sensor.in', show_fill: 'no' }] })).toThrow(/show_fill/); + expect(set({ fill: true, points: false, labels: 'hover', y_min: '~0', y_max: 30 })).not.toThrow(); + }); +}); + +describe('trend card editor', () => { + it('shows the Chart section, writes options, and keeps number bounds as numbers', 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: 'trend', entities: ['sensor.in'], y_max: 30 } 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({ + points_per_hour: 0.5, + graph: 'line', + fill: 'fade', + points: 'hover', + labels: 'hover', + height: 150, + line_width: 2.5, + legend: true, + trend_y_max: '30', + trend_tooltip: true, + }); + form.dispatchEvent( + new CustomEvent('value-changed', { + detail: { value: { ...data, graph: 'bar', trend_y_min: '0', trend_y_max: '~40', extrema: true } }, + }), + ); + expect(events.at(-1)).toEqual({ ...raw, graph: 'bar', y_min: 0, y_max: '~40', extrema: true }); + 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).toContain('y_axis_side'); + expect(fields).toContain('show_state'); + expect(fields).toContain('fixed_value'); + }); +}); diff --git a/test/violin.test.ts b/test/violin.test.ts new file mode 100644 index 0000000..57e554c --- /dev/null +++ b/test/violin.test.ts @@ -0,0 +1,282 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import '../src/chartdeck-card'; +import { shapePath, violinOptions } from '../src/styles/violin'; +import { categoryGeometry } from '../src/chart/category-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'); + +type Card = HTMLElement & { setConfig(c: CardConfig): void; hass?: HomeAssistant; updateComplete: Promise }; + +const now = Date.now(); +const H = 3_600_000; + +/** A reading every 20 minutes for two days: bedroom around 19, nursery around 21. */ +function rows(base: number) { + const out: { s: string; lu: number }[] = []; + for (let i = 0; i < 144; i++) { + const t = now - 48 * H + i * 20 * 60_000; + out.push({ s: String(base + Math.round(Math.sin(i / 5) * 15) / 10), lu: t / 1000 }); + } + return out; +} + +function hassWith(history: Record) { + const hass = makeHass({ + 'sensor.bedroom': ['19', { friendly_name: 'Bedroom', unit_of_measurement: '°C' }], + 'sensor.nursery': ['21', { friendly_name: 'Nursery', unit_of_measurement: '°C' }], + 'sensor.gone': ['unavailable', { friendly_name: 'Gone' }], + }); + hass.callWS = vi.fn(async () => history) as HomeAssistant['callWS']; + return hass; +} + +const history = { + 'sensor.bedroom': rows(19), + 'sensor.nursery': rows(21), + 'sensor.gone': [{ s: 'unavailable', lu: (now - 40 * H) / 1000 }], +}; + +async function mount(config: Partial, hass = hassWith(history)) { + const card = document.createElement('chartdeck-card') as Card; + card.setConfig({ + type: 'custom:chartdeck-card', + style: 'violin', + period: { last: 2, unit: 'day' }, + entities: ['sensor.bedroom', 'sensor.nursery', 'sensor.gone'], + ...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 text = (e: Element) => e.textContent!.replace(/\s+/g, ' ').trim(); + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('violin options and shapes', () => { + it('defaults to a violin with a box, readings on for a raincloud', () => { + expect(violinOptions({} as CardConfig)).toEqual({ + shape: 'violin', + box: true, + readings: false, + bandwidth: undefined, + split: 'none', + }); + expect(violinOptions({ shape: 'raincloud' } as CardConfig).readings).toBe(true); + expect(violinOptions({ shape: 'raincloud', readings: false, bandwidth: 0.5 } as CardConfig)).toMatchObject({ + readings: false, + bandwidth: 0.5, + }); + }); + + it('outlines a whole violin, or a half for a raincloud', () => { + const host = document.createElement('div'); + const g = categoryGeometry({ host, requestUpdate: () => undefined, raw: {} as CardConfig }, 1, { domain: [0, 10] }); + const curve = [ + { x: 0, d: 0 }, + { x: 5, d: 1 }, + { x: 10, d: 0 }, + ]; + expect(shapePath(curve, g, 0, false, 0, 0.4)).toBe('M0,50L500,10L1000,50L1000,50L500,90L0,50Z'); + expect(shapePath(curve, g, 0, true, 0, 0.4)).toBe('M0,50L500,10L1000,50L1000,50L500,50L0,50Z'); + expect(shapePath([], g, 0, false)).toBe(''); + }); +}); + +describe('violin chart', () => { + it('draws a violin and box per entity, named with its median, the unavailable one empty', async () => { + const card = await mount({}); + expect(qa(card, 'path.shape')).toHaveLength(2); + expect(qa(card, '.box rect.iqr')).toHaveLength(2); + expect(q(card, 'path.dots')).toBeNull(); + const labels = qa(card, '.cat-label').map(text); + expect(labels[0]).toMatch(/^Bedroom median 1\d\.\d °C$/); + expect(labels[2]).toBe('Gone No readings'); + expect(q(card, 'svg.plot')!.getAttribute('aria-label')).toBe('Violin chart of Bedroom, Nursery, Gone'); + expect(qa(card, '.cat-axis .axis-label').length).toBeGreaterThan(1); + expect(qa(card, '.grid line').length).toBeGreaterThan(1); + }); + + it('scatters the readings, or makes a raincloud of half violins, boxes and readings', async () => { + const withDots = await mount({ readings: true, box: false } as Partial); + expect(qa(withDots, 'path.dots')).toHaveLength(2); + expect(q(withDots, '.box')).toBeNull(); + const rain = await mount({ shape: 'raincloud' } as Partial); + expect(qa(rain, 'path.dots')).toHaveLength(2); + expect(qa(rain, '.box')).toHaveLength(2); + expect(q(rain, 'svg.plot')!.getAttribute('aria-label')).toBe('Raincloud chart of Bedroom, Nursery, Gone'); + }); + + it('stands up, without value labels or lines when asked', async () => { + const card = await mount({ orientation: 'vertical', value_axis: false, value_grid: false } as Partial); + expect(q(card, '.cat-plot.vertical')).not.toBeNull(); + expect(q(card, '.cat-axis')).toBeNull(); + expect(q(card, '.grid')).toBeNull(); + expect(q(card, 'svg.plot')!.getAttribute('viewBox')).toBe('0 0 300 1000'); + const labelled = await mount({ orientation: 'vertical', value_min: 0, value_max: 40 } as Partial); + const ticks = qa(labelled, '.cat-axis .axis-label').map(text); + expect(ticks[0]).toBe('0'); + expect(ticks.at(-1)).toBe('40'); + expect(q(labelled, '.cat-sizer')!.textContent).toHaveLength(2); + }); + + it('draws one shape per entity and day', async () => { + const card = await mount({ split_by: 'day', entities: ['sensor.bedroom'] } as Partial); + // Two days back from now touches three calendar days. + expect(qa(card, 'path.shape')).toHaveLength(3); + const two = await mount({ split_by: 'day', entities: ['sensor.bedroom', 'sensor.nursery'] } as Partial); + expect(qa(two, '.cat-name').map(text)[1]).toMatch(/· Nursery$/); + }); + + it('works from grouped readings and a fixed bandwidth', async () => { + const card = await mount({ group_by: 'hour', bandwidth: 0.5 } as Partial); + expect(qa(card, 'path.shape')).toHaveLength(2); + }); + + it('shows the median, middle half, range and count for the band under the pointer', async () => { + const card = await mount({}); + const area = q(card, '.plot-area')!; + area.getBoundingClientRect = () => + ({ left: 0, top: 0, width: 300, height: 192, right: 300, bottom: 192 }) as DOMRect; + area.dispatchEvent(new MouseEvent('pointermove', { bubbles: true, clientX: 50, clientY: 10 })); + await card.updateComplete; + expect(text(q(card, '.band-tooltip .tooltip-time')!)).toBe('Bedroom'); + const rows = qa(card, '.band-tooltip .tooltip-row').map(text); + expect(rows.map((r) => r.split(' ')[0])).toEqual(['Median', 'Middle', 'Lowest', 'Readings']); + expect(rows[3]).toMatch(/Readings 14\d/); + area.dispatchEvent(new MouseEvent('pointermove', { bubbles: true, clientX: 50, clientY: 180 })); + await card.updateComplete; + // Over the unavailable entity: nothing to show. + expect(q(card, '.band-tooltip')).toBeNull(); + area.dispatchEvent(new MouseEvent('pointermove', { bubbles: true, clientX: 50, clientY: 100 })); + await card.updateComplete; + expect(q(card, '.band-tooltip')!.getAttribute('style')).toBe('top:66.67%;right:0'); + area.dispatchEvent(new MouseEvent('pointerleave', { bubbles: true })); + await card.updateComplete; + expect(q(card, '.band-tooltip')).toBeNull(); + }); + + it('puts the box above a band in the bottom half', async () => { + const card = await mount({ entities: ['sensor.gone', 'sensor.bedroom'] } as Partial); + const area = q(card, '.plot-area')!; + area.getBoundingClientRect = () => + ({ left: 0, top: 0, width: 300, height: 128, right: 300, bottom: 128 }) as DOMRect; + area.dispatchEvent(new MouseEvent('pointermove', { bubbles: true, clientX: 50, clientY: 100 })); + await card.updateComplete; + expect(q(card, '.band-tooltip')!.getAttribute('style')).toMatch(/^top:auto;bottom:50%/); + }); + + it('opens an entity from its name', async () => { + const card = await mount({}); + const fired: string[] = []; + card.addEventListener('hass-action', (e) => fired.push((e as CustomEvent).detail.config.entity)); + const label = qa(card, '.cat-label')[1]; + label.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true })); + label.dispatchEvent(new MouseEvent('pointerup', { bubbles: true })); + expect(fired).toEqual(['sensor.nursery']); + }); + + it('says when there is nothing to draw', async () => { + const card = await mount({}, hassWith({})); + 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: 'violin', entities: ['sensor.bedroom'], ...extra } as CardConfig); + expect(set({ shape: 'cello' })).toThrow(/"shape" must be one of: violin, raincloud/); + expect(set({ split_by: 'year' })).toThrow(/split_by/); + expect(set({ box: 'yes' })).toThrow(/box/); + expect(set({ readings: 1 })).toThrow(/readings/); + expect(set({ bandwidth: 0 })).toThrow(/"bandwidth" must be a number above 0/); + expect(set({ compact: 'yes' })).toThrow(/compact/); + expect(set({ orientation: 'diagonal' })).toThrow(/orientation/); + expect(set({ shape: 'raincloud', split_by: 'week', bandwidth: 0.2, orientation: 'vertical' })).not.toThrow(); + }); +}); + +describe('violin editor', () => { + it('writes the shape, direction, box, readings, split, smoothing and value axis', 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: 'violin', entities: ['sensor.bedroom'] } 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({ + shape: 'violin', + orientation: 'horizontal', + box: true, + readings: false, + split_by: 'none', + value_axis: true, + value_grid: true, + tooltip: true, + compact: false, + }); + form.dispatchEvent( + new CustomEvent('value-changed', { + detail: { + value: { + ...(form.data as object), + shape: 'raincloud', + orientation: 'vertical', + split_by: 'day', + bandwidth: 0.3, + value_min: 10, + value_grid: false, + }, + }, + }), + ); + const next = events.at(-1)!; + expect(next).toEqual({ + ...raw, + shape: 'raincloud', + orientation: 'vertical', + split_by: 'day', + bandwidth: 0.3, + value_min: 10, + value_grid: false, + }); + // A raincloud shows its readings unless told not to. + ed.setConfig(next); + await ed.updateComplete; + const rain = ed.shadowRoot!.querySelector('.section-chart ha-form') as Stub; + expect((rain.data as { readings: boolean }).readings).toBe(true); + const list = ed.shadowRoot!.querySelector('cd-entities-editor') as Stub; + expect((list.chartFields as { fields: { name: string }[] }).fields.map((f) => f.name)).toEqual(['fill_color']); + }); +}); diff --git a/test/waterfall.test.ts b/test/waterfall.test.ts new file mode 100644 index 0000000..8031b51 --- /dev/null +++ b/test/waterfall.test.ts @@ -0,0 +1,277 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import '../src/chartdeck-card'; +import { change, firstReading, signed, waterfallBars } from '../src/styles/waterfall'; +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; + +function hassWith(history: Record, fail = false) { + const hass = makeHass({ + 'sensor.battery': ['6', { friendly_name: 'Battery', unit_of_measurement: 'kWh' }], + 'sensor.solar': ['10', { friendly_name: 'Solar', unit_of_measurement: 'kWh' }], + 'sensor.house': ['7', { friendly_name: 'House', unit_of_measurement: 'kWh' }], + 'sensor.off': ['unavailable', { friendly_name: 'Off' }], + }); + hass.callWS = vi.fn(async () => { + if (fail) throw new Error('boom'); + return history; + }) as HomeAssistant['callWS']; + return hass; +} + +const history = { + 'sensor.battery': [ + { s: '4', lu: sec(20 * H) }, + { s: '6', lu: sec(2 * H) }, + ], + 'sensor.solar': [ + { s: '4', lu: sec(20 * H) }, + { s: '10', lu: sec(3 * H) }, + ], + 'sensor.house': [ + { s: '3', lu: sec(20 * H) }, + { s: 'unavailable', lu: sec(10 * H) }, + { s: '7', lu: sec(1 * H) }, + ], +}; + +async function mount(config: Partial, hass = hassWith(history)) { + const card = document.createElement('chartdeck-card') as Card; + card.setConfig({ + type: 'custom:chartdeck-card', + style: 'waterfall', + entities: ['sensor.solar', 'sensor.house'], + ...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 qa = (card: Card, sel: string) => [...card.shadowRoot!.querySelectorAll(sel)] as HTMLElement[]; +const text = (card: Card, sel: string) => qa(card, sel).map((e) => e.textContent!.trim()); + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('waterfall maths', () => { + it('measures change from the first reading to the last, skipping gaps', () => { + expect( + change([ + { t: 0, v: null }, + { t: 1, v: 4 }, + { t: 2, v: null }, + { t: 3, v: 1.5 }, + { t: 4, v: null }, + ]), + ).toBe(-2.5); + expect(change([{ t: 0, v: null }])).toBeNull(); + expect( + firstReading([ + { t: 0, v: null }, + { t: 1, v: 3 }, + ]), + ).toBe(3); + expect(firstReading([])).toBeNull(); + }); + + it('carries a running total from the start through each step to the end', () => { + const { bars, domain } = waterfallBars( + { value: 4.2, index: 0 }, + [ + { value: 6.1, index: 1 }, + { value: -3.4, index: 2 }, + { value: null, index: 3 }, + ], + true, + ); + expect(bars.map((b) => [b.kind, b.from, +b.to.toFixed(2), b.index, b.missing])).toEqual([ + ['start', 0, 4.2, 0, false], + ['step', 4.2, 10.3, 1, false], + ['step', 10.3, 6.9, 2, false], + ['step', 6.9, 6.9, 3, true], + ['total', 0, 6.9, undefined, false], + ]); + expect(domain[0]).toBe(0); + expect(domain[1]).toBeCloseTo(10.3); + }); + + it('reaches below zero, and gives a flat chart some height', () => { + expect(waterfallBars(null, [{ value: -5, index: 0 }], false).domain).toEqual([-5, 0]); + expect(waterfallBars(null, [], false).domain).toEqual([0, 1]); + expect(waterfallBars({ value: null, index: 0 }, [], true).bars[0]).toMatchObject({ to: 0, missing: true }); + }); + + it('signs changes with a real minus', () => { + expect(signed(6.12, 1)).toBe('+6.1'); + expect(signed(-3.4, 1)).toBe('−3.4'); + expect(signed(-0.01, 1)).toBe('0.0'); + }); +}); + +describe('waterfall chart', () => { + it('draws a column per entity from its change over the period, then the total', async () => { + const card = await mount({}); + expect(text(card, '.bar-name')).toEqual(['Solar', 'House', 'Total']); + expect(text(card, '.bar-label')).toEqual(['+6.0', '+4.0', '10.0']); + const bars = qa(card, '.bar'); + expect(bars.map((b) => b.dataset.index)).toEqual(['0', '1', undefined]); + expect(bars[0].classList.contains('entity')).toBe(true); + expect(bars[2].classList.contains('entity')).toBe(false); + expect(qa(card, '.connector')).toHaveLength(2); + expect(qa(card, '.wf.vertical')).toHaveLength(1); + expect(qa(card, '.wf-plot')[0].getAttribute('role')).toBe('img'); + expect(qa(card, '.wf-plot')[0].getAttribute('aria-label')).toBe( + 'Waterfall chart: Solar +6.0 kWh, House +4.0 kWh, Total 10.0 kWh', + ); + expect(card.hass!.callWS).toHaveBeenCalledTimes(1); + }); + + it('starts from the first entity, counts inverted entities down, and ends', async () => { + const card = await mount({ + start_from_first: true, + entities: ['sensor.battery', 'sensor.solar', { entity: 'sensor.house', invert: true }], + } as Partial); + expect(text(card, '.bar-name')).toEqual(['Start', 'Solar', 'House', 'End']); + expect(text(card, '.bar-label')).toEqual(['4.0', '+6.0', '−4.0', '6.0']); + const bars = qa(card, '.bar'); + expect(bars[0].dataset.index).toBe('0'); + expect(bars[2].classList.contains('fall')).toBe(true); + expect(bars[2].getAttribute('style')).toContain('--error-color'); + expect(bars[1].getAttribute('style')).toContain('--success-color'); + }); + + it('uses current values as rows without loading history, and can leave out the total', async () => { + const hass = hassWith(history); + const card = await mount( + { + steps: 'current', + orientation: 'horizontal', + total: false, + entities: ['sensor.solar', 'sensor.off', 'sensor.missing'], + } as Partial, + hass, + ); + expect(hass.callWS).not.toHaveBeenCalled(); + expect(qa(card, '.wf.horizontal')).toHaveLength(1); + expect(text(card, '.bar-label')).toEqual(['+10.0', 'Unavailable', 'Entity not found']); + expect(qa(card, '.bar.missing')).toHaveLength(2); + }); + + it('shows a dash for a column with no readings, and says when history is loading or failed', async () => { + const card = await mount({ entities: ['sensor.solar', 'sensor.off'] }); + expect(text(card, '.bar-label')).toEqual(['+6.0', '–', '6.0']); + expect(qa(card, '.bar')[1].getAttribute('aria-label')).toBe('Off, Unavailable'); + const loading = document.createElement('chartdeck-card') as Card; + loading.setConfig({ type: 'custom:chartdeck-card', style: 'waterfall', entities: ['sensor.solar'] } as CardConfig); + loading.hass = hassWith(history); + document.body.appendChild(loading); + await loading.updateComplete; + expect(text(loading, '.chart-message')).toEqual(['Loading history…']); + const failed = await mount({}, hassWith(history, true)); + expect(text(failed, '.chart-message.error')).toEqual(["Couldn't load history: boom"]); + }); + + it('says when an available entity has no readings in the period', async () => { + const card = await mount({ + steps: 'current', + start_from_first: true, + orientation: 'horizontal', + entities: ['sensor.off', 'sensor.solar'], + } as Partial); + expect(text(card, '.bar-label')[0]).toBe('No readings'); + }); + + it('opens more-info from a column', async () => { + const card = await mount({}); + const fired: string[] = []; + card.addEventListener('hass-action', (e) => fired.push((e as CustomEvent).detail.config.entity)); + const bar = qa(card, '.bar')[1]; + bar.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true })); + bar.dispatchEvent(new MouseEvent('pointerup', { bubbles: true })); + expect(fired).toEqual(['sensor.house']); + }); + + it('checks its options', () => { + const card = document.createElement('chartdeck-card') as Card; + const set = (extra: Record) => () => + card.setConfig({ type: 'x', style: 'waterfall', entities: ['sensor.solar'], ...extra } as CardConfig); + expect(set({ steps: 'sum' })).toThrow(/"steps" must be one of: change, current/); + expect(set({ orientation: 'diagonal' })).toThrow(/orientation/); + expect(set({ total: 'yes' })).toThrow(/"total" must be true or false/); + expect(set({ start_from_first: 1 })).toThrow(/start_from_first/); + expect(set({ entities: [{ entity: 'sensor.solar', invert: 'yes' }] })).toThrow(/invert/); + expect(set({ source: 'cloud' })).toThrow(/source/); + expect(set({ steps: 'current', orientation: 'horizontal', total: false })).not.toThrow(); + }); +}); + +describe('waterfall editor', () => { + it('writes period, steps, direction, start and total, and each entity counting down', 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 = hassWith({}); + const raw = { + type: 'custom:chartdeck-card', + style: 'waterfall', + entities: ['sensor.solar', { entity: 'sensor.house', invert: true }], + } as CardConfig; + ed.setConfig(raw); + document.body.appendChild(ed); + await ed.updateComplete; + const form = ed.shadowRoot!.querySelector('.section-chart ha-form[data-section="chart"]') as Stub; + expect(form.data).toEqual({ + period_last: 24, + period_unit: 'hour', + source: 'history', + steps: 'change', + orientation: 'vertical', + start_from_first: false, + total: true, + }); + form.dispatchEvent( + new CustomEvent('value-changed', { + detail: { + value: { ...(form.data as object), orientation: 'horizontal', start_from_first: true, total: false }, + }, + }), + ); + expect(events.at(-1)).toEqual({ ...raw, orientation: 'horizontal', start_from_first: true, total: false }); + + const list = ed.shadowRoot!.querySelector('.entity-options[data-section="waterfall_invert"] ha-form') as Stub; + expect(list.data).toEqual({ e0: false, e1: true }); + expect((list.computeLabel as (s: { name: string }) => string)({ name: 'e0' })).toBe('Solar'); + list.dispatchEvent(new CustomEvent('value-changed', { detail: { value: { e0: true, e1: false } } })); + expect(events.at(-1)!.entities).toEqual([{ entity: 'sensor.solar', invert: true }, 'sensor.house']); + }); +}); diff --git a/test/wind-rose.test.ts b/test/wind-rose.test.ts new file mode 100644 index 0000000..a95ef4b --- /dev/null +++ b/test/wind-rose.test.ts @@ -0,0 +1,614 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import '../src/chartdeck-card'; +import { parseBearing, normalizeDegrees } from '../src/history/bearing'; +import { key, loadHistory, parseHistory } from '../src/history/fetch'; +import { HistoryStore } from '../src/history/store'; +import { + bandIndex, + pairWind, + petalPath, + polarPoint, + ringSteps, + sectorName, + sectorOf, + speedDefaults, + strongest, + windRose, +} from '../src/chart/wind'; +import { STYLES } from '../src/styles/index'; +import { windPeriod, windSectors } from '../src/styles/wind-rose'; +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 M = 60_000; +const H = 3_600_000; + +describe('wind direction parsing', () => { + it('reads degrees, folding them into 0–360', () => { + expect(parseBearing('225')).toBe(225); + expect(parseBearing(' 225.5 ° ')).toBe(225.5); + expect(parseBearing(360)).toBe(0); + expect(parseBearing(-90)).toBe(270); + expect(parseBearing('725')).toBe(5); + expect(normalizeDegrees(-0.0)).toBe(0); + }); + + it('reads compass points in any case, and full names', () => { + expect(parseBearing('N')).toBe(0); + expect(parseBearing('nne')).toBe(22.5); + expect(parseBearing('SW')).toBe(225); + expect(parseBearing('wsw')).toBe(247.5); + expect(parseBearing('NNW')).toBe(337.5); + expect(parseBearing('North-East')).toBe(45); + expect(parseBearing('south west')).toBe(225); + expect(parseBearing('West')).toBe(270); + }); + + it('ignores what it cannot read', () => { + for (const raw of ['unavailable', 'unknown', '', 'variable', 'NX', null, undefined, NaN, {}]) { + expect(parseBearing(raw)).toBeNull(); + } + }); + + it('loads a direction series as bearings, under its own key', async () => { + expect( + parseHistory( + [ + { s: 'SW', lu: 1 }, + { s: 'calm', lu: 2 }, + ], + undefined, + false, + parseBearing, + ), + ).toEqual([ + { t: 1000, v: 225 }, + { t: 2000, v: null }, + ]); + expect(key({ entity: 'sensor.d', bearing: true })).toBe('sensor.d#bearing'); + const hass = makeHass({}); + hass.callWS = vi.fn(async () => ({ 'sensor.d': [{ s: 'NE', lu: 1 }] })) as HomeAssistant['callWS']; + const out = await loadHistory(hass, [{ entity: 'sensor.d', bearing: true }, { entity: 'sensor.d' }], { + start: 0, + end: 2000, + }); + expect(out.get('sensor.d#bearing')).toEqual([{ t: 1000, v: 45 }]); + expect(out.get('sensor.d')).toEqual([{ t: 1000, v: null }]); + }); + + it('appends live compass states as bearings', async () => { + const hass = makeHass({ 'sensor.d': 'N' }); + hass.callWS = vi.fn(async () => ({ 'sensor.d': [{ s: 'N', lu: 1 }] })) as HomeAssistant['callWS']; + const store = new HistoryStore(() => undefined); + const req = [{ entity: 'sensor.d', bearing: true }]; + store.get(hass, req, { start: 0, end: Date.now() }, 'history'); + await new Promise((r) => setTimeout(r, 0)); + const next = { + ...hass, + states: { + 'sensor.d': { entity_id: 'sensor.d', state: 'ssw', attributes: {}, last_updated: new Date().toISOString() }, + }, + }; + const got = store.get(next, req, { start: 0, end: Date.now() }, 'history'); + expect(got.data.get('sensor.d#bearing')!.map((p) => p.v)).toEqual([0, 202.5]); + }); +}); + +describe('wind rose sectors', () => { + it('centres sector i on i × 360 / n, north running 348.75 up to 11.25', () => { + expect(sectorOf(0, 16)).toBe(0); + expect(sectorOf(360, 16)).toBe(0); + expect(sectorOf(348.75, 16)).toBe(0); + expect(sectorOf(348.7, 16)).toBe(15); + expect(sectorOf(11.2, 16)).toBe(0); + expect(sectorOf(11.25, 16)).toBe(1); + expect(sectorOf(247.5, 16)).toBe(11); + expect(sectorOf(-10, 16)).toBe(0); + expect(sectorOf(337.5, 8)).toBe(0); + expect(sectorOf(337.4, 8)).toBe(7); + expect(sectorOf(225, 4)).toBe(3); + expect(sectorOf(224.9, 4)).toBe(2); + }); + + it('names sectors for 16, 8 and 4', () => { + expect(sectorName(11, 16)).toBe('WSW'); + expect(sectorName(5, 8)).toBe('SW'); + expect(sectorName(3, 4)).toBe('W'); + }); +}); + +describe('pairing direction with speed', () => { + it('weights by how long each pair held, on the union of change times', () => { + // Direction changes at 0 and 30 min; speed at 0, 10 and 50 min; until 60 min. + const dir = [ + { t: 0, v: 270 }, + { t: 30 * M, v: 180 }, + ]; + const speed = [ + { t: 0, v: 2 }, + { t: 10 * M, v: 8 }, + { t: 50 * M, v: 12 }, + ]; + expect(pairWind(dir, speed, 60 * M)).toEqual([ + { dir: 270, speed: 2, ms: 10 * M }, + { dir: 270, speed: 8, ms: 20 * M }, + { dir: 180, speed: 8, ms: 20 * M }, + { dir: 180, speed: 12, ms: 10 * M }, + ]); + }); + + it('counts time, not rows: a gusty minute of many readings weighs a minute', () => { + const dir = [ + { t: 0, v: 90 }, + { t: 59 * M, v: 270 }, + ]; + // Ten readings from the west in the last minute, one steady hour from the east before. + const speed = [{ t: 0, v: 5 }, ...Array.from({ length: 10 }, (_, i) => ({ t: 59 * M + i * 6000, v: 10 + i }))]; + const rose = windRose(pairWind(dir, speed, 60 * M), 4, [0, 9]); + expect(rose.totals[1]).toBeCloseTo(59 / 60); + expect(rose.totals[3]).toBeCloseTo(1 / 60); + expect(rose.prevailing).toBe(1); + }); + + it('drops gaps in either series, and merges repeats', () => { + const dir = [ + { t: 0, v: 0 }, + { t: 10 * M, v: null }, + { t: 20 * M, v: 0 }, + ]; + const speed = [ + { t: 0, v: 4 }, + { t: 25 * M, v: null }, + { t: 28 * M, v: 4 }, + ]; + // 0–10 min, then 20–25 and 28–30: the same pair, so one sample of 17 minutes. + expect(pairWind(dir, speed, 30 * M)).toEqual([{ dir: 0, speed: 4, ms: 17 * M }]); + expect( + pairWind( + [ + { t: 0, v: 90 }, + { t: 5 * M, v: 90 }, + ], + undefined, + 10 * M, + ), + ).toEqual([{ dir: 90, speed: null, ms: 10 * M }]); + expect(pairWind([], [{ t: 0, v: 3 }], 10 * M)).toEqual([]); + }); + + it('holds a statistics row only for its own period', () => { + const dir = [ + { t: 0, v: 90 }, + { t: 3 * H, v: 90 }, + ]; + // Hours 0 and 3 count; the two missing hours between don't. + expect(pairWind(dir, undefined, 4 * H, H)).toEqual([{ dir: 90, speed: null, ms: 2 * H }]); + }); +}); + +describe('wind rose numbers', () => { + it('has unit defaults: m/s bands, Beaufort-ish for km/h, mph and knots, calm 0.5 m/s', () => { + expect(speedDefaults('m/s')).toEqual({ from: [0, 3, 6, 9, 12], calm: 0.5 }); + expect(speedDefaults('km/h')).toEqual({ from: [0, 12, 20, 29, 39], calm: 1.8 }); + expect(speedDefaults('mph').from).toEqual([0, 8, 13, 19, 25]); + expect(speedDefaults('kn').from).toEqual([0, 7, 11, 17, 22]); + expect(speedDefaults('Knots').calm).toBe(1); + expect(speedDefaults('ft/s').from).toEqual([0, 10, 20, 30, 40]); + expect(speedDefaults(undefined)).toEqual(speedDefaults('m/s')); + expect(speedDefaults('kph')).toEqual(speedDefaults('km/h')); + }); + + it('bins speeds into bands', () => { + const from = [0, 3, 6, 9, 12]; + expect(bandIndex(0, from)).toBe(0); + expect(bandIndex(2.99, from)).toBe(0); + expect(bandIndex(3, from)).toBe(1); + expect(bandIndex(30, from)).toBe(4); + expect(bandIndex(-1, [2, 5])).toBe(0); + }); + + it('counts calm apart from the petals, as a share of all the time', () => { + const rose = windRose( + [ + { dir: 0, speed: 0.2, ms: 10 }, + { dir: 270, speed: 4, ms: 60 }, + { dir: 270, speed: 13, ms: 20 }, + { dir: 90, speed: 7, ms: 10 }, + ], + 16, + [0, 3, 6, 9, 12], + 0.5, + ); + expect(rose.total).toBe(100); + expect(rose.calm).toBeCloseTo(0.1); + expect(rose.totals[0]).toBe(0); + expect(rose.sectors[12]).toEqual([0, 0.6, 0, 0, 0.2]); + expect(rose.totals[4]).toBeCloseTo(0.1); + expect(rose.prevailing).toBe(12); + expect(rose.meanSpeed).toBeCloseTo((0.2 * 10 + 4 * 60 + 13 * 20 + 7 * 10) / 100); + const still = windRose([], 8, [0]); + expect(still.prevailing).toBe(-1); + expect(still.meanSpeed).toBeNull(); + expect(still.calm).toBe(0); + }); + + it('finds the strongest speed, from statistics max when there is one', () => { + expect( + strongest([ + { t: 0, v: 3 }, + { t: 1, v: null }, + { t: 2, v: 9 }, + ]), + ).toBe(9); + expect(strongest([{ t: 0, v: 3, max: 14 }])).toBe(14); + expect(strongest([])).toBeNull(); + }); + + it('picks round ring steps', () => { + expect(ringSteps(11)).toEqual([5, 10, 15]); + expect(ringSteps(12)).toEqual([5, 10, 15]); + expect(ringSteps(6.5)).toEqual([2.5, 5, 7.5]); + expect(ringSteps(100)).toEqual([50, 100]); + expect(ringSteps(0.9)).toEqual([0.5, 1]); + expect(ringSteps(0)).toEqual([]); + }); + + it('draws petals clockwise from north', () => { + expect(polarPoint(10, Math.PI / 2)).toEqual([10, 0]); + expect(petalPath(0, 10, 0, Math.PI / 2)).toBe('M0 -10A10 10 0 0 1 10 0L0 0Z'); + expect(petalPath(5, 10, 0, Math.PI / 2)).toBe('M0 -10A10 10 0 0 1 10 0L5 0A5 5 0 0 0 0 -5Z'); + expect(petalPath(10, 10, 0, 1)).toBe(''); + }); +}); + +type Card = HTMLElement & { setConfig(c: CardConfig): void; hass?: HomeAssistant; updateComplete: Promise }; + +const now = Date.now(); +const sec = (msAgo: number) => (now - msAgo) / 1000; +const D = 24 * H; + +function windHass(history: Record, fail = false, extra = {}) { + const hass = makeHass({ + 'sensor.dir': ['250', { friendly_name: 'Direction', unit_of_measurement: '°' }], + 'sensor.compass': ['SW', { friendly_name: 'Compass' }], + 'sensor.speed': ['7', { friendly_name: 'Speed', unit_of_measurement: 'm/s' }], + 'sensor.kmh': ['20', { friendly_name: 'Speed', unit_of_measurement: 'km/h' }], + 'sensor.off': ['unavailable', { friendly_name: 'Off', unit_of_measurement: 'm/s' }], + ...extra, + }); + hass.callWS = vi.fn(async (msg: Record) => { + if (fail) throw new Error('boom'); + if (msg.type === 'recorder/statistics_during_period') { + return { + 'sensor.dir': [ + { start: now - 3 * H, mean: 270, max: 270 }, + { start: now - 2 * H, mean: 90, max: 90 }, + ], + 'sensor.speed': [ + { start: now - 3 * H, mean: 8, max: 20 }, + { start: now - 2 * H, mean: 4, max: 6 }, + ], + }; + } + return history; + }) as HomeAssistant['callWS']; + return hass; +} + +// 10 days of WSW at 8 m/s, 2 of calm, 2 from the east at 4 m/s. +const history = { + 'sensor.dir': [ + { s: '250', lu: sec(14 * D) }, + { s: '90', lu: sec(4 * D) }, + { s: '0', lu: sec(2 * D) }, + ], + 'sensor.compass': [ + { s: 'WSW', lu: sec(14 * D) }, + { s: 'e', lu: sec(4 * D) }, + { s: 'N', lu: sec(2 * D) }, + ], + 'sensor.speed': [ + { s: '8', lu: sec(14 * D) }, + { s: '4', lu: sec(4 * D) }, + { s: '0.1', lu: sec(2 * D) }, + ], + 'sensor.kmh': [ + { s: '30', lu: sec(14 * D) }, + { s: '1', lu: sec(2 * D) }, + ], + 'sensor.off': [{ s: 'unavailable', lu: sec(14 * D) }], +}; + +async function mount(config: Partial, hass = windHass(history)) { + const card = document.createElement('chartdeck-card') as Card; + card.setConfig({ + type: 'custom:chartdeck-card', + style: 'wind_rose', + entities: ['sensor.dir', 'sensor.speed'], + ...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 stats = (card: Card) => + Object.fromEntries( + qa(card, '.stat').map((e) => [ + e.querySelector('.stat-label')!.textContent!.trim(), + e.querySelector('.stat-value')!.textContent!.trim(), + ]), + ); + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('wind rose card', () => { + it('is in the Distributions group and loads the last 30 days by default', async () => { + expect(STYLES.wind_rose.group).toBe('distribution'); + const card = await mount({}); + const msg = (card.hass!.callWS as ReturnType).mock.calls[0][0] as Record; + const days = (Date.parse(msg.end_time) - Date.parse(msg.start_time)) / D; + expect(days).toBeCloseTo(30, 1); + expect(msg.entity_ids).toEqual(['sensor.dir', 'sensor.speed']); + }); + + it('draws petals stacked by speed band, the readout and the legend', async () => { + const card = await mount({}); + const petals = qa(card, '.petal'); + // WSW, E and (calm, so no) N. + expect(petals.map((p) => p.getAttribute('data-sector'))).toEqual(['4', '11']); + expect(petals.every((p) => p.classList.contains('entity') && p.getAttribute('data-index') === '0')).toBe(true); + expect(petals[1].querySelectorAll('path')).toHaveLength(1); + expect(stats(card)).toEqual({ + Prevailing: 'WSW · 71%', + 'Mean speed': '6.3 m/s', + Strongest: '8.0 m/s', + Calm: '14%', + }); + expect(qa(card, '.key-label').map((e) => e.textContent)).toEqual(['0–3', '3–6', '6–9', '9–12', '12+']); + expect(q(card, '.speed-legend .stat-label')!.textContent).toBe('Speed (m/s)'); + expect(q(card, 'svg.rose')!.getAttribute('role')).toBe('img'); + expect(q(card, 'svg.rose')!.getAttribute('aria-label')).toBe('Wind rose, prevailing WSW 71%, calm 14%'); + expect(qa(card, '.ring-label').map((e) => e.textContent)).toEqual(['25%', '50%', '75%']); + expect(qa(card, '.compass').map((e) => e.textContent)).toEqual(['N', 'E', 'S', 'W']); + expect(q(card, '.stat-mean')!.getAttribute('data-index')).toBe('1'); + }); + + it('reads compass points, 8 sectors and km/h bands', async () => { + const card = await mount({ entities: ['sensor.compass', 'sensor.kmh'], sectors: 8 } as Partial); + expect(qa(card, '.petal').map((p) => p.getAttribute('data-sector'))).toEqual(['2', '6']); + expect(qa(card, '.key-label').map((e) => e.textContent)).toEqual(['0–12', '12–20', '20–29', '29–39', '39+']); + expect(stats(card).Prevailing).toBe('W · 71%'); + }); + + it('uses the card colour bands for speed when set', async () => { + const card = await mount({ + severity: [ + { from: 0, color: 'green' }, + { from: 5, color: 'red', label: 'Windy' }, + ], + } as Partial); + expect(qa(card, '.key-label').map((e) => e.textContent)).toEqual(['0–5', 'Windy']); + expect((q(card, '.key-swatch') as HTMLElement).style.background).toContain('--success-color'); + }); + + it('draws directions only without a speed, or when the speed has no readings', async () => { + for (const entities of [['sensor.dir'], ['sensor.dir', 'sensor.off'], ['sensor.dir', 'sensor.gone']]) { + const card = await mount({ entities } as Partial); + expect(Object.keys(stats(card))).toEqual(['Prevailing']); + expect(q(card, '.speed-legend')).toBeNull(); + expect(qa(card, '.petal').map((p) => p.getAttribute('data-sector'))).toEqual(['0', '4', '11']); + expect(q(card, 'svg.rose')!.getAttribute('aria-label')).toMatch(/^Wind rose, prevailing WSW 71%$/); + } + }); + + it('says when loading, failed, or there is nothing to draw', async () => { + const card = document.createElement('chartdeck-card') as Card; + card.setConfig({ type: 'x', style: 'wind_rose', entities: ['sensor.dir'] } as CardConfig); + card.hass = windHass(history); + document.body.appendChild(card); + await card.updateComplete; + expect(q(card, '.chart-message')!.textContent).toBe('Loading history…'); + const failed = await mount({}, windHass(history, true)); + expect(q(failed, '.chart-message.error')!.textContent!.trim()).toBe("Couldn't load history: boom"); + const missing = await mount({ entities: ['sensor.nothing', 'sensor.speed'] } as Partial); + expect(q(missing, '.chart-message')!.textContent).toBe('No readings in this period.'); + const unreadable = await mount( + { entities: ['sensor.dir'] } as Partial, + windHass({ 'sensor.dir': [{ s: 'variable', lu: sec(D) }] }), + ); + expect(q(unreadable, '.chart-message')!.textContent).toBe('No readings in this period.'); + }); + + it('shows all calm as an empty rose', async () => { + const card = await mount( + {}, + windHass({ 'sensor.dir': [{ s: '10', lu: sec(D) }], 'sensor.speed': [{ s: '0', lu: sec(D) }] }), + ); + expect(qa(card, '.petal')).toHaveLength(0); + expect(stats(card).Calm).toBe('100%'); + expect(q(card, 'svg.rose')!.getAttribute('aria-label')).toBe('Wind rose, calm 100%'); + }); + + it('keeps roles by listed order when sorted or hiding unavailable', async () => { + const card = await mount({ + entities: ['sensor.compass', 'sensor.speed'], + hide_unavailable: true, + sort: 'descending', + } as Partial); + expect(stats(card).Prevailing).toBe('WSW · 71%'); + // The compass sensor isn't drawn as an entity (it has no number), so its petals don't open it. + expect(qa(card, '.petal.entity')).toHaveLength(0); + }); + + it('shows a petal’s share and band split on hover or focus', async () => { + const card = await mount({}); + const petal = q(card, '.petal[data-sector="11"]')!; + petal.dispatchEvent(new Event('pointerenter')); + await card.updateComplete; + expect(q(card, '.rose-tip .tooltip-time')!.textContent).toBe('WSW · 71.4%'); + expect(qa(card, '.rose-tip .tooltip-row').map((r) => r.textContent!.replace(/\s+/g, ' ').trim())).toEqual([ + '6–9 m/s 71.4%', + ]); + expect(q(card, '.rose-tip')!.classList.contains('right')).toBe(true); + petal.dispatchEvent(new Event('pointerleave')); + await card.updateComplete; + expect(q(card, '.rose-tip')).toBeNull(); + const east = q(card, '.petal[data-sector="4"]')!; + east.dispatchEvent(new Event('focus')); + await card.updateComplete; + expect(q(card, '.rose-tip')!.classList.contains('left')).toBe(true); + east.dispatchEvent(new Event('blur')); + const off = await mount({ tooltip: false } as Partial); + q(off, '.petal')!.dispatchEvent(new Event('pointerenter')); + await off.updateComplete; + expect(q(off, '.rose-tip')).toBeNull(); + const only = await mount({ entities: ['sensor.dir'] } as Partial); + q(only, '.petal[data-sector="11"]')!.dispatchEvent(new Event('pointerenter')); + await only.updateComplete; + expect(q(only, '.rose-tip .tooltip-name')!.textContent).toBe('Of the time'); + }); + + it('opens the direction from a petal and the speed from its readout', async () => { + const card = await mount({}); + const fired: string[] = []; + card.addEventListener('hass-action', (e) => fired.push((e as CustomEvent).detail.config.entity)); + for (const el of [q(card, '.petal')!, q(card, '.stat-strongest')!]) { + el.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true })); + el.dispatchEvent(new MouseEvent('pointerup', { bubbles: true })); + } + expect(fired).toEqual(['sensor.dir', 'sensor.speed']); + }); + + it('reads hourly statistics, with the speed max as the strongest', async () => { + const card = await mount({ source: 'statistics', compact: true } as Partial); + const msg = (card.hass!.callWS as ReturnType).mock.calls[0][0] as Record; + expect(msg.period).toBe('hour'); + expect(msg.types).toEqual(['mean', 'max']); + expect(stats(card)).toMatchObject({ Prevailing: 'E · 50%', Strongest: '20.0 m/s', 'Mean speed': '6.0 m/s' }); + expect(q(card, '.frame.compact')).not.toBeNull(); + }); + + it('steps back a period with the buttons', async () => { + const card = await mount({ period: { calendar: 'week', navigate: true } } as Partial); + expect(q(card, '.period-label')!.textContent).toBe('This week'); + }); + + it('checks its options', () => { + const card = document.createElement('chartdeck-card') as Card; + const set = (extra: Record) => () => + card.setConfig({ type: 'x', style: 'wind_rose', entities: ['sensor.dir'], ...extra } as CardConfig); + expect(set({ sectors: 12 })).toThrow('"sectors" must be one of: 4, 8, 16'); + expect(set({ calm: -1 })).toThrow(/calm/); + expect(set({ calm: 'still' })).toThrow(/calm/); + expect(set({ period: { last: 0 } })).toThrow(/period/); + expect(set({ period: 'week' })).toThrow(/period/); + expect(set({ tooltip: 'yes' })).toThrow(/tooltip/); + expect(set({ sectors: '8', calm: '1' })).not.toThrow(); + expect(windSectors({ type: 'x', sectors: '4' })).toBe(4); + expect(windPeriod({ type: 'x', period: { last: 7 } })).toMatchObject({ last: 7, unit: 'day' }); + }); + + it('pauses on disconnect', async () => { + const card = await mount({}); + card.remove(); + expect(q(card, '.petal')).not.toBeNull(); + }); +}); + +describe('wind rose editor', () => { + it('says which entity is which, and writes period, sectors and calm', 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 = windHass(history); + const raw = { + type: 'custom:chartdeck-card', + style: 'wind_rose', + entities: ['sensor.dir', { entity: 'sensor.speed', name: 'Gusts' }], + sectors: 8, + } as CardConfig; + ed.setConfig(raw); + document.body.appendChild(ed); + await ed.updateComplete; + expect(ed.shadowRoot!.querySelector('.wind-roles')!.textContent).toContain('Direction: Direction. Speed: Gusts.'); + expect(ed.shadowRoot!.querySelector('.wind-statistics')).toBeNull(); + expect( + ed + .shadowRoot!.querySelector('button[data-style="wind_rose"]')! + .closest('[data-group]')! + .getAttribute('data-group'), + ).toBe('distribution'); + const form = ed.shadowRoot!.querySelector('.section-chart ha-form') as Stub; + expect(form.data).toEqual({ + period_calendar: 'rolling', + period_last: 30, + period_unit: 'day', + period_offset: 0, + period_until: 'now', + period_navigate: false, + source: 'history', + sectors: '8', + tooltip: true, + compact: false, + }); + const names = JSON.stringify(form.schema); + expect(names).not.toContain('group_by'); + expect(names).toContain('"calm"'); + form.dispatchEvent( + new CustomEvent('value-changed', { + detail: { value: { ...(form.data as object), sectors: '16', period_last: 7, calm: 1 } }, + }), + ); + const rest: CardConfig = { ...raw }; + delete rest.sectors; + expect(events.at(-1)).toEqual({ ...rest, period: { last: 7 }, calm: 1 }); + form.dispatchEvent( + new CustomEvent('value-changed', { detail: { value: { ...(form.data as object), sectors: '4' } } }), + ); + expect(events.at(-1)!.sectors).toBe(4); + }); + + it('warns about statistics and names a missing speed', async () => { + const ed = document.createElement('chartdeck-card-editor') as HTMLElement & { + setConfig(c: CardConfig): void; + hass?: unknown; + updateComplete: Promise; + }; + ed.hass = windHass(history); + ed.setConfig({ type: 'x', style: 'wind_rose', source: 'statistics', entities: ['sensor.nowhere'] } as CardConfig); + document.body.appendChild(ed); + await ed.updateComplete; + expect(ed.shadowRoot!.querySelector('.wind-roles')!.textContent).toContain( + 'Direction: sensor.nowhere. Speed: none.', + ); + expect(ed.shadowRoot!.querySelector('.wind-statistics')!.textContent).toContain('measurement_angle'); + }); +}); diff --git a/test/zoom.test.ts b/test/zoom.test.ts new file mode 100644 index 0000000..37ac388 --- /dev/null +++ b/test/zoom.test.ts @@ -0,0 +1,380 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import '../src/chartdeck-card'; +import { + FULL, + clampWindow, + isZoomed, + minZoomMs, + panBy, + pinchWindow, + selectWindow, + visiblePoints, + windowRange, + zoomAt, +} from '../src/chart/zoom'; +import type { CardConfig, HomeAssistant } from '../src/types'; +import { makeHass } from './helpers/hass'; + +const close = (w: { from: number; to: number }, from: number, to: number) => { + expect(w.from).toBeCloseTo(from, 6); + expect(w.to).toBeCloseTo(to, 6); +}; + +describe('zoom maths', () => { + it('keeps a window inside the span and no narrower than the least', () => { + close(clampWindow({ from: -0.2, to: 0.3 }, 0.1), 0, 0.5); + close(clampWindow({ from: 0.9, to: 1.2 }, 0.1), 0.7, 1); + close(clampWindow({ from: 0.5, to: 0.5 }, 0.1), 0.5, 0.6); + close(clampWindow({ from: 0.2, to: 3 }, 0.1), 0, 1); + close(clampWindow({ from: NaN, to: NaN }, 0.1), 0, 1); + expect(isZoomed(FULL)).toBe(false); + expect(isZoomed({ from: 0, to: 0.99 })).toBe(true); + }); + + it('zooms about the pointer, so the time under it stays put', () => { + const w = zoomAt(FULL, 0.25, 2, 0.01); + close(w, 0.125, 0.625); + // The point a quarter across was at 0.25 of the span and still is. + expect(w.from + 0.25 * (w.to - w.from)).toBeCloseTo(0.25); + close(zoomAt(w, 0.5, 0.25, 0.01), 0, 1); + close(zoomAt(FULL, 0.5, 1000, 0.1), 0.45, 0.55); + }); + + it('pans a window, stopping at either end', () => { + close(panBy({ from: 0.2, to: 0.4 }, 0.1), 0.3, 0.5); + close(panBy({ from: 0.2, to: 0.4 }, -1), 0, 0.2); + close(panBy({ from: 0.2, to: 0.4 }, 5), 0.8, 1); + }); + + it('pinches: the moments under each finger stay under them', () => { + // Fingers at 0.4 and 0.6 of the whole span spread to 0.2 and 0.8: three times closer in. + const w = pinchWindow(FULL, [0.4, 0.6], [0.2, 0.8], 0.01); + close(w, 1 / 3, 2 / 3); + // Fingers moving together sideways pan. + close(pinchWindow({ from: 0.4, to: 0.6 }, [0.2, 0.8], [0.3, 0.9], 0.01), 0.38, 0.58); + // Fingers that meet keep the zoom and follow their middle. + close(pinchWindow({ from: 0.4, to: 0.6 }, [0.4, 0.6], [0.6, 0.605], 0.01), 0.3795, 0.5795); + close(pinchWindow(FULL, [0.5, 0.5], [0.1, 0.9], 0.01), 0, 1); + }); + + it('turns a drag into a window, widening one that is too narrow', () => { + close(selectWindow({ from: 0.5, to: 1 }, 0.8, 0.2, 0.01), 0.6, 0.9); + close(selectWindow(FULL, 0.5, 0.5, 0.1), 0.45, 0.55); + }); + + it('finds the times on show and the points they need', () => { + expect(windowRange({ start: 1000, end: 2000 }, { from: 0.25, to: 0.5 })).toEqual({ start: 1250, end: 1500 }); + const pts = [0, 10, 20, 30, 40].map((t) => ({ t, v: t })); + expect(visiblePoints(pts, { start: 12, end: 28 }).map((p) => p.t)).toEqual([10, 20, 30]); + expect(visiblePoints(pts, { start: 20, end: 20 }).map((p) => p.t)).toEqual([10, 20, 30]); + expect(visiblePoints(pts, { start: -5, end: 50 })).toBe(pts); + expect(visiblePoints([], { start: 0, end: 1 })).toEqual([]); + // A group from 10 to 19 is still in view at 15. + const groups = [0, 10, 20].map((t) => ({ t, v: 1, end: t + 10 })); + expect(visiblePoints(groups, { start: 15, end: 16 }).map((p) => p.t)).toEqual([0, 10, 20]); + expect(minZoomMs({ group: undefined })).toBe(300_000); + expect(minZoomMs({ group: { unit: 'day', aggregate: 'mean' } })).toBe(2 * 86_400_000); + }); +}); + +type Card = HTMLElement & { setConfig(c: CardConfig): void; hass?: HomeAssistant; updateComplete: Promise }; +const now = Date.now(); +const H = 3_600_000; +const sec = (ago: number) => (now - ago) / 1000; + +function hass() { + const h = makeHass({ + 'sensor.in': ['21', { friendly_name: 'Inside', unit_of_measurement: '°C' }], + 'sensor.out': ['9', { friendly_name: 'Outside', unit_of_measurement: '°C' }], + }); + const rows = (base: number) => + Array.from({ length: 24 }, (_, i) => ({ s: String(base + (i % 5)), lu: sec((24 - i) * H - 60_000) })); + h.callWS = vi.fn(async () => ({ 'sensor.in': rows(18), 'sensor.out': rows(4) })) as HomeAssistant['callWS']; + return h; +} + +async function mount(extra: Record = {}) { + const card = document.createElement('chartdeck-card') as Card; + card.setConfig({ + type: 'custom:chartdeck-card', + style: 'line', + entities: ['sensor.in', 'sensor.out'], + zoom: true, + ...extra, + } 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) as HTMLElement; +const qa = (card: Card, sel: string) => [...card.shadowRoot!.querySelectorAll(sel)] as HTMLElement[]; + +/** Make `el` 1000 px wide, so a fraction across is `frac * 1000` px. */ +function sized(el: Element) { + el.getBoundingClientRect = () => ({ left: 0, top: 0, width: 1000, height: 160, right: 1000, bottom: 160 }) as DOMRect; + return el; +} + +function pointer(el: Element, type: string, frac: number, opts: { id?: number; kind?: string; button?: number } = {}) { + sized(el); + const ev = new MouseEvent(type, { bubbles: true, cancelable: true, clientX: frac * 1000, button: opts.button ?? 0 }); + Object.defineProperty(ev, 'pointerType', { value: opts.kind ?? 'mouse' }); + Object.defineProperty(ev, 'pointerId', { value: opts.id ?? 1 }); + el.dispatchEvent(ev); + return ev; +} + +function wheel(el: Element, frac: number, init: WheelEventInit) { + sized(el); + const ev = new WheelEvent('wheel', { bubbles: true, cancelable: true, clientX: frac * 1000, ...init }); + el.dispatchEvent(ev); + return ev; +} + +const timeLabels = (card: Card) => qa(card, '.x-axis .axis-label').map((e) => e.textContent); +const windowStyle = (card: Card) => q(card, '.overview-window').getAttribute('style'); + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('zoom on the line chart', () => { + it('zooms with Ctrl + scroll about the pointer, and resets with the button or a double click', async () => { + const card = await mount({ overview: true }); + const area = q(card, '.plot-area'); + expect(area.classList.contains('zoomable')).toBe(true); + expect(q(card, '.zoom-reset')).toBeNull(); + const before = timeLabels(card); + const line = q(card, 'path.line').getAttribute('d'); + // A plain scroll is the page's. + expect(wheel(area, 0.5, { deltaY: -100 }).defaultPrevented).toBe(false); + expect(wheel(area, 0.75, { deltaY: -200, ctrlKey: true }).defaultPrevented).toBe(true); + await card.updateComplete; + expect(q(card, '.zoom-reset')).not.toBeNull(); + expect(area.classList.contains('zoomed')).toBe(true); + expect(timeLabels(card)).not.toEqual(before); + expect(q(card, 'path.line').getAttribute('d')).not.toBe(line); + expect(windowStyle(card)).not.toMatch(/left:0%;width:100%/); + q(card, '.zoom-reset').click(); + await card.updateComplete; + expect(q(card, '.zoom-reset')).toBeNull(); + expect(windowStyle(card)).toMatch(/left:0%;width:100%/); + wheel(area, 0.5, { deltaY: -3, deltaMode: 1, metaKey: true }); + await card.updateComplete; + expect(q(card, '.zoom-reset')).not.toBeNull(); + area.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })); + await card.updateComplete; + expect(q(card, '.zoom-reset')).toBeNull(); + }); + + it('drags to choose a span, then drags to pan it, and scrolls sideways', async () => { + const card = await mount({ overview: true }); + const area = q(card, '.plot-area'); + pointer(area, 'pointerdown', 0.25); + pointer(area, 'pointermove', 0.251); + await card.updateComplete; + expect(q(card, '.zoom-select')).toBeNull(); + expect(q(card, '.tooltip')).not.toBeNull(); + pointer(area, 'pointermove', 0.5); + // Leaving mid-drag doesn't end it. + pointer(area, 'pointerleave', 0.6); + await card.updateComplete; + expect(q(card, '.zoom-select')!.getAttribute('style')).toBe('left:25%;width:25%'); + expect(q(card, '.tooltip')).toBeNull(); + pointer(area, 'pointerup', 0.5); + await card.updateComplete; + expect(q(card, '.zoom-select')).toBeNull(); + expect(windowStyle(card)).toBe('left:25%;width:25%'); + // Zoomed, a drag pans: 10% of the plot to the left moves on by 10% of the window. + pointer(area, 'pointerdown', 0.5); + pointer(area, 'pointermove', 0.4); + pointer(area, 'pointerup', 0.4); + await card.updateComplete; + expect(windowStyle(card)).toBe('left:27.5%;width:25%'); + // Sideways scroll (or Shift + scroll) pans too. + expect(wheel(area, 0.5, { deltaX: 400 }).defaultPrevented).toBe(true); + await card.updateComplete; + expect(windowStyle(card)).toBe('left:37.5%;width:25%'); + wheel(area, 0.5, { deltaY: -400, shiftKey: true }); + await card.updateComplete; + expect(windowStyle(card)).toBe('left:27.5%;width:25%'); + // A right-button press, and a plain click, don't zoom. + pointer(area, 'pointerdown', 0.1, { button: 2 }); + pointer(area, 'pointerup', 0.1, { button: 2 }); + pointer(area, 'pointerdown', 0.1); + pointer(area, 'pointerup', 0.1); + await card.updateComplete; + expect(windowStyle(card)).toBe('left:27.5%;width:25%'); + }); + + it('pinches with two fingers and double-taps back', async () => { + const card = await mount({ overview: true }); + const area = q(card, '.plot-area'); + pointer(area, 'pointerdown', 0.4, { id: 1, kind: 'touch' }); + await card.updateComplete; + expect(q(card, '.tooltip')).not.toBeNull(); + pointer(area, 'pointerdown', 0.6, { id: 2, kind: 'touch' }); + await card.updateComplete; + expect(q(card, '.tooltip')).toBeNull(); + // A third finger is ignored. + pointer(area, 'pointerdown', 0.9, { id: 3, kind: 'touch' }); + pointer(area, 'pointercancel', 0.9, { id: 3, kind: 'touch' }); + pointer(area, 'pointermove', 0.2, { id: 1, kind: 'touch' }); + pointer(area, 'pointermove', 0.8, { id: 2, kind: 'touch' }); + await card.updateComplete; + expect(windowStyle(card)).toBe('left:33.33%;width:33.33%'); + pointer(area, 'pointerup', 0.8, { id: 2, kind: 'touch' }); + pointer(area, 'pointermove', 0.1, { id: 1, kind: 'touch' }); + pointer(area, 'pointerup', 0.1, { id: 1, kind: 'touch' }); + await card.updateComplete; + expect(windowStyle(card)).toBe('left:33.33%;width:33.33%'); + pointer(area, 'pointerdown', 0.5, { id: 4, kind: 'touch' }); + pointer(area, 'pointerup', 0.5, { id: 4, kind: 'touch' }); + pointer(area, 'pointerleave', 0.5, { id: 4, kind: 'touch' }); + pointer(area, 'pointerdown', 0.51, { id: 5, kind: 'touch' }); + pointer(area, 'pointerup', 0.51, { id: 5, kind: 'touch' }); + await card.updateComplete; + expect(windowStyle(card)).toBe('left:0%;width:100%'); + }); + + it('moves, resizes and redraws the window on the overview strip, and by keyboard', async () => { + const card = await mount({ zoom: false, overview: true }); + expect(q(card, '.plot-area').classList.contains('zoomable')).toBe(false); + const strip = q(card, '.overview'); + expect(qa(card, '.overview-lines path')).toHaveLength(2); + // Across the strip: a new window. + pointer(strip, 'pointerdown', 0.2); + pointer(strip, 'pointermove', 0.6); + pointer(strip, 'pointerup', 0.6); + await card.updateComplete; + expect(windowStyle(card)).toBe('left:20%;width:40%'); + expect(q(card, '.overview').classList.contains('zoomed')).toBe(true); + // Its middle moves it; its edges resize it. + pointer(strip, 'pointerdown', 0.4); + pointer(strip, 'pointermove', 0.5); + pointer(strip, 'pointerup', 0.5); + await card.updateComplete; + expect(windowStyle(card)).toBe('left:30%;width:40%'); + pointer(strip, 'pointerdown', 0.7); + pointer(strip, 'pointermove', 0.9); + pointer(strip, 'pointerup', 0.9); + await card.updateComplete; + expect(windowStyle(card)).toBe('left:30%;width:60%'); + pointer(strip, 'pointerdown', 0.3); + pointer(strip, 'pointermove', 0.5); + pointer(strip, 'pointerup', 0.5); + await card.updateComplete; + expect(windowStyle(card)).toBe('left:50%;width:40%'); + // A tap beside it centres it there; a moving pointer with nothing pressed does nothing. + pointer(strip, 'pointermove', 0.1); + pointer(strip, 'pointerdown', 0.2); + pointer(strip, 'pointerup', 0.2); + await card.updateComplete; + expect(windowStyle(card)).toBe('left:0%;width:40%'); + pointer(strip, 'pointerup', 0.2); + const win = q(card, '.overview-window'); + expect(win.getAttribute('aria-valuetext')).toMatch(/^Shown: .+ to .+/); + const key = (k: string) => { + const ev = new KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true }); + win.dispatchEvent(ev); + return ev.defaultPrevented; + }; + expect(key('ArrowRight')).toBe(true); + await card.updateComplete; + expect(windowStyle(card)).toBe('left:10%;width:40%'); + key('ArrowUp'); + await card.updateComplete; + expect(windowStyle(card)).toMatch(/width:26.67%/); + key('-'); + key('End'); + await card.updateComplete; + expect(windowStyle(card)).toBe('left:60%;width:40%'); + for (const k of ['ArrowLeft', 'ArrowDown', '+', '=', 'Home']) key(k); + expect(key('a')).toBe(false); + key('Escape'); + await card.updateComplete; + expect(windowStyle(card)).toBe('left:0%;width:100%'); + // A right-button press on the strip is ignored; a double click resets. + pointer(strip, 'pointerdown', 0.2, { button: 2 }); + pointer(strip, 'pointermove', 0.6, { button: 2 }); + pointer(strip, 'pointerdown', 0.2); + pointer(strip, 'pointermove', 0.6); + pointer(strip, 'pointercancel', 0.6); + await card.updateComplete; + strip.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })); + await card.updateComplete; + expect(windowStyle(card)).toBe('left:0%;width:100%'); + }); + + it('starts again from the whole span after previous / next, and when switched off', async () => { + const card = await mount({ period: { last: 24, unit: 'hour', navigate: true }, overview: true }); + wheel(q(card, '.plot-area'), 0.5, { deltaY: -200, ctrlKey: true }); + await card.updateComplete; + expect(q(card, '.zoom-reset')).not.toBeNull(); + q(card, '.period-prev').click(); + await card.updateComplete; + await new Promise((r) => setTimeout(r, 0)); + await card.updateComplete; + expect(q(card, '.zoom-reset')).toBeNull(); + const off = await mount({ zoom: false }); + expect(q(off, '.overview')).toBeNull(); + expect(q(off, '.plot-area').classList.contains('zoomable')).toBe(false); + }); + + it('checks its options, and forgets the zoom when it leaves the page', async () => { + const card = await mount(); + expect(() => + card.setConfig({ type: 'x', style: 'line', entities: ['sensor.in'], zoom: 'yes' } as unknown as CardConfig), + ).toThrow('"zoom" must be true or false'); + expect(() => + card.setConfig({ type: 'x', style: 'line', entities: ['sensor.in'], overview: 1 } as unknown as CardConfig), + ).toThrow('"overview" must be true or false'); + card.remove(); + }); +}); + +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 {}); +} + +describe('zoom in the editor', () => { + it('sets zoom and the overview strip from the Chart section', async () => { + await import('../src/editor/chartdeck-card-editor'); + 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: 'line', 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 names = JSON.stringify(form.schema); + expect(names).toContain('"zoom"'); + expect(names).toContain('"overview"'); + expect((form.data as Record).zoom).toBe(false); + form.dispatchEvent( + new CustomEvent('value-changed', { detail: { value: { ...(form.data as object), zoom: true, overview: true } } }), + ); + expect(events.at(-1)).toEqual({ ...raw, zoom: true, overview: true }); + }); +});