├── VERSION ├── hacs.json ├── info.md ├── LICENSE ├── README.md └── dual-gauge-card.js /VERSION: -------------------------------------------------------------------------------- 1 | 0.5.2 2 | -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Dual gauge card", 3 | "filename": "dual-gauge-card.js", 4 | "content_in_root": true 5 | } 6 | -------------------------------------------------------------------------------- /info.md: -------------------------------------------------------------------------------- 1 | # Dual gauge card 2 | 3 | Two gauges combined into one. 4 | 5 | ## Features 6 | * Use entities or their attributes 7 | * Change colors depending on values 8 | * Configure both gauges at once and/or separately 9 | 10 | 11 | ![dual-gauge-card-screenshot](https://user-images.githubusercontent.com/2353088/43733272-5f59d8fe-99b4-11e8-8161-0c55e096b862.png) 12 | 13 | 14 | See [README.md](https://github.com/custom-cards/dual-gauge-card/blob/master/README.md) for installation and configuration. 15 | 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Custom cards for Home Assistant 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Dual gauge card 2 | 3 | > [!IMPORTANT] 4 | > **Looking for new maintainer!** 5 | > 6 | > I can't find the time or inspiration to take care of this project anymore. 7 | > So if you'd like to become the new maintainer of this project please let me know via a Pull request! 8 | 9 | 10 | Two gauges in one, built mostly with CSS. 11 | 12 | [![MIT license](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) 13 | [![hacs_badge](https://img.shields.io/badge/HACS-Default-orange.svg)](https://github.com/custom-components/hacs) 14 | 15 | 16 | ![dual-gauge-card-screenshot](https://user-images.githubusercontent.com/2353088/43733272-5f59d8fe-99b4-11e8-8161-0c55e096b862.png) 17 | 18 | 19 | Heavily inspired by [ciotlosm's gauge-card](https://github.com/ciotlosm/custom-lovelace/), but completly written 20 | from scratch. 21 | 22 | ## Installation 23 | 24 | Use [HACS](https://github.com/custom-components/hacs) (recommended) 25 | or download [dual-gauge-card.js](https://github.com/custom-cards/dual-gauge-card/raw/master/dual-gauge-card.js) and place it in your www directory. 26 | 27 | In your ui-lovelace.yaml add this: 28 | ```yaml 29 | - url: /community_plugin/dual-gauge-card/dual-gauge-card.js 30 | type: js 31 | ``` 32 | 33 | If you don't use HACS please change the url accordingly. 34 | 35 | ## Config 36 | 37 | | Name | Type | Default | Description | 38 | |------------------|--------|---------|--------------------------------------------------| 39 | | title | string | | Common title | 40 | | min | int | 0 | minimum value | 41 | | max | int | 100 | maximum value | 42 | | colors | object | | color config (optional) | 43 | | background_color | string | | background color of the gauges | 44 | | shadeInner | bool | true | shade (darken) colors of the inner gauge by 25% | 45 | | cardwidth | int | 300 | width of the card in pixels (see below) | 46 | | outer | object | | config for the outer gauge | 47 | | inner | object | | config for the inner gauge | 48 | | precision | int | 2 | decimal precision | 49 | 50 | ### gauge config 51 | 52 | Both gauges have the same attributes: 53 | 54 | | Name | Type | Default | Description | 55 | |-----------|--------|---------|------------------------------------------------------------------| 56 | | entity | string | | entity id | 57 | | attribute | string | | use this attribute of the entity instead of its state (optional) | 58 | | label | string | | label for this gauges value (optional) | 59 | | unit | object | | unit to add to the value (optional) | 60 | | min | int | | minimum value | 61 | | max | int | | maximum value | 62 | | colors | object | | color config (optional) | 63 | | precision | int | 2 | decimal precision | 64 | 65 | ### cardwidth 66 | 67 | You may use the config value _cardwidth_ to set the overall width of the card as an absolute value in pixels. 68 | All elements of the gauge are sized relative to this so that the gauge scales to this, _but_ the card is not 69 | responsive for now, i.e. it doesn't resize automatically. 70 | 71 | 72 | ### color config 73 | 74 | Colors can be configured as list of pairs of each a color and a minimum value. 75 | 76 | If a gauges value is greater than or equal to one of those minimum values, the according color 77 | is used for that gauge. If no color is found, the last color in the list is used as a fallback. 78 | To use a single color regardless of the value just use a single list entry with any value to always trigger 79 | the fallback. 80 | 81 | By default, colors for the inner gauge are shaded by 25% (see option _shadeInner_). 82 | 83 | The list is automatically sorted so you don't need to do that in your config - but I recommend it anyways. 84 | 85 | ### common config vs. individual config 86 | 87 | Colors, as well as the min and max values, may be configured once for both gauges or individually for each gauge. Individual values override common values. 88 | 89 | ## Example 90 | 91 | The example on the screenshot is configured like this: 92 | ``` 93 | - type: custom:dual-gauge-card 94 | title: Living room 95 | min: -20 96 | max: 40 97 | outer: 98 | entity: climate.living_room 99 | attribute: current_temperature 100 | label: "Current" 101 | unit: "°C" 102 | inner: 103 | entity: climate.living_room 104 | label: "Target" 105 | attribute: temperature 106 | unit: "°C" 107 | colors: 108 | - color: "var(--label-badge-red)" 109 | value: 27.5 110 | - color: "var(--label-badge-green)" 111 | value: 25 112 | - color: "var(--label-badge-yellow)" 113 | value: 18 114 | - color: "var(--label-badge-blue)" 115 | value: 0 116 | - color: "var(--paper-blue-400)" 117 | value: -40 118 | ``` 119 | 120 | In this example, the outer gauge has individual min and max values and uses default colors, whereas the inner 121 | gauge has individual colors and uses the common min and max values. 122 | ``` 123 | - type: custom:dual-gauge-card 124 | title: Living room 125 | min: -20 126 | max: 40 127 | precision: 2 128 | outer: 129 | entity: climate.living_room 130 | attribute: current_temperature 131 | label: "Current" 132 | unit: "°C" 133 | min: -30 134 | max: 50 135 | inner: 136 | entity: climate.living_room 137 | label: "Target" 138 | attribute: temperature 139 | unit: "°C" 140 | colors: 141 | - color: "var(--label-badge-green)" 142 | value: 25 143 | - color: "var(--label-badge-yellow)" 144 | value: 18 145 | - color: "var(--label-badge-blue)" 146 | value: 0 147 | ``` 148 | 149 | -------------------------------------------------------------------------------- /dual-gauge-card.js: -------------------------------------------------------------------------------- 1 | class DualGaugeCard extends HTMLElement { 2 | set hass(hass) { 3 | this._hass = hass; 4 | 5 | if (!this.card) { 6 | this._createCard(); 7 | } 8 | 9 | this._update(); 10 | } 11 | 12 | setConfig(config) { 13 | if (!config.inner|| !config.inner.entity) { 14 | throw new Error('You need to define an entity for the inner gauge'); 15 | } 16 | if (!config.outer || !config.outer.entity) { 17 | throw new Error('You need to define an entity for the outer gauge'); 18 | } 19 | this.config = JSON.parse(JSON.stringify(config)); 20 | 21 | if (!this.config.min) { 22 | this.config.min = 0; 23 | } 24 | if (!this.config.max) { 25 | this.config.max = 100; 26 | } 27 | 28 | if (this.config.precision === undefined) { 29 | this.config.precision = 2; 30 | } 31 | if (this.config.inner.precision === undefined) { 32 | this.config.inner.precision = this.config.precision; 33 | } 34 | if (this.config.outer.precision === undefined) { 35 | this.config.outer.precision = this.config.precision; 36 | } 37 | 38 | if (!this.config.inner.min) { 39 | this.config.inner.min = this.config.min; 40 | } 41 | if (!this.config.inner.max) { 42 | this.config.inner.max = this.config.max; 43 | } 44 | 45 | if (!this.config.outer.min) { 46 | this.config.outer.min = this.config.min; 47 | } 48 | if (!this.config.outer.max) { 49 | this.config.outer.max = this.config.max; 50 | } 51 | 52 | if (!this.config.hasOwnProperty('shadeInner')) { 53 | this.config.shadeInner = true 54 | } 55 | 56 | if (!this.config.inner.colors) { 57 | this.config.inner.colors = this.config.colors; 58 | } 59 | if (!this.config.outer.colors) { 60 | this.config.outer.colors = this.config.colors; 61 | } 62 | 63 | if (this.config.inner.colors) { 64 | this.config.inner.colors.sort((a, b) => a.value < b.value ? 1 : -1); 65 | } 66 | if (this.config.outer.colors) { 67 | this.config.outer.colors.sort((a, b) => a.value < b.value ? 1 : -1); 68 | } 69 | } 70 | 71 | _update() { 72 | if (this._hass.states[this.config['inner'].entity] == undefined || 73 | this._hass.states[this.config['outer'].entity] == undefined) { 74 | console.warn("Undefined entity"); 75 | if (this.card) { 76 | this.card.remove(); 77 | } 78 | 79 | this.card = document.createElement('ha-card'); 80 | if (this.config.header) { 81 | this.card.header = this.config.header; 82 | } 83 | 84 | const content = document.createElement('p'); 85 | content.style.background = "#e8e87a"; 86 | content.style.padding = "8px"; 87 | content.innerHTML = "Error finding these entities:
- " + 88 | this.config['inner'].entity + 89 | "
- " + this.config['outer'].entity; 90 | this.card.appendChild(content); 91 | 92 | this.appendChild(this.card); 93 | return; 94 | } else if (this.card && this.card.firstElementChild.tagName.toLowerCase() == "p") { 95 | this._createCard(); 96 | } 97 | this._updateGauge('inner'); 98 | this._updateGauge('outer'); 99 | } 100 | 101 | _updateGauge(gauge) { 102 | const gaugeConfig = this.config[gauge]; 103 | const value = this._getEntityStateValue(this._hass.states[gaugeConfig.entity], gaugeConfig.attribute); 104 | this._setCssVariable(this.nodes.content, gauge + '-angle', this._calculateRotation(value, gaugeConfig)); 105 | this.nodes[gauge].value.innerHTML = this._formatValue(value, gaugeConfig); 106 | if (gaugeConfig.label) { 107 | this.nodes[gauge].label.innerHTML = gaugeConfig.label; 108 | } 109 | 110 | const color = this._findColor(value, gaugeConfig); 111 | if (color) { 112 | this._setCssVariable(this.nodes.content, gauge + '-color', color); 113 | } 114 | } 115 | 116 | _showDetails(gauge) { 117 | const event = new Event('hass-more-info', { 118 | bubbles: true, 119 | cancelable: false, 120 | composed: true 121 | }); 122 | event.detail = { 123 | entityId: this.config[gauge].entity 124 | }; 125 | this.card.dispatchEvent(event); 126 | return event; 127 | } 128 | 129 | _formatValue(value, gaugeConfig) { 130 | 131 | value = parseFloat(value); 132 | 133 | if (gaugeConfig.precision !== undefined) { 134 | value = value.toFixed(gaugeConfig.precision); 135 | } 136 | 137 | if (gaugeConfig.unit) { 138 | value = value.toString() + gaugeConfig.unit; 139 | } 140 | 141 | return value; 142 | } 143 | 144 | _getEntityStateValue(entity, attribute) { 145 | if (!attribute) { 146 | if(isNaN(entity.state)) return "-" ; //check if entity state is NaN 147 | else return entity.state; 148 | } 149 | 150 | // return entity.attributes[attribute]; 151 | if(isNaN(entity.attributes[attribute])) return "-" ; //check if entity attribute is NaN 152 | else return entity.attributes[attribute]; 153 | } 154 | 155 | _calculateRotation(value, gaugeConfig) { 156 | if(isNaN(value)) return '180deg'; //check if value is NaN 157 | const maxTurnValue = Math.min(Math.max(value, gaugeConfig.min), gaugeConfig.max); 158 | return (180 + (5 * (maxTurnValue - gaugeConfig.min)) / (gaugeConfig.max - gaugeConfig.min) / 10 * 360) + 'deg'; 159 | } 160 | 161 | _findColor(value, gaugeConfig) { 162 | if (!gaugeConfig.colors) return; 163 | 164 | var i = 0, 165 | count = gaugeConfig.colors.length - 1; 166 | for (; i < count; i++) { 167 | if (value >= gaugeConfig.colors[i].value) return gaugeConfig.colors[i].color; 168 | } 169 | 170 | return gaugeConfig.colors[count].color; 171 | } 172 | 173 | _createCard() { 174 | if (this.card) { 175 | this.card.remove(); 176 | } 177 | 178 | this.card = document.createElement('ha-card'); 179 | if (this.config.header) { 180 | this.card.header = this.config.header; 181 | } 182 | 183 | const content = document.createElement('div'); 184 | this.card.appendChild(content); 185 | 186 | this.styles = document.createElement('style'); 187 | this.card.appendChild(this.styles); 188 | 189 | this.appendChild(this.card); 190 | 191 | content.classList.add('gauge-dual-card'); 192 | content.innerHTML = ` 193 |
194 |
195 |
196 |
197 |
198 | 199 |
200 |
201 |
202 | 203 |
204 |
205 |
206 | 207 | 208 |
209 |
210 | 211 |
212 |
213 | 214 |
215 | 216 |
217 |
218 | `; 219 | 220 | this.nodes = { 221 | content: content, 222 | title: content.querySelector('.gauge-title'), 223 | outer: { 224 | value: content.querySelector('.gauge-value-outer'), 225 | label: content.querySelector('.gauge-label-outer'), 226 | }, 227 | inner: { 228 | value: content.querySelector('.gauge-value-inner'), 229 | label: content.querySelector('.gauge-label-inner'), 230 | } 231 | }; 232 | 233 | if (this.config.title) { 234 | this.nodes.title.innerHTML = this.config.title; 235 | this.nodes.title.addEventListener('click', event => { 236 | this._showDetails('outer'); 237 | }); 238 | } 239 | 240 | this.nodes.outer.value.addEventListener('click', event => { 241 | this._showDetails('outer'); 242 | }); 243 | this.nodes.inner.value.addEventListener('click', event => { 244 | this._showDetails('inner'); 245 | }); 246 | 247 | if (this.config.shadeInner) { 248 | this.nodes.content.classList.add('shadeInner'); 249 | } 250 | 251 | if (this.config.cardwidth) { 252 | this._setCssVariable(this.nodes.content, 'gauge-card-width', this.config.cardwidth + 'px'); 253 | } 254 | 255 | if (this.config.background_color) { 256 | this._setCssVariable(this.nodes.content, 'gauge-background-color', this.config.background_color); 257 | } 258 | 259 | this._initStyles(); 260 | } 261 | 262 | _setCssVariable(node, variable, value) { 263 | node.style.setProperty('--' + variable, value); 264 | } 265 | 266 | _initStyles() { 267 | this.styles.innerHTML = ` 268 | .gauge-dual-card { 269 | --gauge-card-width:300px; 270 | --outer-value: 50; 271 | --inner-value: 50; 272 | --outer-color: var(--primary-color); 273 | --inner-color: var(--primary-color); 274 | --gauge-background-color: var(--secondary-background-color); 275 | 276 | --outer-angle: 90deg; 277 | --inner-angle: 90deg; 278 | --gauge-width: calc(var(--gauge-card-width) / 10.5); 279 | --value-font-size: calc(var(--gauge-card-width) / 17); 280 | --title-font-size: calc(var(--gauge-card-width) / 14); 281 | --label-font-size: calc(var(--gauge-card-width) / 20); 282 | 283 | width: var(--gauge-card-width); 284 | padding: 16px; 285 | box-sizing:border-box; 286 | margin: 6px auto; 287 | } 288 | 289 | .gauge-dual-card div { 290 | box-sizing:border-box 291 | } 292 | .gauge-dual { 293 | overflow: hidden; 294 | width: 100%; 295 | height: 0; 296 | padding-bottom: 50%; 297 | } 298 | 299 | .gauge-frame { 300 | width: 100%; 301 | height: 0; 302 | padding-bottom:100%; 303 | position: relative; 304 | } 305 | 306 | .circle { 307 | position: absolute; 308 | top: 0; 309 | left: 0; 310 | width: 100%; 311 | height: 200%; 312 | border-radius: 100%; 313 | border: var(--gauge-width) solid; 314 | transition: border-color .5s linear; 315 | } 316 | 317 | .circle-container { 318 | position: absolute; 319 | transform-origin: 50% 100%; 320 | top: 0; 321 | left: 0; 322 | height: 50%; 323 | width: 100%; 324 | overflow: hidden; 325 | transition: transform .5s linear; 326 | } 327 | 328 | .small-circle .circle { 329 | top: 20%; 330 | left: 10%; 331 | width: 80%; 332 | height: 160%; 333 | } 334 | 335 | .gauge-background .circle { 336 | border: calc(var(--gauge-width) * 2 - 2px) solid var(--gauge-background-color); 337 | } 338 | 339 | .gauge-title { 340 | position: absolute; 341 | bottom: 51%; 342 | margin-bottom: 0.1em; 343 | text-align: center; 344 | width: 100%; 345 | font-size: var(--title-font-size); 346 | } 347 | 348 | .gauge-value, .gauge-label { 349 | position: absolute; 350 | bottom: 50%; 351 | width: 81%; 352 | text-align: center; 353 | } 354 | 355 | .gauge-value { 356 | margin-bottom:15%; 357 | font-size: var(--value-font-size); 358 | font-weight: bold; 359 | } 360 | 361 | .gauge-label { 362 | font-size: var(--label-font-size); 363 | margin-bottom:10%; 364 | } 365 | 366 | .gauge-value-outer, .gauge-label-outer { 367 | color: var(--outer-color); 368 | } 369 | 370 | 371 | .gauge-value-inner, .gauge-label-inner { 372 | right: 0; 373 | color: var(--inner-color); 374 | } 375 | 376 | 377 | .outer-gauge { 378 | transform: rotate(var(--outer-angle)); 379 | } 380 | 381 | .outer-gauge .circle { 382 | border-color: var(--outer-color); 383 | } 384 | 385 | 386 | .inner-gauge { 387 | transform: rotate(var(--inner-angle)); 388 | } 389 | 390 | .inner-gauge .circle { 391 | border-color: var(--inner-color); 392 | } 393 | 394 | .shadeInner .gauge-value-inner, .shadeInner .gauge-label-inner, .shadeInner .inner-gauge .circle { 395 | filter: brightness(75%); 396 | } 397 | 398 | `; 399 | } 400 | } 401 | 402 | customElements.define('dual-gauge-card', DualGaugeCard); 403 | --------------------------------------------------------------------------------