├── ha-tsconfig.json ├── images └── astroweather-card.png ├── hacs.json ├── .github └── workflows │ ├── build.yaml │ ├── validate.yaml │ └── release.yaml ├── rollup.config.js ├── tsconfig.json ├── LICENSE ├── package.json ├── .gitignore ├── src ├── style.ts ├── astroweather-card-editor.ts └── astroweather-card.ts └── README.md /ha-tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "types": ["home-assistant-js-websocket"] 4 | } 5 | } -------------------------------------------------------------------------------- /images/astroweather-card.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mawinkler/astroweather-card/HEAD/images/astroweather-card.png -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "AstroWeather Card", 3 | "render_readme": true, 4 | "filename": "astroweather-card.js" 5 | } -------------------------------------------------------------------------------- /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: 'Build' 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | pull_request: 7 | 8 | jobs: 9 | build: 10 | name: Test build 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v1 14 | - name: Build 15 | run: | 16 | npm install 17 | npm run build 18 | -------------------------------------------------------------------------------- /.github/workflows/validate.yaml: -------------------------------------------------------------------------------- 1 | name: Validate 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: "0 0 * * *" 8 | workflow_dispatch: 9 | 10 | jobs: 11 | validate-hacs: 12 | runs-on: "ubuntu-latest" 13 | steps: 14 | - uses: "actions/checkout@v2" 15 | - name: HACS validation 16 | uses: "hacs/action@main" 17 | with: 18 | category: "plugin" 19 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import resolve from "@rollup/plugin-node-resolve"; 2 | import commonjs from "@rollup/plugin-commonjs"; 3 | import typescript from "@rollup/plugin-typescript"; 4 | 5 | export default { 6 | input: ["src/astroweather-card.ts"], 7 | output: { 8 | dir: "dist", 9 | format: "es", 10 | entryFileNames: "[name].js", 11 | sourcemap: false, 12 | }, 13 | context: "this", 14 | plugins: [ 15 | resolve(), 16 | commonjs(), 17 | typescript(), 18 | ], 19 | external: [], 20 | }; 21 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "lib": ["ES2022", "DOM"], 7 | "noEmit": true, 8 | "noUnusedParameters": true, 9 | "outDir": "./dist", 10 | "strict": true, 11 | "noImplicitAny": false, 12 | "esModuleInterop": true, 13 | "experimentalDecorators": true, 14 | "useDefineForClassFields": false, 15 | "allowSyntheticDefaultImports": true 16 | }, 17 | "include": ["src/*.ts"] 18 | } 19 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*.*.*" 7 | workflow_dispatch: 8 | 9 | jobs: 10 | release: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v4 15 | 16 | - name: Setup Node.js 17 | uses: actions/setup-node@v4 18 | with: 19 | node-version: 20 20 | cache: npm 21 | 22 | - name: Install dependencies 23 | run: npm ci 24 | 25 | - name: Build 26 | run: npm run build 27 | 28 | - name: Release 29 | uses: softprops/action-gh-release@v2 30 | with: 31 | draft: true 32 | generate_release_notes: false 33 | files: dist/astroweather-card.js 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Markus Winkler 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 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "module", 3 | "name": "astroweather-card", 4 | "version": "0.74.2", 5 | "description": "This is a custom weather card for my custom [Home Assistant](https://www.home-assistant.io/) integration [AstroWeather](https://github.com/mawinkler/astroweather). It requires an AstroWeather version `0.20.0+`.", 6 | "keywords": [ 7 | "home-assistant", 8 | "homeassistant", 9 | "hass", 10 | "automation", 11 | "lovelace", 12 | "custom-cards", 13 | "astroweather" 14 | ], 15 | "module": "dist/astroweather-card.js", 16 | "repository": { 17 | "type": "git", 18 | "url": "git@github.com:mawinkler/astroweather-card.git" 19 | }, 20 | "author": "Markus Winkler", 21 | "license": "MIT", 22 | "scripts": { 23 | "build": "rollup -c", 24 | "watch": "rollup -c --watch", 25 | "lint": "eslint ./src --ext .ts", 26 | "format": "prettier --write ./src", 27 | "test": "jest" 28 | }, 29 | "dependencies": { 30 | "chart.js": "^4.4.8", 31 | "custom-card-helpers": "^1.9.0", 32 | "lit": "^3.2.1", 33 | "tslib": "^2.8.1" 34 | }, 35 | "devDependencies": { 36 | "@rollup/plugin-commonjs": "^28.0.3", 37 | "@rollup/plugin-node-resolve": "^15.3.1", 38 | "@rollup/plugin-typescript": "^11.1.6", 39 | "depcheck": "^1.4.7", 40 | "eslint": "^8.57.1", 41 | "jest": "^29.7.0", 42 | "prettier": "^3.5.3", 43 | "rollup": "^4.37.0", 44 | "typescript": "^5.8.2" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.toptal.com/developers/gitignore/api/node,visualstudiocode 3 | # Edit at https://www.toptal.com/developers/gitignore?templates=node,visualstudiocode 4 | 5 | ### Node ### 6 | # Logs 7 | logs 8 | *.log 9 | npm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | lerna-debug.log* 13 | .pnpm-debug.log* 14 | 15 | # Diagnostic reports (https://nodejs.org/api/report.html) 16 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 17 | 18 | # Runtime data 19 | pids 20 | *.pid 21 | *.seed 22 | *.pid.lock 23 | 24 | # Directory for instrumented libs generated by jscoverage/JSCover 25 | lib-cov 26 | 27 | # Coverage directory used by tools like istanbul 28 | coverage 29 | *.lcov 30 | 31 | # nyc test coverage 32 | .nyc_output 33 | 34 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 35 | .grunt 36 | 37 | # Bower dependency directory (https://bower.io/) 38 | bower_components 39 | 40 | # node-waf configuration 41 | .lock-wscript 42 | 43 | # Compiled binary addons (https://nodejs.org/api/addons.html) 44 | build/Release 45 | 46 | # Dependency directories 47 | node_modules/ 48 | dist/ 49 | jspm_packages/ 50 | 51 | # Snowpack dependency directory (https://snowpack.dev/) 52 | web_modules/ 53 | 54 | # TypeScript cache 55 | *.tsbuildinfo 56 | 57 | # Optional npm cache directory 58 | .npm 59 | 60 | # Optional eslint cache 61 | .eslintcache 62 | 63 | # Optional stylelint cache 64 | .stylelintcache 65 | 66 | # Microbundle cache 67 | .rpt2_cache/ 68 | .rts2_cache_cjs/ 69 | .rts2_cache_es/ 70 | .rts2_cache_umd/ 71 | 72 | # Optional REPL history 73 | .node_repl_history 74 | 75 | # Output of 'npm pack' 76 | *.tgz 77 | 78 | # Yarn Integrity file 79 | .yarn-integrity 80 | 81 | # dotenv environment variable files 82 | .env 83 | .env.development.local 84 | .env.test.local 85 | .env.production.local 86 | .env.local 87 | 88 | # parcel-bundler cache (https://parceljs.org/) 89 | .cache 90 | .parcel-cache 91 | 92 | # Next.js build output 93 | .next 94 | out 95 | 96 | # Nuxt.js build / generate output 97 | .nuxt 98 | 99 | # Gatsby files 100 | .cache/ 101 | # Comment in the public line in if your project uses Gatsby and not Next.js 102 | # https://nextjs.org/blog/next-9-1#public-directory-support 103 | # public 104 | 105 | # vuepress build output 106 | .vuepress/dist 107 | 108 | # vuepress v2.x temp and cache directory 109 | .temp 110 | 111 | # Docusaurus cache and generated files 112 | .docusaurus 113 | 114 | # Serverless directories 115 | .serverless/ 116 | 117 | # FuseBox cache 118 | .fusebox/ 119 | 120 | # DynamoDB Local files 121 | .dynamodb/ 122 | 123 | # TernJS port file 124 | .tern-port 125 | 126 | # Stores VSCode versions used for testing VSCode extensions 127 | .vscode-test 128 | .vscode 129 | 130 | # yarn v2 131 | .yarn/cache 132 | .yarn/unplugged 133 | .yarn/build-state.yml 134 | .yarn/install-state.gz 135 | .pnp.* 136 | 137 | ### Node Patch ### 138 | # Serverless Webpack directories 139 | .webpack/ 140 | 141 | # Optional stylelint cache 142 | 143 | # SvelteKit build / generate output 144 | .svelte-kit 145 | 146 | ### VisualStudioCode ### 147 | .vscode/* 148 | !.vscode/settings.json 149 | !.vscode/tasks.json 150 | !.vscode/launch.json 151 | !.vscode/extensions.json 152 | !.vscode/*.code-snippets 153 | 154 | # Local History for Visual Studio Code 155 | .history/ 156 | 157 | # Built Visual Studio Code Extensions 158 | *.vsix 159 | 160 | ### VisualStudioCode Patch ### 161 | # Ignore all local history of files 162 | .history 163 | .ionide 164 | 165 | # Support for Project snippet scope 166 | 167 | # End of https://www.toptal.com/developers/gitignore/api/node,visualstudiocode -------------------------------------------------------------------------------- /src/style.ts: -------------------------------------------------------------------------------- 1 | import { css } from "lit"; 2 | 3 | const style = css` 4 | .chart-container { 5 | position: relative; 6 | aspect-ratio: 16/8; 7 | height: auto; 8 | width: 100%; 9 | will-change: transform; 10 | transform: translateZ(0); /* helps WKWebView compositing */ 11 | backface-visibility: hidden; 12 | contain: size layout paint; /* isolate layout/paint */ 13 | } 14 | canvas { 15 | width: 100% !important; 16 | height: 100% !important; 17 | } 18 | 19 | ha-card { 20 | cursor: pointer; 21 | overflow: hidden; 22 | display: flex 23 | letter-spacing: -0.288px 24 | font-weight: 400; 25 | } 26 | 27 | .spacer { 28 | } 29 | 30 | .clear { 31 | clear: both; 32 | } 33 | 34 | .current { 35 | margin-bottom: 4px; 36 | font-size: 24px; 37 | color: var(--primary-text-color); 38 | line-height: 48px; 39 | align-items: center 40 | } 41 | 42 | .current-location { 43 | position: relative; 44 | font-size: 24px; 45 | } 46 | 47 | .current-condition { 48 | position: absolute; 49 | // font-size: 14px; 50 | right: 16px; 51 | } 52 | 53 | .details { 54 | font-size: 14px; 55 | display: flex; 56 | flex-flow: row wrap; 57 | justify-content: space-between; 58 | color: var(--primary-text-color); 59 | list-style: none; 60 | margin-bottom: 16px; 61 | } 62 | 63 | .details ha-icon { 64 | height: 12px; 65 | font-size: 14px; 66 | color: var(--paper-item-icon-color); 67 | } 68 | 69 | .details li { 70 | flex-basis: auto; 71 | width: 50%; 72 | } 73 | 74 | .details li:nth-child(2n) { 75 | text-align: right; 76 | width: 50%; 77 | } 78 | 79 | .details li:nth-child(2n) ha-icon { 80 | height: 12px; 81 | font-size: 14px; 82 | margin-right: 0; 83 | margin-left: 5px; 84 | float: right; 85 | } 86 | 87 | .deepskyforecast { 88 | font-size: 14px; 89 | display: flex; 90 | flex-flow: column wrap; 91 | justify-content: space-between; 92 | color: var(--primary-text-color); 93 | list-style: none; 94 | margin-bottom: 16px; 95 | } 96 | 97 | .deepskyforecast ha-icon { 98 | height: 12px; 99 | font-size: 14px; 100 | color: var(--paper-item-icon-color); 101 | } 102 | 103 | .deepskyforecast li { 104 | flex-basis: auto; 105 | width: 100%; 106 | } 107 | 108 | .unit { 109 | font-size: 12px; 110 | } 111 | 112 | .forecast { 113 | width: 100%; 114 | margin: 0 auto; 115 | display: flex; 116 | margin-bottom: 16px; 117 | } 118 | 119 | .forecast ha-icon { 120 | height: 12px; 121 | font-size: 12px; 122 | margin-right: 5px; 123 | color: var(--paper-item-icon-color); 124 | text-align: left; 125 | } 126 | 127 | .forecastrow { 128 | font-size: 14px; 129 | flex: 1; 130 | display: block; 131 | text-align: right; 132 | color: var(--primary-text-color); 133 | line-height: 2; 134 | box-sizing: border-box; 135 | } 136 | 137 | .forecastrowname { 138 | text-transform: uppercase; 139 | } 140 | 141 | .forecast .forecastrow:first-child { 142 | margin-left: 0; 143 | text-align: left; 144 | } 145 | 146 | .forecast .forecastrow:nth-last-child(1) { 147 | border-right: none; 148 | margin -right: 0; 149 | } 150 | 151 | .value_item { 152 | } 153 | 154 | .value_item_bold { 155 | font-weight: bold; 156 | } 157 | 158 | .label { 159 | font-weight: bold; 160 | text-align: center; 161 | } 162 | 163 | .center { 164 | display: block; 165 | margin-top: auto; 166 | margin-bottom: auto; 167 | margin-left: auto; 168 | margin-right: auto; 169 | background: var( 170 | --ha-card-background, 171 | var(--card-background-color, white) 172 | ); 173 | box-shadow: var(--ha-card-box-shadow, none); 174 | color: var(--primary-text-color); 175 | transition: all 0.3s ease-out 0s; 176 | position: relative; 177 | border-radius: var(--ha-card-border-radius, 12px); 178 | width: 100%; 179 | } 180 | 181 | .withMargin { 182 | margin: 5%; 183 | } 184 | 185 | .withoutMargin { 186 | margin: 0; 187 | } 188 | `; 189 | 190 | export default style; 191 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Lovelace AstroWeather Card 2 | 3 | ![GitHub release](https://img.shields.io/badge/release-v0.74.2-blue) 4 | [![hacs_badge](https://img.shields.io/badge/HACS-Default-orange.svg)](https://github.com/custom-components/hacs) 5 | 6 | This is a custom weather card for my custom [Home Assistant](https://www.home-assistant.io/) integration [AstroWeather](https://github.com/mawinkler/astroweather). 7 | 8 | AstroWeather Card 9 | 10 | The percentages that are calculated and graphed are to be read like 100% is perfect, 0% is bad. This also means, that a percentage of e.g. 87% for cloud does stand for a sky being nearly cloud free ;-). 11 | 12 | Thanks for all picking this card up. 13 | 14 | PS: will redo the screenshot with better conditions... 15 | 16 | ## Installation 17 | 18 | ### HACS installation 19 | 20 | This Integration is part of the default HACS store, so go to the HACS page and search for *AstroWeather* within the Lovelace category. 21 | 22 | ### Manual Installation 23 | 24 | To add the AstroWeather card to your installation, download the `astroweather-card.js` from the [release](https://raw.githubusercontent.com/mawinkler/astroweather-card/main/dist/astroweather-card.js)-page and copy it to `/config/www/custom-lovelace/astroweather-card/`. 25 | 26 | Add the card to your dashboard by choosing `[Edit Dashboard]` and then `[Manage Resources]`. 27 | 28 | Use `/local/custom-lovelace/astroweather-card/astroweather-card.js` for the URL parameter and set the resource type to `JavaScript Module`. 29 | 30 | Alternatively, add the following to resources in your lovelace config: 31 | 32 | ```yaml 33 | resources: 34 | - url: /local/custom-lovelace/astroweather-card/astroweather-card.js 35 | type: module 36 | ``` 37 | 38 | ## Configuration 39 | 40 | And add a card with type `custom:astroweather-card`: 41 | 42 | ```yaml 43 | type: custom:astroweather-card 44 | entity: weather.astroweather_backyard 45 | details: true 46 | current: true 47 | deepskydetails: true 48 | forecast: false 49 | graph: true 50 | graph_condition: true 51 | graph_cloudless: true 52 | graph_seeing: false 53 | graph_transparency: false 54 | graph_calm: true 55 | graph_li: false 56 | graph_precip: true 57 | graph_fog: true 58 | number_of_forecasts: "48" 59 | line_color_condition: "#f07178" 60 | line_color_condition_night: "#eeffff" 61 | line_color_cloudless: "#c3e88d" 62 | line_color_seeing: "#ffcb6b" 63 | line_color_transparency: "#82aaff" 64 | line_color_calm: "#ff5370" 65 | line_color_li: "#89ddff" 66 | line_color_precip: "#82aaff" 67 | line_color_fog: "#dde8ff" 68 | ``` 69 | 70 | Optionally, you can define custom tap actions to happen when clicking on the card. Below are some examples: 71 | 72 | ```yaml 73 | tap_action: 74 | action: more-info 75 | ``` 76 | 77 | ```yaml 78 | # Assumes an input boolean to put your house into stargazer mode 79 | tap_action: 80 | action: call-service 81 | service: input_boolean.toggle 82 | data: 83 | entity_id: input_boolean.stargazer_mode 84 | ``` 85 | 86 | ```yaml 87 | # Assumes you have a view called astroweather 88 | tap_action: 89 | action: navigate 90 | navigation_path: /lovelace/astroweather 91 | ``` 92 | 93 | ```yaml 94 | # Navigates you to Meteoblue seeing forecast 95 | tap_action: 96 | action: url 97 | url_path: https://www.meteoblue.com/en/weather/outdoorsports/seeing 98 | ``` 99 | 100 | ```yaml 101 | # Assumes you have UpTonight and browser_mod 102 | tap_action: 103 | action: fire-dom-event 104 | browser_mod: 105 | service: browser_mod.popup 106 | data: 107 | title: UpTonight 108 | size: wide 109 | content: 110 | type: picture-entity 111 | entity: image.uptonight 112 | ``` 113 | 114 | You can choose wich elements of the weather card you want to show: 115 | 116 | - The title and current view conditions. 117 | - The details about the current weather. 118 | - The deep sky forecast for today and tomorrow in plain text. 119 | - The hourly forecast for clouds, seeing, transparency, view conditions and temperature. 120 | - The graphical forecast. You can configure which conditions to display and define the line colors. 121 | 122 | If you enable either the forecast or the graph you can define the number of future forecasts in hourly steps. It is best to only choose the forecast table or the graphical forcast since the graphical variant can display 48hs easily which is not possible with the table. You might create a dedicated card for the table view, simply clone the card and enable forecast only. 123 | 124 | ```yaml 125 | type: custom:astroweather-card 126 | entity: weather.astroweather_LONGITUDE_LATITUDE 127 | name: Backyard 128 | current: false 129 | details: false 130 | deepskydetails: false 131 | forecast: true 132 | graph: false 133 | number_of_forecasts: '8' 134 | ``` 135 | 136 | The card owns a card editor which pops up if you click on `[Edit]` which being in edit mode of your view. 137 | 138 | ## TS Migration 139 | 140 | ```sh 141 | npm install --save-dev rollup @rollup/plugin-node-resolve @rollup/plugin-typescript @rollup/plugin-commonjs tslib custom-card-helpers 142 | 143 | npm install 144 | export PATH=$PWD/node_modules/.bin:$PATH 145 | 146 | npm run build 147 | ``` 148 | 149 | ## Development Instructions 150 | 151 | To do development work on this card (either for your personal use, or to contribute via pull requests), follow these steps: 152 | 153 | 1. Create a fork of this repository on GitHub 154 | 2. Download and setup the repository on your local machine, by running: 155 | ``` 156 | git clone https://github.com/mawinkler/astroweather-card 157 | cd astroweather-card 158 | git remote add upstream https://github.com/mawinkler/astroweather-card 159 | ``` 160 | 3. Once the repository is setup, install the npm dependencies with `npm install` 161 | 4. Make local edits as needed to the grocy chores card. 162 | 5. To test changes, run `npm run build`. This will create a compiled version of the card in `dist/astroweather-card.js` and `dist/astroweather-card-editor.js`. 163 | 6. Copy the compiled card in the `dist/` folder to your Home Assistant installation and update the dashboard resources to point to it. Make sure the cache is cleared each time you try updating with new changes. 164 | 7. Push the changes back to your GitHub origin, and open a pull request if you want to contribute them to the main repository. 165 | -------------------------------------------------------------------------------- /src/astroweather-card-editor.ts: -------------------------------------------------------------------------------- 1 | import { LitElement, html, TemplateResult, css } from "lit"; 2 | import { 3 | HomeAssistant, 4 | LovelaceCardEditor, 5 | LovelaceCardConfig, 6 | fireEvent, 7 | } from "custom-card-helpers"; 8 | import { customElement, property, state } from "lit/decorators.js"; 9 | 10 | interface CardConfig extends LovelaceCardConfig { 11 | entity: string; 12 | [key: string]: any; 13 | } 14 | 15 | @customElement("astroweather-card-editor") 16 | export class AstroWeatherCardEditor 17 | extends LitElement 18 | implements LovelaceCardEditor 19 | { 20 | @property({ attribute: false }) public hass?: HomeAssistant; 21 | 22 | @state() private _hass!: HomeAssistant; 23 | @state() private _config!: CardConfig; 24 | @state() private _helpers?: any; 25 | private _glanceCard?: any; 26 | 27 | private _initialized = false; 28 | 29 | public setConfig(config: CardConfig): void { 30 | this._config = { ...config }; 31 | this.loadCardHelpers(); 32 | this.render(); 33 | } 34 | 35 | protected shouldUpdate(): boolean { 36 | if (!this._initialized) { 37 | this._initialize(); 38 | } 39 | return true; 40 | } 41 | 42 | private _initialize(): void { 43 | if (this.hass === undefined) return; 44 | if (this._config === undefined) return; 45 | if (this._helpers === undefined) return; 46 | this._initialized = true; 47 | } 48 | 49 | private async loadCardHelpers(): Promise { 50 | this._helpers = await (window as any).loadCardHelpers(); 51 | 52 | // Load the ha-entity-picker 53 | if (!this._glanceCard) { 54 | this._glanceCard = customElements.get("hui-glance-card"); 55 | this._glanceCard.getConfigElement().then(() => this.requestUpdate()); 56 | } 57 | } 58 | 59 | firstUpdated() { 60 | this._helpers.then((help) => { 61 | if (help.importMoreInfoControl) { 62 | help.importMoreInfoControl("fan"); 63 | } 64 | }); 65 | } 66 | 67 | static get properties() { 68 | return { hass: {}, _config: {} }; 69 | } 70 | 71 | get _entity() { 72 | return this._config?.entity || ""; 73 | } 74 | 75 | get _current() { 76 | return this._config?.current !== false; 77 | } 78 | 79 | get _details() { 80 | return this._config?.details !== false; 81 | } 82 | 83 | get _deepskydetails() { 84 | return this._config?.deepskydetails !== false; 85 | } 86 | 87 | get _forecast() { 88 | return this._config?.forecast !== false; 89 | } 90 | 91 | get _graph() { 92 | return this._config?.graph !== false; 93 | } 94 | 95 | get _graph_condition() { 96 | return this._config?.graph_condition !== false; 97 | } 98 | 99 | get _graph_cloudless() { 100 | return this._config?.graph_cloudless !== false; 101 | } 102 | 103 | get _graph_seeing() { 104 | return this._config?.graph_seeing !== false; 105 | } 106 | 107 | get _graph_transparency() { 108 | return this._config?.graph_transparency !== false; 109 | } 110 | 111 | get _graph_calm() { 112 | return this._config?.graph_calm !== false; 113 | } 114 | 115 | get _graph_li() { 116 | return this._config?.graph_li !== false; 117 | } 118 | 119 | get _graph_precip() { 120 | return this._config?.graph_precip !== false; 121 | } 122 | 123 | get _graph_fog() { 124 | return this._config?.graph_fog !== false; 125 | } 126 | 127 | get _line_color_condition() { 128 | return this._config?.line_color_condition || "#f07178"; 129 | } 130 | 131 | get _line_color_condition_night() { 132 | return this._config?.line_color_condition_night || "#eeffff"; 133 | } 134 | 135 | get _line_color_cloudless() { 136 | return this._config?.line_color_cloudless || "#c3e88d"; 137 | } 138 | 139 | get _line_color_seeing() { 140 | return this._config?.line_color_seeing || "#ffcb6b"; 141 | } 142 | 143 | get _line_color_transparency() { 144 | return this._config?.line_color_transparency || "#82aaff"; 145 | } 146 | 147 | get _line_color_calm() { 148 | return this._config?.line_color_calm || "#ff5370"; 149 | } 150 | 151 | get _line_color_li() { 152 | return this._config?.line_color_li || "#89ddff"; 153 | } 154 | 155 | get _line_color_precip() { 156 | return this._config?.line_color_precip || "#82aaff"; 157 | } 158 | 159 | get _line_color_fog() { 160 | return this._config?.line_color_fog || "#dde8ff"; 161 | } 162 | 163 | get _hourly_forecast() { 164 | return true; 165 | // return this._config?.hourly_forecast !== false; 166 | } 167 | 168 | get _number_of_forecasts() { 169 | return this._config?.number_of_forecasts || 8; 170 | } 171 | 172 | protected render(): TemplateResult | void { 173 | if (!this.hass || !this._config) { 174 | return html``; 175 | } 176 | 177 | const entities = Object.keys(this.hass.states).filter((e) => 178 | e.startsWith("weather.astroweather") 179 | ); 180 | 181 | return html` 182 |
183 |
184 | 193 |
194 |
195 | Show current 201 |
202 |
203 | Show details 209 |
210 |
211 | Show deepsky details 217 |
218 |
219 | Show forecast 225 |
226 |
227 | Show graph 233 |
234 | 242 |
243 | ${this._graph == true || this._forecast == true 244 | ? html`` 253 | : ""} 254 | ${this._graph == true 255 | ? html`
256 |
257 | 262 | Graph condition 263 |
264 | 275 |
276 |
277 |
278 | Graph cloudless 284 |
285 | 286 | 292 | 293 |
294 |
295 |
296 | Graph seeing 302 |
303 | 304 | 310 | 311 |
312 |
313 |
314 | Graph transparency 320 |
321 | 322 | 328 | 329 |
330 |
331 |
332 | Graph calmness 338 |
339 | 340 | 346 | 347 |
348 |
349 |
350 | Graph lifted index 356 |
357 | 358 | 364 | 365 |
366 |
367 |
368 | Graph precipitation 374 |
375 | 376 | 382 | 383 |
384 |
385 |
386 | Graph fog density 392 |
393 | 394 | 400 | 401 |
402 |
403 |
` 404 | : ""} 405 |
406 |
407 | `; 408 | } 409 | 410 | _valueChanged(ev) { 411 | if (!this.hass || !this._config) { 412 | return; 413 | } 414 | const target = ev.target; 415 | if (this[`_${target.configValue}`] === target.value) { 416 | return; 417 | } 418 | if (target.configValue) { 419 | if (target.value === "") { 420 | delete this._config[target.configValue]; 421 | } 422 | if (target.value !== undefined) { 423 | this._config = { 424 | ...this._config, 425 | [target.configValue]: target.value, 426 | }; 427 | } else { 428 | this._config = { 429 | ...this._config, 430 | [target.configValue]: 431 | target.checked !== undefined ? target.checked : target.value, 432 | }; 433 | } 434 | } 435 | fireEvent(this, "config-changed", { config: this._config }); 436 | } 437 | 438 | static get styles() { 439 | return css` 440 | .switches { 441 | margin: 8px 0; 442 | display: flex; 443 | justify-content: space-between; 444 | flex-direction: row; 445 | display: block; 446 | } 447 | .switch { 448 | margin-bottom: 12px; 449 | display: flex; 450 | align-items: center; 451 | justify-items: center; 452 | } 453 | .switches span { 454 | padding: 0 16px; 455 | } 456 | ha-textfield { 457 | display: block; 458 | } 459 | `; 460 | } 461 | } 462 | -------------------------------------------------------------------------------- /src/astroweather-card.ts: -------------------------------------------------------------------------------- 1 | import { LitElement, html, css, PropertyValues } from "lit"; 2 | import { customElement, property, state } from "lit/decorators.js"; 3 | import { HomeAssistant, LovelaceCardEditor } from "custom-card-helpers"; 4 | import Chart from "chart.js/auto"; 5 | import style from "./style"; 6 | import "./astroweather-card-editor"; 7 | 8 | const CARD_VERSION = "v0.74.2"; 9 | 10 | console.info( 11 | `%c ASTROWEATHER-CARD \n%c Version ${CARD_VERSION} `, 12 | "color: yellow; font-weight: bold; background: navy", 13 | "color: white; font-weight: bold; background: black" 14 | ); 15 | 16 | declare global { 17 | interface Window { 18 | loadCardHelpers?: () => Promise; 19 | customCards?: Array<{ 20 | type: string; 21 | name: string; 22 | description: string; 23 | preview: boolean; 24 | }>; 25 | } 26 | } 27 | 28 | interface Hass { 29 | states: Record; 30 | connection: any; 31 | config: { unit_system: { length: string } }; 32 | selectedLanguage?: string; 33 | language: string; 34 | } 35 | 36 | interface CardConfig { 37 | entity: string; 38 | [key: string]: any; 39 | } 40 | 41 | // This puts your card into the UI card picker dialog 42 | window.customCards = window.customCards || []; 43 | window.customCards.push({ 44 | type: "astroweather-card", 45 | name: "AstroWeather Card", 46 | description: 47 | "A custom weather card made for AstroWeather. Repo: https://github.com/mawinkler/astroweather-card", 48 | preview: true, 49 | }); 50 | 51 | const fireEvent = (node, type, detail, options) => { 52 | options = options || {}; 53 | detail = detail === null || detail === undefined ? {} : detail; 54 | const event = new Event(type, { 55 | bubbles: options.bubbles === undefined ? true : options.bubbles, 56 | cancelable: Boolean(options.cancelable), 57 | composed: options.composed === undefined ? true : options.composed, 58 | }); 59 | 60 | node.dispatchEvent(event); 61 | return event; 62 | }; 63 | 64 | @customElement("astroweather-card") 65 | export class AstroWeatherCard extends LitElement { 66 | static get properties() { 67 | return { 68 | _config: { attribute: false }, 69 | _hass: { attribute: false }, 70 | }; 71 | } 72 | 73 | @property({ attribute: false }) private _hass?: HomeAssistant; 74 | @state() private _config!: CardConfig; 75 | @state() private _weather?: any; 76 | @state() private _component_loaded?: boolean = false; 77 | @state() private _forecasts: any[] = []; 78 | @state() private _forecastChart?: Chart; 79 | @state() private _forecastSubscriber?: any; 80 | @state() private _numberElements!: number; 81 | private _lastDataTs = 0; 82 | private _lastResizeTs = 0; 83 | private _resizeObs?: ResizeObserver; 84 | 85 | // tune these 86 | private static readonly REDRAW_DEBOUNCE_MS = 300; 87 | private static readonly RESIZE_DEBOUNCE_MS = 200; 88 | 89 | constructor() { 90 | super(); 91 | this.initialise(); 92 | } 93 | 94 | public static async getConfigElement(): Promise { 95 | return document.createElement( 96 | "astroweather-card-editor" 97 | ) as unknown as LovelaceCardEditor; 98 | } 99 | 100 | public static getStubConfig(hass, unusedEntities, allEntities) { 101 | let entity = unusedEntities.find( 102 | (eid) => eid.split("_")[0] === "weather.astroweather" 103 | ); 104 | if (!entity) { 105 | entity = allEntities.find( 106 | (eid) => eid.split("_")[0] === "weather.astroweather" 107 | ); 108 | } 109 | return { 110 | entity, 111 | details: true, 112 | current: true, 113 | deepskydetails: true, 114 | forecast: true, 115 | graph: true, 116 | graph_condition: true, 117 | graph_cloudless: true, 118 | graph_seeing: true, 119 | graph_transparency: true, 120 | graph_calm: true, 121 | graph_li: true, 122 | graph_precip: true, 123 | graph_fog: true, 124 | number_of_forecasts: "8", 125 | line_color_condition: "#f07178", // magenta 126 | line_color_condition_night: "#eeffff", // white 127 | line_color_cloudless: "#c3e88d", // green 128 | line_color_seeing: "#ffcb6b", // yellow 129 | line_color_transparency: "#82aaff", // blue 130 | line_color_calm: "#ff5370", // red 131 | line_color_li: "#89ddff", // cyan 132 | line_color_precip: "#82aaff", // blue 133 | line_color_fog: "#dde8ff", // blue 134 | }; 135 | // materialBox colors: 136 | // black: '#263238', 137 | // red: '#FF5370', 138 | // green: '#C3E88D', 139 | // yellow: '#FFCB6B', 140 | // blue: '#82AAFF', 141 | // magenta: '#F07178', 142 | // cyan: '#89DDFF', 143 | // white: '#EEFFFF', 144 | } 145 | 146 | public setConfig(config) { 147 | if (!config.entity) { 148 | throw new Error("Please define an AstroWeather entity"); 149 | } 150 | if (!config.entity.startsWith("weather.astroweather")) { 151 | throw new Error("Entity is not an AstroWeather entity"); 152 | } 153 | this._config = config; 154 | } 155 | 156 | set hass(hass: HomeAssistant) { 157 | if (!this._config) return; 158 | 159 | // Throttle noisy hass updates 160 | const now = Date.now(); 161 | this._hass = hass as any; 162 | if (now - this._lastDataTs >= AstroWeatherCard.REDRAW_DEBOUNCE_MS) { 163 | this._lastDataTs = now; 164 | this._updateChart(); // imperatively update chart (no recreate) 165 | } 166 | 167 | this._weather = 168 | this._config.entity in hass.states 169 | ? hass.states[this._config.entity] 170 | : null; 171 | 172 | if (this._weather && !this._forecastSubscriber) { 173 | this.subscribeForecastEvents(); 174 | } 175 | } 176 | 177 | async initialise(): Promise { 178 | if (await this.isComponentLoaded()) { 179 | this._component_loaded = true; 180 | } 181 | return true; 182 | } 183 | 184 | async isComponentLoaded(): Promise { 185 | while (!this._hass || !this._hass.config.components.includes("wiser")) { 186 | await new Promise((resolve) => setTimeout(resolve, 100)); 187 | } 188 | return true; 189 | } 190 | 191 | public getCardSize(): number { 192 | const card = this.shadowRoot?.querySelector("ha-card"); 193 | if (!card) return 4; // fallback 194 | 195 | // Pixel height of the card 196 | const height = card.getBoundingClientRect().height; 197 | 198 | // Convert pixels → "rows" (approx. 50px per row in Lovelace grid) 199 | return Math.ceil(height / 50); 200 | } 201 | 202 | subscribeForecastEvents() { 203 | const callback = (event) => { 204 | this._forecasts = event.forecast; 205 | this.requestUpdate(); 206 | this._updateChart(); 207 | }; 208 | 209 | if (this._hass) { 210 | this._forecastSubscriber = this._hass.connection.subscribeMessage( 211 | callback, 212 | { 213 | type: "weather/subscribe_forecast", 214 | forecast_type: "hourly", 215 | entity_id: this._config.entity, 216 | } 217 | ); 218 | } else { 219 | console.error("this._hass is undefined"); 220 | } 221 | } 222 | 223 | supportsFeature(feature) { 224 | return (this._weather.attributes.supported_features & feature) !== 0; 225 | } 226 | 227 | connectedCallback() { 228 | super.connectedCallback(); 229 | } 230 | 231 | disconnectedCallback(): void { 232 | super.disconnectedCallback(); 233 | this._resizeObs?.disconnect(); 234 | } 235 | 236 | protected shouldUpdate(changedProps: PropertyValues): boolean { 237 | // Only re-render DOM when config changes (or first render). 238 | // Chart updates are done imperatively, not through DOM re-render. 239 | if (changedProps.has("_config")) return true; 240 | 241 | // Allow DOM updates only when the bound entity actually changes: 242 | const oldHass = changedProps.get("_hass") as HomeAssistant | undefined; 243 | if (!oldHass) return true; 244 | const eid = this._config?.entity; 245 | return oldHass.states[eid] !== this._hass?.states[eid]; 246 | } 247 | 248 | firstUpdated() { 249 | // Get chart canvas 250 | const chartCanvas = this.shadowRoot!.getElementById( 251 | "forecastChart" 252 | ) as HTMLCanvasElement; 253 | this._drawChart(chartCanvas); 254 | 255 | // Throttled resize handling 256 | this._resizeObs = new ResizeObserver(() => { 257 | const now = Date.now(); 258 | if (now - this._lastResizeTs < AstroWeatherCard.RESIZE_DEBOUNCE_MS) 259 | return; 260 | this._lastResizeTs = now; 261 | this._forecastChart?.resize(); 262 | this._forecastChart?.update("none"); 263 | }); 264 | // this._resizeObs.observe(this.renderRoot.host); 265 | 266 | const host = 267 | this.renderRoot instanceof ShadowRoot 268 | ? this.renderRoot.host 269 | : this; 270 | 271 | this._resizeObs.observe(host as Element); 272 | } 273 | 274 | async updated(changedProperties) { 275 | await this.updateComplete; 276 | 277 | if ( 278 | changedProperties.has("_config") && 279 | changedProperties.get("_config") !== undefined 280 | ) { 281 | const oldConfig = changedProperties.get("_config"); 282 | 283 | const entityChanged = 284 | oldConfig && this._config.entity !== oldConfig.entity; 285 | 286 | if (entityChanged) { 287 | if ( 288 | this._forecastSubscriber && 289 | typeof this._forecastSubscriber === "function" 290 | ) { 291 | this._forecastSubscriber(); 292 | } 293 | 294 | this.subscribeForecastEvents(); 295 | } 296 | 297 | if (this._forecasts && this._forecasts.length) { 298 | this._updateChart(); 299 | } 300 | } 301 | 302 | if (this._config.graph !== false) { 303 | if ( 304 | changedProperties.has("_config") && 305 | changedProperties.get("_config") !== undefined 306 | ) { 307 | this._updateChart(); 308 | } 309 | if (changedProperties.has("weather")) { 310 | this._updateChart(); 311 | } 312 | } 313 | } 314 | 315 | static get styles() { 316 | return style; 317 | } 318 | 319 | // Render card 320 | protected render() { 321 | if (!this._config || !this._hass) { 322 | return html``; 323 | } 324 | 325 | this._numberElements = 0; 326 | 327 | const lang = this._hass.selectedLanguage || this._hass.language; 328 | const stateObj = this._hass.states[this._config.entity]; 329 | 330 | if (!stateObj) { 331 | return html` 332 | 339 | 340 |
341 | Entity not available: ${this._config.entity} 342 |
343 |
344 | `; 345 | } 346 | if (!stateObj.attributes.attribution.startsWith("Powered by Met.no")) { 347 | return html` 348 | 356 | 357 |
358 | Entity is not an AstroWeather entity: ${this._config.entity} 359 |
360 |
361 | `; 362 | } 363 | 364 | return html` 365 | this._handlePopup(e, this._config.entity)}> 366 |
367 | ${this._config.current !== false ? this._renderCurrent(stateObj) : ""} 368 | ${this._config.details !== false 369 | ? this._renderDetails(stateObj, lang) 370 | : ""} 371 | ${this._config.deepskydetails !== false 372 | ? this._renderDeepSkyForecast(stateObj) 373 | : ""} 374 | ${this._config.forecast !== false ? this._renderForecast(lang) : ""} 375 | ${this._config.graph !== false 376 | ? html`
377 | 378 |
` 379 | : ""} 380 |
381 |
382 | `; 383 | } 384 | 385 | private _renderCurrent(stateObj) { 386 | this._numberElements++; 387 | 388 | return html` 389 |
390 | ${stateObj.attributes.location_name 392 | ? stateObj.attributes.location_name 393 | : "AstroWeather"} 395 | 396 | ${stateObj.attributes.condition_plain} 399 |
400 | `; 401 | } 402 | 403 | private _renderDetails(stateObj, lang) { 404 | let sun_next_rising; 405 | let sun_next_setting; 406 | let sun_next_rising_nautical; 407 | let sun_next_setting_nautical; 408 | let sun_next_rising_astro; 409 | let sun_next_setting_astro; 410 | let moon_next_rising; 411 | let moon_next_setting; 412 | let moon_next_new_moon; 413 | let moon_next_full_moon; 414 | let moon_next_dark_night; 415 | let local_time; 416 | 417 | sun_next_rising = new Date( 418 | stateObj.attributes.sun_next_rising 419 | ).toLocaleTimeString(lang, { 420 | month: "2-digit", 421 | day: "2-digit", 422 | hour: "2-digit", 423 | minute: "2-digit", 424 | hour12: false, 425 | }); 426 | sun_next_setting = new Date( 427 | stateObj.attributes.sun_next_setting 428 | ).toLocaleTimeString(lang, { 429 | month: "2-digit", 430 | day: "2-digit", 431 | hour: "2-digit", 432 | minute: "2-digit", 433 | hour12: false, 434 | }); 435 | sun_next_rising_nautical = new Date( 436 | stateObj.attributes.sun_next_rising_nautical 437 | ).toLocaleTimeString(lang, { 438 | month: "2-digit", 439 | day: "2-digit", 440 | hour: "2-digit", 441 | minute: "2-digit", 442 | hour12: false, 443 | }); 444 | sun_next_setting_nautical = new Date( 445 | stateObj.attributes.sun_next_setting_nautical 446 | ).toLocaleTimeString(lang, { 447 | month: "2-digit", 448 | day: "2-digit", 449 | hour: "2-digit", 450 | minute: "2-digit", 451 | hour12: false, 452 | }); 453 | sun_next_rising_astro = new Date( 454 | stateObj.attributes.sun_next_rising_astro 455 | ).toLocaleTimeString(lang, { 456 | month: "2-digit", 457 | day: "2-digit", 458 | hour: "2-digit", 459 | minute: "2-digit", 460 | hour12: false, 461 | }); 462 | sun_next_setting_astro = new Date( 463 | stateObj.attributes.sun_next_setting_astro 464 | ).toLocaleTimeString(lang, { 465 | month: "2-digit", 466 | day: "2-digit", 467 | hour: "2-digit", 468 | minute: "2-digit", 469 | hour12: false, 470 | }); 471 | moon_next_rising = new Date( 472 | stateObj.attributes.moon_next_rising 473 | ).toLocaleTimeString(lang, { 474 | month: "2-digit", 475 | day: "2-digit", 476 | hour: "2-digit", 477 | minute: "2-digit", 478 | hour12: false, 479 | }); 480 | moon_next_setting = new Date( 481 | stateObj.attributes.moon_next_setting 482 | ).toLocaleTimeString(lang, { 483 | month: "2-digit", 484 | day: "2-digit", 485 | hour: "2-digit", 486 | minute: "2-digit", 487 | hour12: false, 488 | }); 489 | moon_next_new_moon = new Date( 490 | stateObj.attributes.moon_next_new_moon 491 | ).toLocaleDateString(lang, { 492 | month: "2-digit", 493 | day: "2-digit", 494 | }); 495 | moon_next_full_moon = new Date( 496 | stateObj.attributes.moon_next_full_moon 497 | ).toLocaleDateString(lang, { 498 | month: "2-digit", 499 | day: "2-digit", 500 | }); 501 | moon_next_dark_night = new Date( 502 | stateObj.attributes.moon_next_dark_night 503 | ).toLocaleDateString(lang, { 504 | month: "2-digit", 505 | day: "2-digit", 506 | }); 507 | let diff = new Date().getTimezoneOffset(); 508 | local_time = new Date( 509 | Date.now() + stateObj.attributes.time_shift * 1000 + diff * 60000 510 | ).toLocaleTimeString(lang, { 511 | hour: "2-digit", 512 | minute: "2-digit", 513 | hour12: false, 514 | }); 515 | 516 | var asd_duration = stateObj.attributes.night_duration_astronomical / 60; 517 | var asd_h = Math.floor(asd_duration / 60); 518 | var asd_m = Math.round(asd_duration - asd_h * 60); 519 | 520 | var dsd_duration = stateObj.attributes.deep_sky_darkness / 60; 521 | var dsd_h = Math.floor(dsd_duration / 60); 522 | var dsd_m = Math.round(dsd_duration - dsd_h * 60); 523 | 524 | this._numberElements++; 525 | 526 | return html` 527 |
528 |
  • 529 | 530 | ASD: ${asd_h}h ${asd_m}min 531 |
  • 532 |
  • 533 | 534 | DSD: ${dsd_h}h ${dsd_m}min 535 |
  • 536 |
  • 537 | 538 | Condition: ${stateObj.attributes.condition_percentage} 542 | % 543 | 545 |
  • 546 |
  • 547 | 548 | Cloudless: ${stateObj.attributes.cloudless_percentage} 552 | % 553 | 555 |
  • 556 |
  • 557 | 558 | Seeing: ${stateObj.attributes.seeing} 560 | asec 563 |
  • 564 |
  • 565 | 566 | Transp: ${stateObj.attributes.transparency} 568 | mag 571 |
  • 572 |
  • 573 | 574 | Temp: ${stateObj.attributes.temperature} 576 | ${this._getUnit("temperature")} 578 |
  • 579 |
  • 580 | 581 | Humidity: ${stateObj.attributes.humidity} % 582 |
  • 583 |
  • 584 | 585 | Dewpoint: ${stateObj.attributes.dewpoint} 587 | °C 590 |
  • 591 |
  • 592 | Precip: 594 | ${stateObj.attributes.precipitation_amount >= 2 595 | ? html` ` 596 | : stateObj.attributes.precipitation_amount >= 0.2 597 | ? html` ` 598 | : stateObj.attributes.precipitation_amount >= 0 599 | ? html` ` 600 | : ""} 601 | ${stateObj.attributes.precipitation_amount} 602 | ${this._getUnit("precipitation")} 604 |
  • 605 |
  • 606 | 607 | Wind: ${stateObj.attributes.wind_bearing} 609 | ${this._getUnit("wind_speed") == "m/s" 610 | ? stateObj.attributes.wind_speed 611 | : Math.round(stateObj.attributes.wind_speed * 2.23694)} 612 | ${this._getUnit("wind_speed")} 614 |
  • 615 |
  • 616 | 617 | LI: ${stateObj.attributes.lifted_index} 619 | °C 622 |
  • 623 |
  • 624 | 625 | Civil: ${sun_next_setting} 626 |
  • 627 |
  • 628 | 629 | Civil: ${sun_next_rising} 630 |
  • 631 |
  • 632 | 633 | Naut: ${sun_next_setting_nautical} 634 |
  • 635 |
  • 636 | 637 | Naut: ${sun_next_rising_nautical} 638 |
  • 639 |
  • 640 | 641 | Astro: ${sun_next_setting_astro} 642 |
  • 643 |
  • 644 | 645 | Astro: ${sun_next_rising_astro} 646 |
  • 647 |
  • 648 | 649 | Moon: ${moon_next_setting} 650 |
  • 651 |
  • 652 | 653 | Moon: ${moon_next_rising} 654 |
  • 655 |
  • 656 | 657 | New Moon: ${moon_next_new_moon} 658 |
  • 659 |
  • 660 | 661 | Full Moon: ${moon_next_full_moon} 662 |
  • 663 |
  • 664 | 665 | Phase: ${stateObj.attributes.moon_phase} % 666 |
  • 667 |
  • 668 | 669 | Dark Night: ${moon_next_dark_night} 670 |
  • 671 |
  • 672 | 673 | Local Time: ${local_time} 674 |
  • 675 |
    676 | `; 677 | } 678 | 679 | private _renderDeepSkyForecast(stateObj) { 680 | this._numberElements++; 681 | 682 | return html` 683 |
    688 | ${stateObj.attributes.deepsky_forecast_today_plain 689 | ? html` 690 |
  • 691 | 692 | ${stateObj.attributes.deepsky_forecast_today_dayname}: 693 | ${stateObj.attributes.deepsky_forecast_today_plain} 694 |
  • 695 |
  • 696 | 697 | ${stateObj.attributes.deepsky_forecast_today_desc} 699 | ${stateObj.attributes 700 | .deepsky_forecast_today_precipitation_amount6 > 0 701 | ? html`, Precip: 702 | ${stateObj.attributes 703 | .deepsky_forecast_today_precipitation_amount6} 704 | ${this._getUnit("precipitation")}` 705 | : ""} 706 | 707 |
  • 708 | ` 709 | : ""} 710 | ${stateObj.attributes.deepsky_forecast_tomorrow_plain 711 | ? html` 712 |
  • 713 | 714 | ${stateObj.attributes.deepsky_forecast_tomorrow_dayname}: 715 | ${stateObj.attributes.deepsky_forecast_tomorrow_plain} 716 |
  • 717 |
  • 718 | 719 | ${stateObj.attributes.deepsky_forecast_tomorrow_desc} 721 | ${stateObj.attributes 722 | .deepsky_forecast_tomorrow_precipitation_amount6 > 0 723 | ? html`, Precip: 724 | ${stateObj.attributes 725 | .deepsky_forecast_tomorrow_precipitation_amount6} 726 | ${this._getUnit("precipitation")}` 727 | : ""} 728 | 729 |
  • 730 | ` 731 | : ""} 732 |
    733 | `; 734 | } 735 | 736 | private _renderForecast(lang) { 737 | if (!this._forecasts || !this._forecasts.length || !this._config) { 738 | return []; 739 | } 740 | 741 | this._numberElements++; 742 | return html` 743 |
    744 |
    745 |
    746 | ${this._config.graph_condition 747 | ? html`
    ` 748 | : ""} 749 | ${this._config.graph_cloudless 750 | ? html`
    ` 752 | : ""} 753 | ${this._config.graph_seeing 754 | ? html`
    ` 755 | : ""} 756 | ${this._config.graph_transparency 757 | ? html`
    ` 758 | : ""} 759 | ${this._config.graph_calm 760 | ? html`
    ` 761 | : ""} 762 | ${this._config.graph_li 763 | ? html`
    ` 764 | : ""} 765 | ${this._config.graph_precip 766 | ? html`` 767 | : ""} 768 | ${this._config.graph_fog 769 | ? html`` 770 | : ""} 771 |
    772 | ${this._forecasts 773 | ? this._forecasts 774 | .slice( 775 | 0, 776 | this._config.number_of_forecasts 777 | ? this._config.number_of_forecasts > 7 778 | ? 7 779 | : this._config.number_of_forecasts 780 | : 5 781 | ) 782 | .map( 783 | (hourly) => html` 784 |
    785 |
    786 | ${new Date(hourly.datetime).toLocaleTimeString(lang, { 787 | hour: "2-digit", 788 | minute: "2-digit", 789 | hour12: false, 790 | })} 791 | ${this._config.graph_condition 792 | ? html`
    793 | ${hourly.condition} 794 |
    ` 795 | : ""} 796 | ${this._config.graph_cloudless 797 | ? html`
    798 | ${hourly.cloudcover_percentage} 799 |
    ` 800 | : ""} 801 | ${this._config.graph_seeing 802 | ? html`
    803 | ${( 804 | ((100 - hourly.seeing_percentage) * 2.5) / 805 | 100 806 | ).toFixed(2)} 807 |
    ` 808 | : ""} 809 | ${this._config.graph_transparency 810 | ? html`
    811 | ${( 812 | ((100 - hourly.transparency_percentage) * 1) / 813 | 100 814 | ).toFixed(2)} 815 |
    ` 816 | : ""} 817 | ${this._config.graph_calm 818 | ? html`
    819 | ${this._getUnit("wind_speed") == "m/s" 820 | ? hourly.wind_speed 821 | : Math.round(hourly.wind_speed * 2.23694)} 822 |
    ` 823 | : ""} 824 | ${this._config.graph_li 825 | ? html`
    826 | ${hourly.lifted_index} 827 |
    ` 828 | : ""} 829 | ${this._config.graph_precip 830 | ? html`
    831 | ${hourly.precipitation_amount} 832 |
    ` 833 | : ""} 834 | ${this._config.graph_fog 835 | ? html`
    836 | ${hourly.fog_area_fraction} 837 |
    ` 838 | : ""} 839 |
    840 |
    841 | ` 842 | ) 843 | : ""} 844 |
    845 |
    846 | ${this._config.graph_condition ? html`%
    ` : ""} 847 | ${this._config.graph_cloudless ? html`%
    ` : ""} 848 | ${this._config.graph_seeing ? html`asec
    ` : ""} 849 | ${this._config.graph_transparency ? html`mag
    ` : ""} 850 | ${this._config.graph_calm ? html`m/s
    ` : ""} 851 | ${this._config.graph_li ? html`°C
    ` : ""} 852 | ${this._config.graph_precip ? html`mm
    ` : ""} 853 | ${this._config.graph_fog ? html`mm
    ` : ""} 854 |
    855 |
    856 | `; 857 | } 858 | 859 | private _drawChart(chartCanvas: HTMLCanvasElement) { 860 | var lang = "en"; 861 | if (this._hass) { 862 | lang = this._hass.selectedLanguage || this._hass.language; 863 | } 864 | 865 | if (!this._forecasts || !this._config || !chartCanvas) { 866 | return []; 867 | } 868 | 869 | const ctx = chartCanvas.getContext("2d"); 870 | if (!ctx) { 871 | return []; 872 | } 873 | 874 | // Render forecast 875 | const forecast = this._forecasts 876 | ? this._forecasts.slice( 877 | 0, 878 | this._config.number_of_forecasts 879 | ? this._config.number_of_forecasts 880 | : 5 881 | ) 882 | : []; 883 | const mode = "hourly"; 884 | 885 | const graphCondition = this._config.graph_condition; 886 | const graphCloudless = this._config.graph_cloudless; 887 | const graphSeeing = this._config.graph_seeing; 888 | const graphTransparency = this._config.graph_transparency; 889 | const graphCalm = this._config.graph_calm; 890 | const graphLi = this._config.graph_li; 891 | const graphPrecip = this._config.graph_precip; 892 | const graphFog = this._config.graph_fog; 893 | 894 | const style = getComputedStyle(document.body); 895 | const backgroundColor = style.getPropertyValue("--card-background-color"); 896 | const textColor = style.getPropertyValue("--primary-text-color"); 897 | 898 | const colorCondition = this._config.line_color_condition 899 | ? this._config.line_color_condition 900 | : "#f07178"; 901 | const colorConditionNight = this._config.line_color_condition_night 902 | ? this._config.line_color_condition_night 903 | : "#eeffff"; 904 | const colorCloudless = this._config.line_color_cloudless 905 | ? this._config.line_color_cloudless 906 | : "#c3e88d"; 907 | const colorCloudlessLevels = colorCloudless + "80"; 908 | const colorSeeing = this._config.line_color_seeing 909 | ? this._config.line_color_seeing 910 | : "#ffcb6b"; 911 | const colorTransparency = this._config.line_color_transparency 912 | ? this._config.line_color_transparency 913 | : "#82aaff"; 914 | const colorCalm = this._config.line_color_calm 915 | ? this._config.line_color_calm 916 | : "#ff5370"; 917 | const colorLi = this._config.line_color_li 918 | ? this._config.line_color_li 919 | : "#89ddff"; 920 | const colorPrecip = this._config.line_color_precip 921 | ? this._config.line_color_precip 922 | : "#82aaff"; 923 | const colorFog = this._config.line_color_fog 924 | ? this._config.line_color_fog 925 | : "#dde8ff"; 926 | const colorDivider = style.getPropertyValue("--divider-color"); 927 | const fillLine = false; 928 | 929 | var dateTime: string[] = []; 930 | var condition: number[] = []; 931 | var clouds: number[] = []; 932 | var clouds_high: number[] = []; 933 | var clouds_medium: number[] = []; 934 | var clouds_low: number[] = []; 935 | var seeing: number[] = []; 936 | var transparency: number[] = []; 937 | var calm: number[] = []; 938 | var li: number[] = []; 939 | var precip: number[] = []; 940 | var fog: number[] = []; 941 | 942 | Chart.defaults.color = textColor; 943 | Chart.defaults.scale.grid.color = colorDivider; 944 | Chart.defaults.elements.line.fill = false; 945 | Chart.defaults.elements.line.tension = 0.4; 946 | Chart.defaults.elements.line.borderWidth = 1.5; 947 | Chart.defaults.elements.point.radius = 2; 948 | Chart.defaults.elements.point.hitRadius = 10; 949 | Chart.defaults.plugins.legend.position = "bottom"; 950 | Chart.defaults.animation = false; 951 | Chart.defaults.transitions.active.animation.duration = 0; 952 | 953 | var colorConditionGradient = ctx.createLinearGradient(0, 0, 0, 300); 954 | var colorCloudlessGradient = ctx.createLinearGradient(0, 0, 0, 300); 955 | var colorSeeingGradient = ctx.createLinearGradient(0, 0, 0, 300); 956 | var colorTransparencyGradient = ctx.createLinearGradient(0, 0, 0, 300); 957 | var colorCalmGradient = ctx.createLinearGradient(0, 0, 0, 300); 958 | var colorLiGradient = ctx.createLinearGradient(0, 0, 0, 300); 959 | var colorPrecipGradient = ctx.createLinearGradient(0, 0, 0, 300); 960 | var colorFogGradient = ctx.createLinearGradient(0, 0, 0, 300); 961 | colorConditionGradient.addColorStop(0, colorCondition); 962 | colorConditionGradient.addColorStop(1, "rgba(0, 0, 0, 0)"); 963 | colorCloudlessGradient.addColorStop(0, colorCloudless); 964 | colorCloudlessGradient.addColorStop(1, "rgba(0, 0, 0, 0)"); 965 | colorSeeingGradient.addColorStop(0, colorSeeing); 966 | colorSeeingGradient.addColorStop(1, "rgba(0, 0, 0, 0)"); 967 | colorTransparencyGradient.addColorStop(0, colorTransparency); 968 | colorTransparencyGradient.addColorStop(1, "rgba(0, 0, 0, 0)"); 969 | colorCalmGradient.addColorStop(0, colorCalm); 970 | colorCalmGradient.addColorStop(1, "rgba(0, 0, 0, 0)"); 971 | colorLiGradient.addColorStop(0, colorLi); 972 | colorLiGradient.addColorStop(1, "rgba(0, 0, 0, 0)"); 973 | colorPrecipGradient.addColorStop(0, colorPrecip); 974 | colorPrecipGradient.addColorStop(1, "rgba(0, 0, 0, 0)"); 975 | colorFogGradient.addColorStop(0, colorFog); 976 | colorFogGradient.addColorStop(1, "rgba(0, 0, 0, 0)"); 977 | 978 | var sun_next_setting_astro = new Date( 979 | this._weather.attributes.sun_next_setting_astro 980 | ).getHours(); 981 | var sun_next_rising_astro = new Date( 982 | this._weather.attributes.sun_next_rising_astro 983 | ).getHours(); 984 | 985 | const astroDarknessBackgroundPlugin = { 986 | id: "astroDarknessBackground", 987 | beforeDraw(chart: any, _args: any, pluginOptions: any) { 988 | const { 989 | ctx, 990 | chartArea: { top, bottom, left, right }, 991 | } = chart; 992 | 993 | const xAxis = chart.scales["x"]; 994 | const labels = (chart.data.labels || []) as string[]; 995 | if (!xAxis || !labels.length || !pluginOptions) return; 996 | 997 | const sunSetHour: number = pluginOptions.sunSetHour; 998 | const sunRiseHour: number = pluginOptions.sunRiseHour; 999 | const color: string = 1000 | pluginOptions.color || "rgba(255, 255, 255, 0.06)"; // tweak to taste 1001 | 1002 | // Build mask per label: is this point in astronomical darkness? 1003 | const darkMask = labels.map((label) => { 1004 | const hour = new Date(label).getHours(); 1005 | if (Number.isNaN(hour)) return false; 1006 | 1007 | if (sunSetHour < sunRiseHour) { 1008 | // Darkness between same-evening set and next-morning rise 1009 | return hour >= sunSetHour && hour <= sunRiseHour; 1010 | } else { 1011 | // Darkness spans midnight 1012 | return hour >= sunSetHour || hour <= sunRiseHour; 1013 | } 1014 | }); 1015 | 1016 | ctx.save(); 1017 | ctx.fillStyle = color; 1018 | 1019 | // Find contiguous dark segments and draw rectangles 1020 | let segmentStart: number | null = null; 1021 | for (let i = 0; i < darkMask.length; i++) { 1022 | const isDark = darkMask[i]; 1023 | const isLast = i === darkMask.length - 1; 1024 | 1025 | if (isDark && segmentStart === null) { 1026 | segmentStart = i; 1027 | } 1028 | 1029 | if ((segmentStart !== null && !isDark) || (segmentStart !== null && isLast)) { 1030 | const startIndex = segmentStart; 1031 | const endIndex = isLast && isDark ? i : i - 1; 1032 | 1033 | // Convert label positions to pixels on x-axis 1034 | const xStart = xAxis.getPixelForValue(labels[startIndex]); 1035 | const xEnd = xAxis.getPixelForValue(labels[endIndex]); 1036 | const width = xEnd - xStart || 0; 1037 | 1038 | if (width !== 0) { 1039 | ctx.fillRect(xStart, top, width, bottom - top); 1040 | } 1041 | segmentStart = null; 1042 | } 1043 | } 1044 | 1045 | ctx.restore(); 1046 | }, 1047 | }; 1048 | 1049 | this._forecastChart ||= new Chart(ctx, { 1050 | type: "bar", 1051 | data: { 1052 | labels: dateTime, 1053 | datasets: [ 1054 | { 1055 | label: "Condition", 1056 | type: "line", 1057 | data: condition, 1058 | yAxisID: "PercentageAxis", 1059 | backgroundColor: colorConditionGradient, 1060 | borderDash: [2, 4], 1061 | fill: fillLine, 1062 | borderWidth: 4, 1063 | borderColor: colorCondition, 1064 | pointStyle: "star", 1065 | }, 1066 | { 1067 | label: "Cloudless", 1068 | type: "line", 1069 | data: clouds, 1070 | yAxisID: "PercentageAxis", 1071 | backgroundColor: colorCloudlessGradient, 1072 | fill: fillLine, 1073 | borderColor: colorCloudless, 1074 | pointBorderColor: colorCloudless, 1075 | pointRadius: 0, 1076 | pointStyle: "rect", 1077 | }, 1078 | { 1079 | label: "H", 1080 | type: "line", 1081 | data: clouds_high, 1082 | borderDash: [9, 2], 1083 | yAxisID: "PercentageAxis", 1084 | backgroundColor: colorCloudlessGradient, 1085 | fill: fillLine, 1086 | borderColor: colorCloudlessLevels, 1087 | pointBorderColor: colorCloudless, 1088 | pointRadius: 0, 1089 | pointStyle: "rect", 1090 | }, 1091 | { 1092 | label: "M", 1093 | type: "line", 1094 | data: clouds_medium, 1095 | borderDash: [6, 2], 1096 | yAxisID: "PercentageAxis", 1097 | backgroundColor: colorCloudlessGradient, 1098 | fill: fillLine, 1099 | borderColor: colorCloudlessLevels, 1100 | pointBorderColor: colorCloudless, 1101 | pointRadius: 0, 1102 | pointStyle: "rect", 1103 | }, 1104 | { 1105 | label: "L", 1106 | type: "line", 1107 | data: clouds_low, 1108 | borderDash: [3, 2], 1109 | yAxisID: "PercentageAxis", 1110 | backgroundColor: colorCloudlessGradient, 1111 | fill: fillLine, 1112 | borderColor: colorCloudlessLevels, 1113 | pointBorderColor: colorCloudless, 1114 | pointRadius: 0, 1115 | pointStyle: "rect", 1116 | }, 1117 | 1118 | { 1119 | label: "Seeing", 1120 | type: "line", 1121 | data: seeing, 1122 | yAxisID: "PercentageAxis", 1123 | backgroundColor: colorSeeingGradient, 1124 | fill: fillLine, 1125 | borderColor: colorSeeing, 1126 | pointBorderColor: colorSeeing, 1127 | pointRadius: 0, 1128 | pointStyle: "triangle", 1129 | }, 1130 | 1131 | { 1132 | label: "Transp", 1133 | type: "line", 1134 | data: transparency, 1135 | yAxisID: "PercentageAxis", 1136 | backgroundColor: colorTransparencyGradient, 1137 | fill: fillLine, 1138 | borderColor: colorTransparency, 1139 | pointBorderColor: colorTransparency, 1140 | pointRadius: 0, 1141 | pointStyle: "circle", 1142 | }, 1143 | 1144 | { 1145 | label: "Calm", 1146 | type: "line", 1147 | data: calm, 1148 | yAxisID: "PercentageAxis", 1149 | backgroundColor: colorCalmGradient, 1150 | fill: fillLine, 1151 | borderColor: colorCalm, 1152 | pointBorderColor: colorCalm, 1153 | pointRadius: 0, 1154 | pointStyle: "circle", 1155 | }, 1156 | 1157 | { 1158 | label: "LI", 1159 | type: "line", 1160 | data: li, 1161 | yAxisID: "LiftedIndexAxis", 1162 | backgroundColor: colorLiGradient, 1163 | fill: fillLine, 1164 | borderColor: colorLi, 1165 | pointBorderColor: colorLi, 1166 | pointRadius: 0, 1167 | pointStyle: "circle", 1168 | }, 1169 | 1170 | { 1171 | label: "Precip", 1172 | type: "bar", 1173 | data: precip, 1174 | yAxisID: "PrecipitationAxis", 1175 | backgroundColor: colorPrecipGradient, 1176 | borderColor: colorPrecip, 1177 | pointStyle: "circle", 1178 | }, 1179 | 1180 | { 1181 | label: "Fog", 1182 | type: "bar", 1183 | data: fog, 1184 | yAxisID: "PercentageAxis", 1185 | backgroundColor: colorFogGradient, 1186 | borderColor: colorFog, 1187 | pointStyle: "circle", 1188 | }, 1189 | ], 1190 | }, 1191 | options: { 1192 | animation: false, 1193 | responsive: true, 1194 | maintainAspectRatio: true, 1195 | layout: { 1196 | padding: { 1197 | bottom: 10, 1198 | }, 1199 | }, 1200 | scales: { 1201 | DateTimeAxis: { 1202 | position: "top", 1203 | grid: { 1204 | display: false, 1205 | drawTicks: false, 1206 | }, 1207 | ticks: { 1208 | maxRotation: 0, 1209 | padding: 8, 1210 | font: { 1211 | size: 8, 1212 | }, 1213 | callback: function (value) { 1214 | var datetime = this.getLabelForValue(Number(value)); 1215 | var weekday = new Date(datetime).toLocaleDateString(lang, { 1216 | weekday: "short", 1217 | }); 1218 | var time = new Date(datetime).toLocaleTimeString(lang, { 1219 | hour12: false, 1220 | hour: "numeric", 1221 | minute: "numeric", 1222 | }); 1223 | if (mode == "hourly") { 1224 | return time; 1225 | } 1226 | return weekday; 1227 | }, 1228 | }, 1229 | }, 1230 | PercentageAxis: { 1231 | position: "left", 1232 | beginAtZero: true, 1233 | min: 0, 1234 | max: 100, 1235 | grid: { 1236 | display: false, 1237 | drawTicks: true, 1238 | }, 1239 | ticks: { 1240 | display: true, 1241 | font: { 1242 | size: 8, 1243 | }, 1244 | callback: function (value) { 1245 | return value + "%"; // Add unit 1246 | }, 1247 | }, 1248 | }, 1249 | LiftedIndexAxis: { 1250 | position: "right", 1251 | beginAtZero: true, 1252 | min: -7, 1253 | max: 7, 1254 | grid: { 1255 | display: false, 1256 | drawTicks: true, 1257 | }, 1258 | ticks: { 1259 | display: graphLi !== undefined ? !!graphLi : true, 1260 | font: { 1261 | size: 8, 1262 | }, 1263 | callback: function (value) { 1264 | return value + "°C"; // Add unit 1265 | }, 1266 | }, 1267 | }, 1268 | PrecipitationAxis: { 1269 | position: "right", 1270 | beginAtZero: true, 1271 | min: 0, 1272 | max: 1, 1273 | grid: { 1274 | display: false, 1275 | drawTicks: true, 1276 | }, 1277 | ticks: { 1278 | display: graphPrecip !== undefined ? !!graphPrecip : true, 1279 | font: { 1280 | size: 8, 1281 | }, 1282 | callback: function (value) { 1283 | return value + "mm"; // Add unit 1284 | }, 1285 | }, 1286 | }, 1287 | x: { 1288 | grid: { 1289 | display: false, 1290 | drawTicks: true, 1291 | }, 1292 | ticks: { 1293 | display: false, // ✅ Hides X-axis labels 1294 | }, 1295 | }, 1296 | }, 1297 | plugins: { 1298 | legend: { 1299 | display: true, 1300 | position: "bottom", 1301 | onClick: (e, legendItem, legend) => { 1302 | const index = legendItem.datasetIndex; 1303 | const ci = legend.chart; 1304 | ci.setDatasetVisibility( 1305 | Number(index), 1306 | !ci.isDatasetVisible(Number(index)) 1307 | ); 1308 | ci.update(); 1309 | }, 1310 | labels: { 1311 | boxWidth: 10, 1312 | font: { 1313 | size: 8, 1314 | }, 1315 | padding: 5, 1316 | pointStyle: "circle", 1317 | usePointStyle: true, 1318 | generateLabels: (chart) => { 1319 | return chart.data.datasets.map((ds: any, i) => ({ 1320 | text: ds.label ?? "", 1321 | fontColor: textColor, 1322 | strokeStyle: ds.borderColor as any, 1323 | fillStyle: backgroundColor as any, 1324 | lineWidth: 2, //ds.borderWidth, 1325 | lineDash: ds.borderDash || [], 1326 | lineDashOffset: ds.borderDashOffset || 0, 1327 | hidden: !chart.isDatasetVisible(i), 1328 | index: i, 1329 | })); 1330 | }, 1331 | filter: function (legendItem) { 1332 | return ( 1333 | (legendItem.text == "Condition" && graphCondition) || 1334 | ((legendItem.text == "Cloudless" || 1335 | legendItem.text == "H" || 1336 | legendItem.text == "M" || 1337 | legendItem.text == "L") && 1338 | graphCloudless) || 1339 | (legendItem.text == "Seeing" && graphSeeing) || 1340 | (legendItem.text == "Transp" && graphTransparency) || 1341 | (legendItem.text == "Calm" && graphCalm) || 1342 | (legendItem.text == "LI" && graphLi) || 1343 | (legendItem.text == "Precip" && graphPrecip) || 1344 | (legendItem.text == "Fog" && graphFog) 1345 | ); 1346 | }, 1347 | }, 1348 | }, 1349 | tooltip: { 1350 | caretSize: 0, 1351 | caretPadding: 15, 1352 | callbacks: { 1353 | title: function (tooltipItem) { 1354 | var datetime = tooltipItem[0].label; 1355 | return new Date(datetime).toLocaleDateString(lang, { 1356 | month: "short", 1357 | day: "numeric", 1358 | weekday: "short", 1359 | hour: "numeric", 1360 | minute: "numeric", 1361 | }); 1362 | }, 1363 | label: function (tooltipItem) { 1364 | const units = { 1365 | 0: "%", 1366 | 1: "%", 1367 | 2: "%", 1368 | 3: "%", 1369 | 4: "%", 1370 | 5: "%", 1371 | 6: "%", 1372 | 7: "%", 1373 | 8: "°C", 1374 | 9: "mm", 1375 | 10: "%", 1376 | }; 1377 | 1378 | // Get dataset index and corresponding unit 1379 | const datasetIndex = tooltipItem.datasetIndex; // Index of the data point 1380 | const dataIndex = tooltipItem.dataIndex; 1381 | 1382 | const unit = units[datasetIndex] || ""; 1383 | const label = tooltipItem.dataset.label || ""; 1384 | const value = tooltipItem.raw; 1385 | 1386 | // Format tooltip text 1387 | return `${label}: ${value}${unit}`; 1388 | }, 1389 | }, 1390 | }, 1391 | astroDarknessBackground: { 1392 | sunSetHour: sun_next_setting_astro, 1393 | sunRiseHour: sun_next_rising_astro, 1394 | color: "rgba(0, 0, 0, 0.25)", // or rgba(255,255,255,0.06) for a light band 1395 | }, 1396 | }, 1397 | }, 1398 | plugins: [ 1399 | astroDarknessBackgroundPlugin, 1400 | ], 1401 | }); 1402 | } 1403 | 1404 | private _updateChart() { 1405 | if (!this._forecasts || !this._config || !this._forecastChart) { 1406 | return; 1407 | } 1408 | 1409 | // Update forecast 1410 | const forecast = this._forecasts 1411 | ? this._forecasts.slice( 1412 | 0, 1413 | this._config.number_of_forecasts 1414 | ? this._config.number_of_forecasts 1415 | : 5 1416 | ) 1417 | : []; 1418 | 1419 | const graphCondition = this._config.graph_condition; 1420 | const graphCloudless = this._config.graph_cloudless; 1421 | const graphSeeing = this._config.graph_seeing; 1422 | const graphTransparency = this._config.graph_transparency; 1423 | const graphCalm = this._config.graph_calm; 1424 | const graphLi = this._config.graph_li; 1425 | const graphPrecip = this._config.graph_precip; 1426 | const graphFog = this._config.graph_fog; 1427 | 1428 | let i: number; 1429 | const dateTime: string[] = []; 1430 | const condition: number[] = []; 1431 | const clouds: number[] = []; 1432 | const clouds_high: number[] = []; 1433 | const clouds_medium: number[] = []; 1434 | const clouds_low: number[] = []; 1435 | const seeing: number[] = []; 1436 | const transparency: number[] = []; 1437 | const calm: number[] = []; 1438 | const li: number[] = []; 1439 | const precip: number[] = []; 1440 | var precipMax: number = 0; 1441 | const fog: number[] = []; 1442 | 1443 | for (i = 0; i < forecast.length; i++) { 1444 | var d = forecast[i]; 1445 | dateTime.push(d.datetime); 1446 | if (graphCondition != undefined ? graphCondition : true) { 1447 | condition.push(d.condition); 1448 | } 1449 | if (graphCloudless != undefined ? graphCloudless : true) { 1450 | clouds.push(d.cloudless_percentage); 1451 | clouds_high.push(100 - d.cloud_area_fraction_high); 1452 | clouds_medium.push(100 - d.cloud_area_fraction_medium); 1453 | clouds_low.push(100 - d.cloud_area_fraction_low); 1454 | } 1455 | if (graphSeeing != undefined ? graphSeeing : true) { 1456 | seeing.push(d.seeing_percentage); 1457 | } 1458 | if (graphTransparency != undefined ? graphTransparency : true) { 1459 | transparency.push(d.transparency_percentage); 1460 | } 1461 | if (graphCalm != undefined ? graphCalm : true) { 1462 | calm.push(d.calm_percentage); 1463 | } 1464 | if (graphLi != undefined ? graphLi : true) { 1465 | li.push(d.lifted_index); 1466 | } 1467 | if (graphPrecip != undefined ? graphPrecip : true) { 1468 | precip.push(d.precipitation_amount); 1469 | if (d.precipitation_amount > precipMax) { 1470 | precipMax = d.precipitation_amount; 1471 | } 1472 | } 1473 | if (graphFog != undefined ? graphFog : true) { 1474 | fog.push(d.fog_area_fraction); 1475 | } 1476 | } 1477 | 1478 | // Highlight points during astronomical darkness 1479 | const sun_next_setting_astro = new Date( 1480 | this._weather.attributes.sun_next_setting_astro 1481 | ).getHours(); 1482 | const sun_next_rising_astro = new Date( 1483 | this._weather.attributes.sun_next_rising_astro 1484 | ).getHours(); 1485 | 1486 | const colorCondition = 1487 | this._config.line_color_condition || "#f07178"; 1488 | const colorConditionNight = 1489 | this._config.line_color_condition_night || "#eeffff"; 1490 | 1491 | const condPointBorderColor: string[] = []; 1492 | const condPointRadius: number[] = []; 1493 | 1494 | for (let idx = 0; idx < dateTime.length; idx++) { 1495 | const hour = new Date(dateTime[idx]).getHours(); 1496 | 1497 | let inAstroDarkness = false; 1498 | if (sun_next_setting_astro < sun_next_rising_astro) { 1499 | // Darkness between same-evening set and next-morning rise 1500 | inAstroDarkness = 1501 | hour >= sun_next_setting_astro && hour <= sun_next_rising_astro; 1502 | } else { 1503 | // Darkness spans midnight (set late, rise next day) 1504 | inAstroDarkness = 1505 | hour >= sun_next_setting_astro || hour <= sun_next_rising_astro; 1506 | } 1507 | 1508 | if (inAstroDarkness) { 1509 | condPointBorderColor.push(colorConditionNight); // white 1510 | condPointRadius.push(5); // visible point 1511 | } else { 1512 | condPointBorderColor.push(colorCondition); // normal color or none 1513 | condPointRadius.push(0); // hidden point 1514 | } 1515 | } 1516 | 1517 | function rescaleY( 1518 | chart, 1519 | { 1520 | axisId = "y", 1521 | precipMax = 1, 1522 | pad = 0.1, // 10% headroom 1523 | beginAtZero = true, // clamp min to 0 if desired 1524 | hard = false, // false -> use suggested*, true -> use hard min/max 1525 | } = {} 1526 | ) { 1527 | const yMax = precipMax; //getVisibleYMax(chart, axisId); 1528 | const paddedMax = yMax * (1 + pad); 1529 | 1530 | const scaleOpts = (chart.options.scales[axisId] ||= {}); 1531 | scaleOpts.beginAtZero = beginAtZero; 1532 | 1533 | if (hard) { 1534 | delete scaleOpts.suggestedMin; 1535 | delete scaleOpts.suggestedMax; 1536 | scaleOpts.min = beginAtZero ? 0 : undefined; 1537 | scaleOpts.max = paddedMax || (beginAtZero ? 1 : undefined); 1538 | } else { 1539 | delete scaleOpts.min; 1540 | delete scaleOpts.max; 1541 | scaleOpts.suggestedMin = beginAtZero ? 0 : undefined; 1542 | scaleOpts.suggestedMax = paddedMax || (beginAtZero ? 1 : undefined); 1543 | } 1544 | } 1545 | 1546 | if (this._forecastChart) { 1547 | this._forecastChart.data.labels = dateTime; 1548 | this._forecastChart.data.datasets[0].data = condition; 1549 | this._forecastChart.data.datasets[1].data = clouds; 1550 | this._forecastChart.data.datasets[2].data = clouds_high; 1551 | this._forecastChart.data.datasets[3].data = clouds_medium; 1552 | this._forecastChart.data.datasets[4].data = clouds_low; 1553 | this._forecastChart.data.datasets[5].data = seeing; 1554 | this._forecastChart.data.datasets[6].data = transparency; 1555 | this._forecastChart.data.datasets[7].data = calm; 1556 | this._forecastChart.data.datasets[8].data = li; 1557 | this._forecastChart.data.datasets[9].data = precip; 1558 | this._forecastChart.data.datasets[10].data = fog; 1559 | 1560 | // Apply the per-point styling to the "Condition" dataset 1561 | const conditionDataset: any = this._forecastChart.data.datasets[0]; 1562 | conditionDataset.pointBorderColor = condPointBorderColor; 1563 | conditionDataset.pointRadius = condPointRadius; 1564 | 1565 | rescaleY(this._forecastChart, { 1566 | axisId: "PrecipitationAxis", 1567 | precipMax: precipMax, 1568 | pad: 0.15, 1569 | }); 1570 | 1571 | this._forecastChart.update("none"); 1572 | } 1573 | } 1574 | 1575 | private _getUnit(measure) { 1576 | if (this._hass) { 1577 | const lengthUnit = this._hass.config.unit_system.length; 1578 | switch (measure) { 1579 | case "air_pressure": 1580 | return lengthUnit === "km" ? "hPa" : "inHg"; 1581 | case "length": 1582 | return lengthUnit; 1583 | case "precipitation": 1584 | return lengthUnit === "km" ? "mm" : "in"; 1585 | case "temperature": 1586 | return lengthUnit === "km" ? "°C" : "°F"; 1587 | case "wind_speed": 1588 | return lengthUnit === "km" ? "m/s" : "mph"; 1589 | default: 1590 | return this._hass.config.unit_system.length || ""; 1591 | } 1592 | } else { 1593 | return "km"; 1594 | } 1595 | } 1596 | 1597 | private _handlePopup(e, entity) { 1598 | e.stopPropagation(); 1599 | this._handleClick( 1600 | this, 1601 | this._hass, 1602 | this._config.tap_action, 1603 | entity.entity_id || entity 1604 | ); 1605 | } 1606 | 1607 | private _handleClick(node, hass, actionConfig, entityId) { 1608 | let e; 1609 | 1610 | if (actionConfig) { 1611 | switch (actionConfig.action) { 1612 | case "more-info": { 1613 | e = new Event("hass-more-info", { composed: true }); 1614 | e.detail = { entityId }; 1615 | node.dispatchEvent(e); 1616 | break; 1617 | } 1618 | case "navigate": { 1619 | if (!actionConfig.navigation_path) return; 1620 | window.history.pushState(null, "", actionConfig.navigation_path); 1621 | e = new Event("location-changed", { composed: true }); 1622 | e.detail = { replace: false }; 1623 | window.dispatchEvent(e); 1624 | break; 1625 | } 1626 | case "call-service": { 1627 | if (!actionConfig.service) return; 1628 | const [domain, service] = actionConfig.service.split(".", 2); 1629 | const data = { ...actionConfig.data }; 1630 | hass.callService(domain, service, data); 1631 | break; 1632 | } 1633 | case "url": { 1634 | if (!actionConfig.url_path) return; 1635 | window.location.href = actionConfig.url_path; 1636 | break; 1637 | } 1638 | case "fire-dom-event": { 1639 | e = new Event("ll-custom", { composed: true, bubbles: true }); 1640 | e.detail = actionConfig; 1641 | node.dispatchEvent(e); 1642 | break; 1643 | } 1644 | } 1645 | } 1646 | } 1647 | } 1648 | --------------------------------------------------------------------------------