├── yaml-editor ├── .gitignore ├── tsconfig.json ├── patch-schema.js ├── src │ ├── types.d.ts │ ├── icon.svg │ ├── index.ejs │ ├── index.css │ └── index.ts ├── README.md ├── webpack.config.js └── package.json ├── .gitignore ├── src ├── is-production.ts ├── jsonschema.ts ├── hot-reload.ts ├── style-hack.ts ├── recorder-types.ts ├── utils.ts ├── parse-config │ ├── themed-layout.ts │ ├── deprecations.ts │ ├── parse-statistics.ts │ ├── parse-color-scheme.ts │ ├── defaults.ts │ └── parse-config.ts ├── cache │ ├── date-ranges.test.ts │ ├── fetch-statistics.ts │ ├── fetch-states.ts │ ├── date-ranges.ts │ └── Cache.ts ├── duration │ ├── duration.test.ts │ └── duration.ts ├── filters │ ├── fft-regression.js │ ├── filters.test.ts │ └── filters.ts ├── touch-controller.ts ├── types.ts ├── plotly.ts └── plotly-graph-card.ts ├── .github ├── FUNDING.yml ├── workflows │ ├── validate.yaml │ └── build-on-release.yml └── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── docs └── resources │ ├── example1.png │ ├── offset-nowline.png │ ├── rangeselector.apng │ └── offset-temperature.png ├── hacs.json ├── .prettierrc.js ├── jest.config.js ├── changelog.md ├── tsconfig.json ├── script └── hot-reload.mjs ├── package.json ├── discussion-index.mjs └── readme.md /yaml-editor/.gitignore: -------------------------------------------------------------------------------- 1 | /dist -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | /dist 4 | /coverage -------------------------------------------------------------------------------- /src/is-production.ts: -------------------------------------------------------------------------------- 1 | export default process.env.NODE_ENV === "production"; 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | buy_me_a_coffee: dbuezas 4 | -------------------------------------------------------------------------------- /docs/resources/example1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dbuezas/lovelace-plotly-graph-card/HEAD/docs/resources/example1.png -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Plotly Graph Card", 3 | "render_readme": true, 4 | "filename": "plotly-graph-card.js" 5 | } 6 | -------------------------------------------------------------------------------- /docs/resources/offset-nowline.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dbuezas/lovelace-plotly-graph-card/HEAD/docs/resources/offset-nowline.png -------------------------------------------------------------------------------- /docs/resources/rangeselector.apng: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dbuezas/lovelace-plotly-graph-card/HEAD/docs/resources/rangeselector.apng -------------------------------------------------------------------------------- /docs/resources/offset-temperature.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dbuezas/lovelace-plotly-graph-card/HEAD/docs/resources/offset-temperature.png -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | semi: true, 3 | //trailingComma: "all", 4 | singleQuote: false, 5 | printWidth: 80, 6 | tabWidth: 2, 7 | }; 8 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ 2 | module.exports = { 3 | preset: "ts-jest", 4 | testEnvironment: "node", 5 | maxWorkers: 1, // this makes local testing faster 6 | }; 7 | -------------------------------------------------------------------------------- /yaml-editor/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "preserve", 5 | "noEmit": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "target": "esnext" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.github/workflows/validate.yaml: -------------------------------------------------------------------------------- 1 | name: HACS-Validate 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | jobs: 8 | hacs: 9 | name: HACS Action 10 | runs-on: "ubuntu-latest" 11 | steps: 12 | - uses: "actions/checkout@v2" 13 | - name: HACS Action 14 | uses: "hacs/action@main" 15 | with: 16 | category: "plugin" 17 | -------------------------------------------------------------------------------- /src/jsonschema.ts: -------------------------------------------------------------------------------- 1 | import { InputConfig } from "./types"; 2 | 3 | type With$fn = { 4 | [K in keyof T]: 5 | | (T[K] extends (infer U)[] // Handle arrays recursively 6 | ? With$fn[] 7 | : With$fn) // Handle everything else recursively 8 | | `${string}$ex$fn_REPLACER`; // Apply extension to everything 9 | }; 10 | 11 | export type JsonSchemaRoot = With$fn; 12 | -------------------------------------------------------------------------------- /changelog.md: -------------------------------------------------------------------------------- 1 | v1.4.0 2 | 3 | - Feature: long term statistics support (thanks to @FrnchFrgg) 4 | - Breaking change: yaml for attributes changed (use `attribute: temperature` instead of `climate.living::temperature`) see readme! (old way still works) 5 | - Fix: `minimal_response` attribute was ignored and it was set equal to `significant_changes_only`instead 6 | - Fix: default yaxes now applies to 30 yaxes (previously only 10) 7 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **Screenshots** 14 | If applicable, add screenshots to help explain your problem. 15 | 16 | **yaml** 17 | ```yaml 18 | type: custom:plotly-graph 19 | entities: 20 | - entity: sensor.my_sensor 21 | ``` 22 | 23 | **Additional context** 24 | Add any other context about the problem here. 25 | -------------------------------------------------------------------------------- /yaml-editor/patch-schema.js: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import path from "path"; 3 | import { fileURLToPath } from "url"; 4 | const __filename = fileURLToPath(import.meta.url); 5 | const __dirname = path.dirname(__filename); 6 | const file = path.join(__dirname, "src/schema.json"); // Create relative path for reading 7 | 8 | const patched = fs 9 | .readFileSync(file) 10 | .toString() 11 | .replaceAll( 12 | "^.*\\\\$ex\\\\$fn_REPLACER$", 13 | "^[\\\\s]*\\\\$(ex|fn)\\\\s[\\\\s\\\\S]+$", 14 | ); 15 | 16 | fs.writeFileSync(file, patched); 17 | 18 | console.log("Patch completed."); 19 | -------------------------------------------------------------------------------- /src/hot-reload.ts: -------------------------------------------------------------------------------- 1 | import isProduction from "./is-production"; 2 | 3 | if (!isProduction) { 4 | const socket = new WebSocket("ws://localhost:8081"); 5 | socket.addEventListener("connection", (event) => { 6 | console.log("connected ", event); 7 | }); 8 | socket.addEventListener("message", async (event) => { 9 | if ((window as any).no_hot_reload) return; 10 | console.log("Message from server ", event); 11 | const { action, payload } = JSON.parse(event.data); 12 | if (action === "update-app") window.location.reload(); 13 | if (action === "error") console.warn(payload); 14 | }); 15 | } 16 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2018", 4 | "module": "esnext", 5 | "moduleResolution": "node", 6 | "lib": ["es2018", "dom", "dom.iterable"], 7 | "noEmit": true, 8 | "noUnusedParameters": true, 9 | "noImplicitReturns": true, 10 | "noFallthroughCasesInSwitch": true, 11 | "strict": true, 12 | "noImplicitAny": false, 13 | "skipLibCheck": true, 14 | "resolveJsonModule": true, 15 | "experimentalDecorators": true, 16 | "allowSyntheticDefaultImports": true, 17 | "esModuleInterop": true 18 | }, 19 | "exclude": ["./yaml-editor/**"] 20 | } 21 | -------------------------------------------------------------------------------- /.github/workflows/build-on-release.yml: -------------------------------------------------------------------------------- 1 | name: Build on release 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v3 12 | 13 | - name: Install dependencies 14 | run: npm install 15 | 16 | - name: Update package.json 17 | run: npm version ${{ github.ref_name }} --git-tag-version false 18 | 19 | - name: Build 20 | run: npm run build 21 | 22 | - name: Upload release asset 23 | uses: svenstaro/upload-release-action@v2 24 | with: 25 | file: dist/plotly-graph-card.js 26 | -------------------------------------------------------------------------------- /src/style-hack.ts: -------------------------------------------------------------------------------- 1 | export function isTruthy(x: T | null): x is T { 2 | return Boolean(x); 3 | } 4 | 5 | const insertStyleHack = (styleEl: HTMLStyleElement) => { 6 | const style = Array.from( 7 | document.querySelectorAll(`style[id^="plotly.js"]`) 8 | ) 9 | .map((styleEl) => styleEl.sheet) 10 | .filter(isTruthy) 11 | .flatMap((sheet) => Array.from(sheet.cssRules)) 12 | .map((rule) => rule.cssText) 13 | .join("\n"); 14 | 15 | styleEl.innerHTML += ` 16 | .js-plotly-plot .plotly .modebar-btn { 17 | fill: rgb(136,136,136); 18 | } 19 | ${style}`; 20 | }; 21 | export default insertStyleHack; 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the solution you'd like** 11 | A clear and concise description of what you want to happen. 12 | 13 | **How would it be defined in yaml?** 14 | ```yaml 15 | type: custom:plotly-graph 16 | entities: 17 | - entity: sensor.monthly_internet_energy 18 | entity_feature_config: xxx 19 | global_feature_config: xxx 20 | ``` 21 | 22 | **Scribble** 23 | When applicable, paste a drawing of how the feature would look like 24 | 25 | **Additional context** 26 | Add any other context or screenshots about the feature request here. 27 | -------------------------------------------------------------------------------- /yaml-editor/src/types.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'monaco-editor/esm/vs/editor/common/services/languageFeatures.js' { 2 | export const ILanguageFeaturesService: { documentSymbolProvider: unknown } 3 | } 4 | 5 | declare module 'monaco-editor/esm/vs/editor/contrib/documentSymbols/browser/outlineModel.js' { 6 | import { type editor, type languages } from 'monaco-editor' 7 | 8 | export abstract class OutlineModel { 9 | static create(registry: unknown, model: editor.ITextModel): Promise 10 | 11 | asListOfDocumentSymbols(): languages.DocumentSymbol[] 12 | } 13 | } 14 | 15 | declare module 'monaco-editor/esm/vs/editor/standalone/browser/standaloneServices.js' { 16 | export const StandaloneServices: { 17 | get: (id: unknown) => { documentSymbolProvider: unknown } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /yaml-editor/README.md: -------------------------------------------------------------------------------- 1 | # Demo 2 | 3 | This demo is deployed to [monaco-yaml.js.org](https://monaco-yaml.js.org). It shows how 4 | `monaco-editor` and `monaco-yaml` can be used with 5 | [Webpack 5](https://webpack.js.org/concepts/entry-points). 6 | 7 | ## Table of Contents 8 | 9 | - [Prerequisites](#prerequisites) 10 | - [Setup](#setup) 11 | - [Running](#running) 12 | 13 | ## Prerequisites 14 | 15 | - [NodeJS](https://nodejs.org) 16 or higher 16 | - [npm](https://github.com/npm/cli) 8.1.2 or higher 17 | 18 | ## Setup 19 | 20 | To run the project locally, clone the repository and set it up: 21 | 22 | ```sh 23 | git clone https://github.com/remcohaszing/monaco-yaml 24 | cd monaco-yaml 25 | npm ci 26 | ``` 27 | 28 | ## Running 29 | 30 | To start it, simply run: 31 | 32 | ```sh 33 | npm --workspace demo start 34 | ``` 35 | 36 | The demo will open in your browser. 37 | -------------------------------------------------------------------------------- /src/recorder-types.ts: -------------------------------------------------------------------------------- 1 | // https://github.com/home-assistant/frontend/blob/dev/src/data/recorder.ts 2 | import { TimeDurationStr } from "./duration/duration"; 3 | 4 | export interface StatisticValue { 5 | statistic_id: string; 6 | start: string; 7 | end: string; 8 | last_reset: string | null; 9 | max: number | null; 10 | mean: number | null; 11 | min: number | null; 12 | sum: number | null; 13 | state: number | null; 14 | } 15 | 16 | export interface Statistics { 17 | [statisticId: string]: StatisticValue[]; 18 | } 19 | export const STATISTIC_TYPES = ["state", "sum", "min", "max", "mean"] as const; 20 | export type StatisticType = typeof STATISTIC_TYPES[number]; 21 | 22 | export const STATISTIC_PERIODS = [ 23 | "5minute", 24 | "hour", 25 | "day", 26 | "week", 27 | "month", 28 | ] as const; 29 | export type StatisticPeriod = typeof STATISTIC_PERIODS[number]; 30 | export type AutoPeriodConfig = Record; 31 | -------------------------------------------------------------------------------- /yaml-editor/webpack.config.js: -------------------------------------------------------------------------------- 1 | import CssMinimizerPlugin from 'css-minimizer-webpack-plugin' 2 | import HtmlWebPackPlugin from 'html-webpack-plugin' 3 | import MiniCssExtractPlugin from 'mini-css-extract-plugin' 4 | 5 | export default { 6 | output: { 7 | filename: '[contenthash].js' 8 | }, 9 | devtool: 'source-map', 10 | resolve: { 11 | extensions: ['.mjs', '.js', '.ts'] 12 | }, 13 | module: { 14 | rules: [ 15 | { 16 | test: /\.css$/, 17 | use: [MiniCssExtractPlugin.loader, 'css-loader'] 18 | }, 19 | { 20 | // Monaco editor uses .ttf icons. 21 | test: /\.(svg|ttf)$/, 22 | type: 'asset/resource' 23 | }, 24 | { 25 | test: /\.ts$/, 26 | loader: 'ts-loader', 27 | options: { transpileOnly: true } 28 | } 29 | ] 30 | }, 31 | optimization: { 32 | minimizer: ['...', new CssMinimizerPlugin()] 33 | }, 34 | plugins: [new HtmlWebPackPlugin(), new MiniCssExtractPlugin({ filename: '[contenthash].css' })] 35 | } 36 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | export const sleep = (ms: number) => 2 | new Promise((resolve) => setTimeout(resolve, ms)); 3 | export function getIsPureObject(val: any) { 4 | return typeof val === "object" && val !== null && !Array.isArray(val); 5 | } 6 | 7 | export function debounce(func: (delay?: number) => Promise) { 8 | let lastRunningPromise = Promise.resolve(); 9 | let waiting = { 10 | cancelled: false, 11 | }; 12 | return (delay?: number) => { 13 | waiting.cancelled = true; 14 | const me = { 15 | cancelled: false, 16 | }; 17 | waiting = me; 18 | return (lastRunningPromise = lastRunningPromise 19 | .catch(() => {}) 20 | .then( 21 | () => 22 | new Promise(async (resolve) => { 23 | if (delay) { 24 | await sleep(delay); 25 | } 26 | requestAnimationFrame(async () => { 27 | if (me.cancelled) resolve(); 28 | else resolve(func()); 29 | }); 30 | }) 31 | )); 32 | }; 33 | } 34 | -------------------------------------------------------------------------------- /src/parse-config/themed-layout.ts: -------------------------------------------------------------------------------- 1 | export type HATheme = { 2 | "card-background-color": string; 3 | "primary-background-color": string; 4 | "primary-color": string; 5 | "primary-text-color": string; 6 | "secondary-text-color": string; 7 | }; 8 | 9 | const themeAxisStyle = { 10 | tickcolor: "rgba(127,127,127,.3)", 11 | gridcolor: "rgba(127,127,127,.3)", 12 | linecolor: "rgba(127,127,127,.3)", 13 | zerolinecolor: "rgba(127,127,127,.3)", 14 | }; 15 | 16 | export default function getThemedLayout( 17 | haTheme: HATheme 18 | ): Partial { 19 | return { 20 | paper_bgcolor: haTheme["card-background-color"], 21 | plot_bgcolor: haTheme["card-background-color"], 22 | font: { 23 | color: haTheme["secondary-text-color"], 24 | size: 11, 25 | }, 26 | xaxis: { ...themeAxisStyle }, 27 | yaxis: { ...themeAxisStyle }, 28 | ...Object.fromEntries( 29 | Array.from({ length: 28 }).map((_, i) => [ 30 | `yaxis${i + 2}`, 31 | { ...themeAxisStyle }, 32 | ]) 33 | ), 34 | }; 35 | } 36 | -------------------------------------------------------------------------------- /yaml-editor/src/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/cache/date-ranges.test.ts: -------------------------------------------------------------------------------- 1 | import { subtractRanges } from "./date-ranges"; 2 | describe("data-ranges", () => { 3 | it("Should subtract left ", () => { 4 | const result = subtractRanges([[0, 10]], [[0, 5]]); 5 | expect(result).toEqual([[6, 10]]); 6 | }); 7 | it("Should subtract right ", () => { 8 | const result = subtractRanges([[0, 10]], [[5, 10]]); 9 | expect(result).toEqual([[0, 4]]); 10 | }); 11 | it("Should subtract middle ", () => { 12 | const result = subtractRanges([[0, 10]], [[3, 7]]); 13 | expect(result).toEqual([ 14 | [0, 2], 15 | [8, 10], 16 | ]); 17 | }); 18 | it("Should handle almost empty", () => { 19 | const result = subtractRanges([[0, 10]], [[1, 10]]); 20 | expect(result).toEqual([[0, 0]]); 21 | }); 22 | it("Should handle equl subraction", () => { 23 | const result = subtractRanges([[0, 10]], [[0, 10]]); 24 | expect(result).toEqual([]); 25 | }); 26 | it("Should handle empty singleton range", () => { 27 | const result = subtractRanges([[0, 10]], [[1, 9]]); 28 | expect(result).toEqual([ 29 | [0, 0], 30 | [10, 10], 31 | ]); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /yaml-editor/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "demo", 3 | "version": "1.0.0", 4 | "private": true, 5 | "type": "module", 6 | "scripts": { 7 | "start": "npm run schema && webpack serve --open --mode development", 8 | "build": "npm run schema &&rm -rf dist && webpack --mode production", 9 | "schema-1": "cd .. && typescript-json-schema --required tsconfig.json JsonSchemaRoot > yaml-editor/src/schema.json", 10 | "schema-2": "node patch-schema.js", 11 | "schema": "npm run schema-1 && npm run schema-2", 12 | "deploy": "pnpm run build && gh-pages -d dist" 13 | }, 14 | "dependencies": { 15 | "@fortawesome/fontawesome-free": "^6.0.0", 16 | "@schemastore/schema-catalog": "^0.0.6", 17 | "css-loader": "^7.0.0", 18 | "css-minimizer-webpack-plugin": "^7.0.0", 19 | "html-webpack-plugin": "^5.0.0", 20 | "mini-css-extract-plugin": "^2.0.0", 21 | "monaco-editor": "^0.50.0", 22 | "monaco-yaml": "^5.2.2", 23 | "ts-loader": "^9.0.0", 24 | "typescript-json-schema": "^0.65.1", 25 | "webpack": "^5.0.0", 26 | "webpack-cli": "^5.0.0", 27 | "webpack-dev-server": "^5.0.0" 28 | }, 29 | "devDependencies": { 30 | "gh-pages": "^6.2.0" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /script/hot-reload.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import WebSocket, { WebSocketServer } from "ws"; 3 | import chokidar from "chokidar"; 4 | 5 | const watchOptn = { 6 | // awaitWriteFinish: {stabilityThreshold:100, pollInterval:50}, 7 | ignoreInitial: true, 8 | }; 9 | async function hotReload() { 10 | const wss = new WebSocketServer({ port: 8081 }); 11 | wss.on("connection", () => console.log(wss.clients.size)); 12 | wss.on("close", () => console.log(wss.clients.size)); 13 | const sendToClients = ( 14 | /** @type {{ action: string; payload?: any }} */ message 15 | ) => { 16 | wss.clients.forEach(function each( 17 | /** @type {{ readyState: number; send: (arg0: string) => void; }} */ client 18 | ) { 19 | if (client.readyState === WebSocket.OPEN) { 20 | console.log("sending"); 21 | client.send(JSON.stringify(message)); 22 | } 23 | }); 24 | }; 25 | chokidar.watch("src", watchOptn).on("all", async (...args) => { 26 | console.log(args); 27 | try { 28 | sendToClients({ action: "update-app" }); 29 | } catch (e) { 30 | console.error(e); 31 | sendToClients({ action: "error", payload: e.message }); 32 | } 33 | }); 34 | } 35 | 36 | hotReload(); 37 | -------------------------------------------------------------------------------- /yaml-editor/src/index.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Plotly graph card yaml editor 7 | 8 | 9 | 10 | 11 | 33 |
34 | 35 |
36 |
37 |
38 | 39 |
40 | 41 | 42 | -------------------------------------------------------------------------------- /src/cache/fetch-statistics.ts: -------------------------------------------------------------------------------- 1 | import { HomeAssistant } from "custom-card-helpers"; 2 | import { Statistics, StatisticValue } from "../recorder-types"; 3 | import { CachedStatisticsEntity, EntityIdStatisticsConfig } from "../types"; 4 | 5 | async function fetchStatistics( 6 | hass: HomeAssistant, 7 | entity: EntityIdStatisticsConfig, 8 | [start, end]: [Date, Date] 9 | ): Promise { 10 | let statistics: StatisticValue[] | null = null; 11 | try { 12 | const statsP = hass.callWS({ 13 | type: "recorder/statistics_during_period", 14 | start_time: start.toISOString(), 15 | end_time: end.toISOString(), 16 | statistic_ids: [entity.entity], 17 | period: entity.period, 18 | }); 19 | statistics = (await statsP)[entity.entity]; 20 | } catch (e: any) { 21 | console.error(e); 22 | throw new Error( 23 | `Error fetching statistics of ${entity.entity}: ${JSON.stringify( 24 | e.message || "" 25 | )}` 26 | ); 27 | } 28 | return (statistics || []) 29 | .map((statistics) => ({ 30 | statistics, 31 | x: new Date(statistics.start), 32 | y: null, //depends on the statistic, will be set in getHistory 33 | })) 34 | .filter(({ x }) => x); 35 | } 36 | export default fetchStatistics; 37 | -------------------------------------------------------------------------------- /src/cache/fetch-states.ts: -------------------------------------------------------------------------------- 1 | import { HomeAssistant } from "custom-card-helpers"; 2 | import { 3 | CachedStateEntity, 4 | EntityIdAttrConfig, 5 | EntityIdStateConfig, 6 | HassEntity, 7 | isEntityIdAttrConfig, 8 | } from "../types"; 9 | 10 | async function fetchStates( 11 | hass: HomeAssistant, 12 | entity: EntityIdStateConfig | EntityIdAttrConfig, 13 | [start, end]: [Date, Date] 14 | ): Promise { 15 | const uri = 16 | `history/period/${start.toISOString()}?` + 17 | [ 18 | `filter_entity_id=${entity.entity}`, 19 | `significant_changes_only=0`, 20 | isEntityIdAttrConfig(entity) ? "" : "no_attributes&", 21 | isEntityIdAttrConfig(entity) ? "" : "minimal_response&", 22 | `end_time=${end.toISOString()}`, 23 | ] 24 | .filter(Boolean) 25 | .join("&"); 26 | let list: HassEntity[] | undefined; 27 | try { 28 | const lists: HassEntity[][] = (await hass.callApi("GET", uri)) || []; 29 | list = lists[0]; 30 | } catch (e: any) { 31 | console.error(e); 32 | throw new Error( 33 | `Error fetching states of ${entity.entity}: ${JSON.stringify( 34 | e.message || "" 35 | )}` 36 | ); 37 | } 38 | return (list || []) 39 | .map((state) => ({ 40 | state, 41 | x: new Date(state.last_updated || state.last_changed), 42 | y: null, // may be state or an attribute. Will be set when getting the history 43 | })) 44 | .filter(({ x }) => x); 45 | } 46 | export default fetchStates; 47 | -------------------------------------------------------------------------------- /src/duration/duration.test.ts: -------------------------------------------------------------------------------- 1 | import { parseTimeDuration } from "./duration"; 2 | 3 | describe("data-ranges", () => { 4 | const ms = 1; 5 | const s = ms * 1000; 6 | const m = s * 60; 7 | const h = m * 60; 8 | const d = h * 24; 9 | const w = d * 7; 10 | const M = d * 30; 11 | const y = d * 365; 12 | it("Should parse all units", () => { 13 | expect(parseTimeDuration("1ms")).toBe(1 * ms); 14 | expect(parseTimeDuration("1s")).toBe(1 * s); 15 | expect(parseTimeDuration("1m")).toBe(1 * m); 16 | expect(parseTimeDuration("1h")).toBe(1 * h); 17 | expect(parseTimeDuration("1d")).toBe(1 * d); 18 | expect(parseTimeDuration("1w")).toBe(1 * w); 19 | expect(parseTimeDuration("1M")).toBe(1 * M); 20 | expect(parseTimeDuration("1y")).toBe(1 * y); 21 | }); 22 | it("Should parse all signs", () => { 23 | expect(parseTimeDuration("1ms")).toBe(1 * ms); 24 | expect(parseTimeDuration("+1ms")).toBe(1 * ms); 25 | expect(parseTimeDuration("-1ms")).toBe(-1 * ms); 26 | }); 27 | it("Should parse all numbers", () => { 28 | expect(parseTimeDuration("1s")).toBe(1 * s); 29 | expect(parseTimeDuration("1.5s")).toBe(1.5 * s); 30 | }); 31 | it("Should parse undefined", () => { 32 | expect(() => parseTimeDuration(undefined)).toThrow(); 33 | }); 34 | it("Should throw when it can't parse", () => { 35 | // @ts-expect-error 36 | expect(() => parseTimeDuration("1")).toThrow(); 37 | // @ts-expect-error 38 | expect(() => parseTimeDuration("s")).toThrow(); 39 | // @ts-expect-error 40 | expect(() => parseTimeDuration("--1s")).toThrow(); 41 | // @ts-expect-error 42 | expect(() => parseTimeDuration("-1.1.1s")).toThrow(); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /src/filters/fft-regression.js: -------------------------------------------------------------------------------- 1 | import fft from "ndarray-fft"; 2 | import ndarray from "ndarray"; 3 | import BaseRegression, { checkArrayLength } from "ml-regression-base"; 4 | 5 | export default class FFTRegression extends BaseRegression { 6 | x0 = 0; 7 | dt = 0; 8 | filtered = []; 9 | constructor(x, y, degree) { 10 | super(); 11 | if (x === true) { 12 | throw new Error("not implemented"); 13 | } else { 14 | checkArrayLength(x, y); 15 | this._regress(x, y, degree); 16 | } 17 | } 18 | _regress(x, y, degree) { 19 | var re = ndarray(new Float64Array(y)); //, [y.length,1]) 20 | var im = ndarray(new Float64Array(Array(y.length).fill(0))); //, [y.length,1]) 21 | fft(1, re, im); 22 | this.x0 = x[0]; 23 | this.dt = (x[x.length - 1] - x[0]) / x.length; 24 | // coefficients beyond the degree are zeroed 25 | const sorted = Array.from(re.data) 26 | .map((x, i) => [x, i]) 27 | .sort((a, b) => b[0] - a[0]); 28 | 29 | for (let i = degree; i < sorted.length; i++) { 30 | re.set(sorted[i][1], 0); 31 | im.set(sorted[i][1], 0); 32 | } 33 | fft(-1, re, im); 34 | this.filtered = re.data; 35 | } 36 | 37 | toJSON() { 38 | throw new Error("not implemented"); 39 | } 40 | 41 | _predict(x) { 42 | return this.filtered[ 43 | Math.round((x - this.x0) / this.dt) % this.filtered.length 44 | ]; 45 | } 46 | 47 | computeX(y) { 48 | return "not implemented"; 49 | } 50 | 51 | toString(precision) { 52 | return "not implemented"; 53 | } 54 | 55 | toLaTeX(precision) { 56 | return this.toString(precision); 57 | } 58 | 59 | static load(json) { 60 | throw new Error("not implemented"); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "plotly-graph-card", 3 | "version": "to-be-set-on-release", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "(node ./script/hot-reload.mjs & esbuild src/plotly-graph-card.ts --servedir=dist --outdir=dist --bundle --sourcemap=inline)", 8 | "build": "esbuild src/plotly-graph-card.ts --outdir=dist --bundle --minify", 9 | "tsc": "tsc", 10 | "test": "jest", 11 | "test:watch": "jest --watchAll", 12 | "version": "npm run build && git add .", 13 | "npm-upgrade": "npx npm-upgrade" 14 | }, 15 | "author": "", 16 | "license": "ISC", 17 | "devDependencies": { 18 | "@types/jest": "^29.2.4", 19 | "@types/lodash": "^4.14.191", 20 | "@types/node": "^18.11.17", 21 | "@types/plotly.js": "^2.12.11", 22 | "@types/ws": "^8.5.4", 23 | "chokidar": "^3.5.3", 24 | "esbuild": "^0.16.10", 25 | "ts-jest": "^29.0.3", 26 | "ws": "^8.12.0" 27 | }, 28 | "dependencies": { 29 | "binary-search-bounds": "^2.0.5", 30 | "buffer": "^6.0.3", 31 | "custom-card-helpers": "^1.9.0", 32 | "date-fns": "^2.29.3", 33 | "deepmerge": "^4.2.2", 34 | "fourier-transform": "^1.1.2", 35 | "lodash": "^4.17.21", 36 | "ml-fft": "^1.3.5", 37 | "ml-regression-exponential": "^2.1.0", 38 | "ml-regression-logarithmic": "github:DoubleCorner/regression-logarithmic", 39 | "ml-regression-polynomial": "^2.2.0", 40 | "ml-regression-power": "^2.0.0", 41 | "ml-regression-robust-polynomial": "^3.0.0", 42 | "ml-regression-simple-linear": "^2.0.3", 43 | "ml-regression-theil-sen": "^2.0.0", 44 | "ndarray": "^1.0.19", 45 | "ndarray-fft": "^1.0.3", 46 | "plotly.js": "^2.34.0", 47 | "prettier": "^3.3.3", 48 | "propose": "^0.0.5", 49 | "typescript": "^5.6.3" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/cache/date-ranges.ts: -------------------------------------------------------------------------------- 1 | import { json } from "stream/consumers"; 2 | import { TimestampRange } from "../types"; 3 | 4 | const subtract_single_single = ( 5 | [a_start, a_end]: TimestampRange, 6 | [b_start, b_end]: TimestampRange 7 | ): TimestampRange[] => { 8 | // no intersection 9 | if (a_end < b_start) return [[a_start, a_end]]; 10 | if (b_end < a_start) return [[a_start, a_end]]; 11 | // a contains b 12 | if (a_start < b_start && b_end < a_end) 13 | return [ 14 | [a_start, b_start - 1], 15 | [b_end + 1, a_end], 16 | ]; 17 | // b contains a 18 | if (b_start <= a_start && a_end <= b_end) return []; 19 | // remove left 20 | if (b_start <= a_start && a_start <= b_end && b_end < a_end) 21 | return [[b_end + 1, a_end]]; 22 | // remove right 23 | if (a_start < b_start && b_start <= a_end && a_end <= b_end) 24 | return [[a_start, b_start - 1]]; 25 | else { 26 | throw new Error( 27 | `Error computing range subtraction. Please report an issue in the repo of this card and share this:` + 28 | JSON.stringify([a_start, a_end]) + 29 | JSON.stringify([b_start, b_end]) 30 | ); 31 | } 32 | }; 33 | 34 | const subtract_many_single = (as: TimestampRange[], b: TimestampRange) => 35 | as.flatMap((a) => subtract_single_single(a, b)); 36 | 37 | export const subtractRanges = (as: TimestampRange[], bs: TimestampRange[]) => 38 | bs.reduce((acc, curr) => subtract_many_single(acc, curr), as); 39 | 40 | export const compactRanges = (ranges: TimestampRange[]) => 41 | ranges 42 | .slice() 43 | .sort((a, b) => a[0] - b[0]) 44 | .reduce((acc, next) => { 45 | if (acc.length === 0) return [next]; 46 | const prev = acc[acc.length - 1]; 47 | if (prev[1] + 1 >= next[0]) { 48 | const merged: TimestampRange = [prev[0], next[1]]; 49 | return [...acc.slice(0, -1), merged]; 50 | } 51 | return [...acc, next]; 52 | }, [] as TimestampRange[]); 53 | -------------------------------------------------------------------------------- /src/parse-config/deprecations.ts: -------------------------------------------------------------------------------- 1 | import { parseTimeDuration } from "../duration/duration"; 2 | 3 | export default function getDeprecationError(path: string, value: any) { 4 | const e = _getDeprecationError(path, value); 5 | if (e) return new Error(`at [${path}]: ${e}`); 6 | return null; 7 | } 8 | function _getDeprecationError(path: string, value: any) { 9 | if (path.match(/^no_theme$/)) 10 | return `renamed to ha_theme (inverted logic) in v3.0.0`; 11 | if (path.match(/^no_default_layout$/)) 12 | return `replaced with more general raw-plotly-config in v3.0.0. See layout migration guide.`; 13 | if (path.match(/^offset$/)) return "renamed to time_offset in v3.0.0"; 14 | if (path.match(/^entities\.\d+\.offset$/)) { 15 | try { 16 | parseTimeDuration(value); 17 | return 'renamed to time_offset in v3.0.0 to avoid conflicts with bar-offsets.'; 18 | } catch (e) { 19 | // bar-offsets are numbers without time unit 20 | } 21 | } 22 | if (path.match(/^entities\.\d+\.lambda$/)) 23 | return `removed in v3.0.0, use filters instead. See lambda migration guide.`; 24 | if (path.match(/^entities\.\d+\.show_value\.right_margin$/)) 25 | return "removed in v3.0.0, use `true` and set the global `time_offset` or `layout.margins.r` to make space at the right. "; 26 | if (path.match(/^significant_changes_only$/)) 27 | return "removed in v3.0.0, it is now always set to false"; 28 | if (path.match(/^minimal_response$/)) 29 | return "removed in v3.0.0, if you need attributes use the 'attribute' parameter instead."; 30 | return null; 31 | } 32 | -------------------------------------------------------------------------------- /src/parse-config/parse-statistics.ts: -------------------------------------------------------------------------------- 1 | import { getIsPureObject } from "../utils"; 2 | import { 3 | AutoPeriodConfig, 4 | StatisticPeriod, 5 | StatisticType, 6 | STATISTIC_PERIODS, 7 | STATISTIC_TYPES, 8 | } from "../recorder-types"; 9 | 10 | import { parseTimeDuration } from "../duration/duration"; 11 | 12 | function getIsAutoPeriodConfig(periodObj: any): periodObj is AutoPeriodConfig { 13 | if (!getIsPureObject(periodObj)) return false; 14 | let lastDuration = -1; 15 | for (const durationStr in periodObj) { 16 | const period = periodObj[durationStr]; 17 | const duration = parseTimeDuration(durationStr as any); // will throw if not a valud duration 18 | if (!STATISTIC_PERIODS.includes(period as any)) { 19 | throw new Error( 20 | `Error parsing automatic period config: "${period}" not expected. Must be ${STATISTIC_PERIODS}` 21 | ); 22 | } 23 | if (duration <= lastDuration) { 24 | throw new Error( 25 | `Error parsing automatic period config: ranges must be sorted in ascending order, "${durationStr}" not expected` 26 | ); 27 | } 28 | lastDuration = duration; 29 | } 30 | return true; 31 | } 32 | export function parseStatistics( 33 | visible_range: number[], 34 | statistic?: StatisticType, 35 | period?: StatisticPeriod | "auto" | AutoPeriodConfig 36 | ) { 37 | if (!statistic && !period) return null; 38 | statistic ??= "mean"; 39 | period ??= "hour"; 40 | if (period === "auto") { 41 | period = { 42 | "0": "5minute", 43 | "100h": "hour", 44 | "100d": "day", 45 | "100w": "week", 46 | "100M": "month", 47 | }; 48 | } 49 | if (getIsAutoPeriodConfig(period)) { 50 | const autoPeriod = period; 51 | period = "5minute"; 52 | const timeSpan = visible_range[1] - visible_range[0]; 53 | const mapping = Object.entries(autoPeriod).map( 54 | ([duration, period]) => 55 | [parseTimeDuration(duration as any), period] as [ 56 | number, 57 | StatisticPeriod 58 | ] 59 | ); 60 | 61 | for (const [fromMS, aPeriod] of mapping) { 62 | /* 63 | the durations are validated to be sorted in ascendinig order 64 | when the config is parsed 65 | */ 66 | if (timeSpan >= fromMS) period = aPeriod; 67 | } 68 | } 69 | if (!STATISTIC_TYPES.includes(statistic)) 70 | throw new Error( 71 | `statistic: "${statistic}" is not valid. Use ${STATISTIC_TYPES}` 72 | ); 73 | if (!STATISTIC_PERIODS.includes(period)) 74 | throw new Error( 75 | `period: "${period}" is not valid. Use ${STATISTIC_PERIODS}` 76 | ); 77 | return { statistic, period }; 78 | } 79 | -------------------------------------------------------------------------------- /yaml-editor/src/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --background-color: hsl(0, 0%, 96%); 3 | --editor-background: hsl(60, 100%, 100%); 4 | --error-color: hsl(0, 85%, 62%); 5 | --foreground-color: hsl(0, 0%, 0%); 6 | --primary-color: hsl(0, 0%, 79%); 7 | --shadow-color: hsla(0, 0%, 27%, 0.239); 8 | --scrollbar-color: hsla(0, 0%, 47%, 0.4); 9 | --warning-color: hsl(49, 100%, 40%); 10 | } 11 | 12 | @media (prefers-color-scheme: dark) { 13 | :root { 14 | --background-color: hsl(0, 0%, 23%); 15 | --editor-background: hsl(0, 0%, 12%); 16 | --foreground-color: hsl(0, 0%, 100%); 17 | --shadow-color: hsl(0, 0%, 43%); 18 | } 19 | } 20 | 21 | body { 22 | background: var(--background-color); 23 | display: flex; 24 | flex-flow: column; 25 | font-family: sans-serif; 26 | height: 100vh; 27 | margin: 0; 28 | } 29 | 30 | h1 { 31 | margin: 0 auto 0 1rem; 32 | } 33 | 34 | nav { 35 | align-items: center; 36 | background: var(--primary-color); 37 | box-shadow: 0px 5px 5px var(--shadow-color); 38 | display: flex; 39 | flex: 0 0 auto; 40 | height: 3rem; 41 | justify-content: space-between; 42 | } 43 | 44 | .nav-icon { 45 | margin-right: 1rem; 46 | text-decoration: none; 47 | } 48 | 49 | .nav-icon > img { 50 | vertical-align: middle; 51 | } 52 | 53 | main { 54 | background: var(--editor-background); 55 | box-shadow: 0 0 10px var(--shadow-color); 56 | display: flex; 57 | flex: 1 1 auto; 58 | flex-flow: column; 59 | margin: 1.5rem; 60 | } 61 | 62 | #schema-selection { 63 | background-color: var(--editor-background); 64 | border: none; 65 | border-bottom: 1px solid var(--shadow-color); 66 | color: var(--foreground-color); 67 | width: 100%; 68 | } 69 | 70 | #breadcrumbs { 71 | border-bottom: 1px solid var(--shadow-color); 72 | color: var(--foreground-color); 73 | flex: 0 0 1rem; 74 | } 75 | 76 | .breadcrumb { 77 | cursor: pointer; 78 | } 79 | 80 | #breadcrumbs::before, 81 | .breadcrumb:not(:last-child)::after { 82 | content: '›'; 83 | margin: 0 0.2rem; 84 | } 85 | 86 | .breadcrumb.array::before { 87 | content: '[]'; 88 | } 89 | 90 | .breadcrumb.object::before { 91 | content: '{}'; 92 | } 93 | 94 | #editor { 95 | flex: 1 1 auto; 96 | } 97 | 98 | #problems { 99 | border-top: 1px solid var(--shadow-color); 100 | flex: 0 0 20vh; 101 | color: var(--foreground-color); 102 | overflow-y: scroll; 103 | } 104 | 105 | .problem { 106 | align-items: center; 107 | cursor: pointer; 108 | display: flex; 109 | padding: 0.25rem; 110 | } 111 | 112 | .problem:hover { 113 | background-color: var(--shadow-color); 114 | } 115 | 116 | .problem-text { 117 | margin-left: 0.5rem; 118 | } 119 | 120 | .problem .codicon-warning { 121 | color: var(--warning-color); 122 | } 123 | 124 | .problem .codicon-error { 125 | color: var(--error-color); 126 | } 127 | 128 | *::-webkit-scrollbar { 129 | box-shadow: 1px 0 0 0 var(--scrollbar-color) inset; 130 | width: 14px; 131 | } 132 | 133 | *::-webkit-scrollbar-thumb { 134 | background: var(--scrollbar-color); 135 | } 136 | .toast { 137 | background-color: #323232; 138 | color: white; 139 | padding: 10px 20px; 140 | margin-bottom: 10px; 141 | border-radius: 4px; 142 | opacity: 0; 143 | transition: opacity 0.5s ease-in-out; 144 | } 145 | 146 | .toast.show { 147 | opacity: 1; 148 | } 149 | -------------------------------------------------------------------------------- /src/duration/duration.ts: -------------------------------------------------------------------------------- 1 | import { HomeAssistant } from "custom-card-helpers"; 2 | import { 3 | endOfDay, 4 | endOfHour, 5 | endOfMinute, 6 | endOfMonth, 7 | endOfQuarter, 8 | endOfWeek, 9 | endOfYear, 10 | setDefaultOptions, 11 | startOfDay, 12 | startOfHour, 13 | startOfMinute, 14 | startOfMonth, 15 | startOfQuarter, 16 | startOfWeek, 17 | startOfYear, 18 | } from "date-fns"; 19 | 20 | export const timeUnits = { 21 | ms: 1, 22 | s: 1000, 23 | m: 1000 * 60, 24 | h: 1000 * 60 * 60, 25 | d: 1000 * 60 * 60 * 24, 26 | w: 1000 * 60 * 60 * 24 * 7, 27 | M: 1000 * 60 * 60 * 24 * 30, 28 | y: 1000 * 60 * 60 * 24 * 365, 29 | }; 30 | type TimeUnit = keyof typeof timeUnits; 31 | export type TimeDurationStr = `${number}${TimeUnit}` | `0`; 32 | 33 | /** 34 | * 35 | * @param str 1.5s, -2m, 1h, 1d, 1w, 1M, 1.5y 36 | * @returns duration in milliseconds 37 | */ 38 | export const parseTimeDuration = (str: TimeDurationStr | undefined): number => { 39 | if (str === "0") return 0; 40 | if (!str || !str.match) 41 | throw new Error(`Cannot parse "${str}" as a duration`); 42 | const match = str.match( 43 | /^(?[+-])?(?\d*(\.\d)?)(?(ms|s|m|h|d|w|M|y))$/, 44 | ); 45 | if (!match || !match.groups) 46 | throw new Error(`Cannot parse "${str}" as a duration`); 47 | const g = match.groups; 48 | const sign = g.sign === "-" ? -1 : 1; 49 | const number = parseFloat(g.number); 50 | if (Number.isNaN(number)) 51 | throw new Error(`Cannot parse "${str}" as a duration`); 52 | const unit = timeUnits[g.unit as TimeUnit]; 53 | if (unit === undefined) 54 | throw new Error(`Cannot parse "${str}" as a duration`); 55 | 56 | return sign * number * unit; 57 | }; 58 | 59 | export const isTimeDuration = (str: any) => { 60 | try { 61 | parseTimeDuration(str); 62 | return true; 63 | } catch (e) { 64 | return false; 65 | } 66 | }; 67 | 68 | export const setDateFnDefaultOptions = (hass: HomeAssistant) => { 69 | const first_weekday: "sunday" | "saturday" | "monday" | "language" = ( 70 | hass.locale as any 71 | ).first_weekday; 72 | const weekStartsOn = ( 73 | { 74 | language: undefined, 75 | sunday: 0, 76 | monday: 1, 77 | tuesday: 2, 78 | wednesday: 3, 79 | thursday: 4, 80 | friday: 5, 81 | saturday: 6, 82 | } as const 83 | )[first_weekday]; 84 | 85 | setDefaultOptions({ 86 | locale: { code: hass.locale.language }, 87 | weekStartsOn, 88 | }); 89 | }; 90 | export type RelativeTimeStr = 91 | | "current_minute" 92 | | "current_hour" 93 | | "current_day" 94 | | "current_week" 95 | | "current_month" 96 | | "current_quarter" 97 | | "current_year"; 98 | 99 | export const parseRelativeTime = (str: RelativeTimeStr): [number, number] => { 100 | const now = new Date(); 101 | switch (str) { 102 | case "current_minute": 103 | return [+startOfMinute(now), +endOfMinute(now)]; 104 | case "current_hour": 105 | return [+startOfHour(now), +endOfHour(now)]; 106 | case "current_day": 107 | return [+startOfDay(now), +endOfDay(now)]; 108 | case "current_week": 109 | return [+startOfWeek(now), +endOfWeek(now)]; 110 | case "current_month": 111 | return [+startOfMonth(now), +endOfMonth(now)]; 112 | case "current_quarter": 113 | return [+startOfQuarter(now), +endOfQuarter(now)]; 114 | case "current_year": 115 | return [+startOfYear(now), +endOfYear(now)]; 116 | } 117 | throw new Error(`${str} is not a dynamic relative time`); 118 | }; 119 | 120 | export const isRelativeTime = (str: any) => { 121 | try { 122 | parseRelativeTime(str); 123 | return true; 124 | } catch (e) { 125 | return false; 126 | } 127 | }; 128 | -------------------------------------------------------------------------------- /discussion-index.mjs: -------------------------------------------------------------------------------- 1 | import { Octokit } from "@octokit/core"; 2 | 3 | const owner = "dbuezas"; 4 | const repo = "lovelace-plotly-graph-card"; 5 | const auth = "github_pat_xxx"; // create from https://github.com/settings/tokens 6 | import fetch from "node-fetch"; 7 | 8 | const octokit = new Octokit({ 9 | request: { 10 | fetch: fetch, 11 | }, 12 | auth, 13 | }); 14 | 15 | async function fetchDiscussions(owner, repo, cursor) { 16 | const query = ` 17 | query ($owner: String!, $repo: String!, $cursor: String) { 18 | repository(owner: $owner, name: $repo) { 19 | discussions(first: 100, after: $cursor) { 20 | totalCount 21 | pageInfo { 22 | endCursor 23 | hasNextPage 24 | } 25 | nodes { 26 | id 27 | title 28 | url 29 | body 30 | comments(first: 70) { 31 | totalCount 32 | nodes { 33 | id 34 | body 35 | createdAt 36 | replies(first: 70) { 37 | totalCount 38 | nodes { 39 | body 40 | } 41 | } 42 | } 43 | pageInfo { 44 | endCursor 45 | hasNextPage 46 | } 47 | } 48 | } 49 | } 50 | } 51 | } 52 | `; 53 | 54 | const response = await octokit.graphql(query, { 55 | owner, 56 | repo, 57 | cursor, 58 | }); 59 | 60 | // console.log(JSON.stringify(response, 0, 2)); 61 | function extractImageUrls(markdown) { 62 | const imageUrls = []; 63 | // This regex specifically matches URLs starting with the specified GitHub path 64 | const regexes = [/!\[.*?\]\((.*?)\)/g, /src="([^"]+)"/g]; 65 | for (const regex of regexes) { 66 | let match; 67 | while ((match = regex.exec(markdown)) !== null) { 68 | imageUrls.push(match[1]); 69 | } 70 | } 71 | return imageUrls; 72 | } 73 | const data = response.repository.discussions.nodes 74 | .map(({ title, url, body, comments }) => { 75 | const images = extractImageUrls(body); 76 | for (const comment of comments.nodes) { 77 | images.push(...extractImageUrls(comment.body)); 78 | for (const reply of comment.replies.nodes) { 79 | images.push(...extractImageUrls(reply.body)); 80 | } 81 | } 82 | return { title, url, images }; 83 | }) 84 | .filter(({ images }) => images.length); 85 | 86 | const md = data.map(({ title, url, images }) => { 87 | let groups = []; 88 | let group = []; 89 | groups.push(group); 90 | for (let i = 0; i < images.length; i++) { 91 | if (i % 4 === 0) { 92 | group = []; 93 | groups.push(group); 94 | } 95 | group.push(images[i]); 96 | } 97 | 98 | let txt = ""; 99 | for (const group of groups) { 100 | if (group.length === 0) continue; 101 | txt += "\n"; 102 | for (const img of group) { 103 | txt += `\n`; 104 | } 105 | txt += "\n"; 106 | } 107 | return ` 108 | ## [${title}](${url}) 109 | 110 | ${txt} 111 |
112 | 113 | ---`; 114 | }); 115 | 116 | return { 117 | md: md.join("\n"), 118 | next: response.repository.discussions.pageInfo.endCursor, 119 | }; 120 | } 121 | 122 | let { md, next } = await fetchDiscussions(owner, repo, null); 123 | let result = md; 124 | while (next) { 125 | console.log(next); 126 | let xx = await fetchDiscussions(owner, repo, next); 127 | result += "\n" + xx.md; 128 | next = xx.next; 129 | } 130 | 131 | console.log(result); 132 | -------------------------------------------------------------------------------- /src/filters/filters.test.ts: -------------------------------------------------------------------------------- 1 | import filters, { FilterInput } from "./filters"; 2 | 3 | const RIGHT_1 = { integrate: { offset: "2d" } } satisfies FilterInput; 4 | const RIGHT_11 = { integrate: "d" } satisfies FilterInput; 5 | const RIGHT_2 = "integrate" satisfies FilterInput; 6 | const RIGHT_3 = "delta" satisfies FilterInput; 7 | const RIGHT_4 = "deduplicate_adjacent" satisfies FilterInput; 8 | const RIGHT_5 = "force_numeric" satisfies FilterInput; 9 | const RIGHT_6 = "resample" satisfies FilterInput; 10 | const RIGHT_7 = { resample: "5m" } satisfies FilterInput; 11 | 12 | //@ts-expect-error 13 | const WRONG_1 = "add" satisfies FilterInput; 14 | //@ts-expect-error 15 | const WRONG_2 = { integrate: 3 } satisfies FilterInput; 16 | 17 | const data = { 18 | states: [], 19 | statistics: [], 20 | ys: [0, 1, null, 2], 21 | xs: [ 22 | "2022-12-20T18:07:28.000Z", 23 | "2022-12-20T18:07:29.000Z", 24 | "2022-12-20T18:07:29.500Z", 25 | "2022-12-20T18:07:30.000Z", 26 | ].map((s) => new Date(s)), 27 | attributes: { 28 | unit_of_measurement: "w", 29 | }, 30 | history: [], 31 | vars: {}, 32 | meta: {} as any, 33 | hass: {} as any, 34 | }; 35 | 36 | describe("filters", () => { 37 | it("offset", () => { 38 | expect(filters.add(-1)(data)).toEqual({ 39 | attributes: { 40 | unit_of_measurement: "w", 41 | }, 42 | xs: [ 43 | new Date("2022-12-20T18:07:28.000Z"), 44 | new Date("2022-12-20T18:07:29.000Z"), 45 | new Date("2022-12-20T18:07:30.000Z"), 46 | ], 47 | ys: [-1, 0, 1], 48 | }); 49 | }); 50 | it("multiply * 2", () => { 51 | expect(filters.multiply(2)(data)).toEqual({ 52 | attributes: { 53 | unit_of_measurement: "w", 54 | }, 55 | xs: [ 56 | new Date("2022-12-20T18:07:28.000Z"), 57 | new Date("2022-12-20T18:07:29.000Z"), 58 | new Date("2022-12-20T18:07:30.000Z"), 59 | ], 60 | ys: [0, 2, 4], 61 | }); 62 | }); 63 | it("calibrate", () => { 64 | expect(filters.calibrate_linear(["1 -> 11", "11 -> 21"])(data)).toEqual({ 65 | attributes: { 66 | unit_of_measurement: "w", 67 | }, 68 | xs: [ 69 | new Date("2022-12-20T18:07:28.000Z"), 70 | new Date("2022-12-20T18:07:29.000Z"), 71 | new Date("2022-12-20T18:07:30.000Z"), 72 | ], 73 | ys: [1, 11, 21], 74 | }); 75 | }); 76 | it("derivate", () => { 77 | expect(filters.derivate("s")(data)).toEqual({ 78 | attributes: { 79 | unit_of_measurement: "w/s", 80 | }, 81 | xs: [ 82 | new Date("2022-12-20T18:07:29.000Z"), 83 | new Date("2022-12-20T18:07:30.000Z"), 84 | ], 85 | ys: [1, 1], 86 | }); 87 | }); 88 | it("integrate", () => { 89 | expect(filters.integrate("s")(data)).toEqual({ 90 | attributes: { 91 | unit_of_measurement: "w*s", 92 | }, 93 | xs: [ 94 | new Date("2022-12-20T18:07:29.000Z"), 95 | new Date("2022-12-20T18:07:30.000Z"), 96 | ], 97 | ys: [1, 3], 98 | }); 99 | }); 100 | it("map_x", () => { 101 | expect(filters.map_x(`new Date(x.setHours(1))`)(data)).toEqual({ 102 | attributes: { 103 | unit_of_measurement: "w", 104 | }, 105 | xs: [ 106 | new Date("2022-12-20T00:07:28.000Z"), 107 | new Date("2022-12-20T00:07:29.000Z"), 108 | new Date("2022-12-20T00:07:30.000Z"), 109 | ], 110 | ys: [0, 1, 2], 111 | }); 112 | }); 113 | it("map_y", () => { 114 | expect(filters.map_y(`Math.sqrt(y)`)(data)).toEqual({ 115 | attributes: { 116 | unit_of_measurement: "w", 117 | }, 118 | xs: [ 119 | new Date("2022-12-20T00:07:28.000Z"), 120 | new Date("2022-12-20T00:07:29.000Z"), 121 | new Date("2022-12-20T00:07:30.000Z"), 122 | ], 123 | 124 | ys: [0, 1, 1.4142135623730951], 125 | }); 126 | }); 127 | it("fn", () => { 128 | expect( 129 | filters.fn(`({xs,ys,...rest}) => ({xs:ys, ys:xs,...rest})`)(data), 130 | ).toEqual({ 131 | attributes: { 132 | unit_of_measurement: "w", 133 | }, 134 | xs: [0, 1, 2], 135 | ys: [ 136 | new Date("2022-12-20T00:07:28.000Z"), 137 | new Date("2022-12-20T00:07:29.000Z"), 138 | new Date("2022-12-20T00:07:30.000Z"), 139 | ], 140 | }); 141 | }); 142 | }); 143 | -------------------------------------------------------------------------------- /src/parse-config/parse-color-scheme.ts: -------------------------------------------------------------------------------- 1 | import { InputConfig } from "../types"; 2 | 3 | /* 4 | Usage example in YAML: 5 | 6 | color_scheme: accent 7 | color_scheme: 0 # both mean the same 8 | */ 9 | export type ColorSchemeArray = string[]; 10 | // prettier-ignore 11 | const colorSchemes = { 12 | // https://vega.github.io/vega/docs/schemes/#categorical 13 | accent: ["#7fc97f","#beaed4","#fdc086","#ffff99","#386cb0","#f0027f","#bf5b17","#666666"], 14 | category10: ["#1f77b4","#ff7f0e","#2ca02c","#d62728","#9467bd","#8c564b","#e377c2","#7f7f7f","#bcbd22","#17becf"], 15 | category20: ["#1f77b4","#aec7e8","#ff7f0e","#ffbb78","#2ca02c","#98df8a","#d62728","#ff9896","#9467bd","#c5b0d5","#8c564b","#c49c94","#e377c2","#f7b6d2","#7f7f7f","#c7c7c7","#bcbd22","#dbdb8d","#17becf","#9edae5"], 16 | category20b: ["#393b79","#5254a3","#6b6ecf","#9c9ede","#637939","#8ca252","#b5cf6b","#cedb9c","#8c6d31","#bd9e39","#e7ba52","#e7cb94","#843c39","#ad494a","#d6616b","#e7969c","#7b4173","#a55194","#ce6dbd","#de9ed6"], 17 | category20c: ["#3182bd","#6baed6","#9ecae1","#c6dbef","#e6550d","#fd8d3c","#fdae6b","#fdd0a2","#31a354","#74c476","#a1d99b","#c7e9c0","#756bb1","#9e9ac8","#bcbddc","#dadaeb","#636363","#969696","#bdbdbd","#d9d9d9"], 18 | dark2: ["#1b9e77","#d95f02","#7570b3","#e7298a","#66a61e","#e6ab02","#a6761d","#666666"], 19 | paired: ["#a6cee3","#1f78b4","#b2df8a","#33a02c","#fb9a99","#e31a1c","#fdbf6f","#ff7f00","#cab2d6","#6a3d9a","#ffff99","#b15928"], 20 | pastel1: ["#fbb4ae","#b3cde3","#ccebc5","#decbe4","#fed9a6","#ffffcc","#e5d8bd","#fddaec","#f2f2f2"], 21 | pastel2: ["#b3e2cd","#fdcdac","#cbd5e8","#f4cae4","#e6f5c9","#fff2ae","#f1e2cc","#cccccc"], 22 | set1: ["#e41a1c","#377eb8","#4daf4a","#984ea3","#ff7f00","#ffff33","#a65628","#f781bf","#999999"], 23 | set2: ["#66c2a5","#fc8d62","#8da0cb","#e78ac3","#a6d854","#ffd92f","#e5c494","#b3b3b3"], 24 | set3: ["#8dd3c7","#ffffb3","#bebada","#fb8072","#80b1d3","#fdb462","#b3de69","#fccde5","#d9d9d9","#bc80bd","#ccebc5","#ffed6f"], 25 | tableau10: ["#4c78a8","#f58518","#e45756","#72b7b2","#54a24b","#eeca3b","#b279a2","#ff9da6","#9d755d","#bab0ac"], 26 | tableau20: ["#4c78a8","#9ecae9","#f58518","#ffbf79","#54a24b","#88d27a","#b79a20","#f2cf5b","#439894","#83bcb6","#e45756","#ff9d98","#79706e","#bab0ac","#d67195","#fcbfd2","#b279a2","#d6a5c9","#9e765f","#d8b5a5"], 27 | // https://www.omnisci.com/blog/12-color-palettes-for-telling-better-stories-with-your-data 28 | retro_metro: ["#ea5545", "#f46a9b", "#ef9b20", "#edbf33", "#ede15b", "#bdcf32", "#87bc45", "#27aeef", "#b33dc6"], 29 | dutch_field: ["#e60049", "#0bb4ff", "#50e991", "#e6d800", "#9b19f5", "#ffa300", "#dc0ab4", "#b3d4ff", "#00bfa0"], 30 | river_nights: ["#b30000", "#7c1158", "#4421af", "#1a53ff", "#0d88e6", "#00b7c7", "#5ad45a", "#8be04e", "#ebdc78"], 31 | spring_pastels: ["#fd7f6f", "#7eb0d5", "#b2e061", "#bd7ebe", "#ffb55a", "#ffee65", "#beb9db", "#fdcce5", "#8bd3c7"], 32 | blue_to_yellow: ["#115f9a", "#1984c5", "#22a7f0", "#48b5c4", "#76c68f", "#a6d75b", "#c9e52f", "#d0ee11", "#d0f400"], 33 | grey_to_red: ["#d7e1ee", "#cbd6e4", "#bfcbdb", "#b3bfd1", "#a4a2a8", "#df8879", "#c86558", "#b04238", "#991f17"], 34 | black_to_pink: ["#2e2b28", "#3b3734", "#474440", "#54504c", "#6b506b", "#ab3da9", "#de25da", "#eb44e8", "#ff80ff"], 35 | blue_to_red: ["#1984c5", "#22a7f0", "#63bff0", "#a7d5ed", "#e2e2e2", "#e1a692", "#de6e56", "#e14b31", "#c23728"], 36 | orange_to_purple: ["#ffb400", "#d2980d", "#a57c1b", "#786028", "#363445", "#48446e", "#5e569b", "#776bcd", "#9080ff"], 37 | pink_foam: ["#54bebe", "#76c8c8", "#98d1d1", "#badbdb", "#dedad2", "#e4bcad", "#df979e", "#d7658b", "#c80064"], 38 | salmon_to_aqua: ["#e27c7c", "#a86464", "#6d4b4b", "#503f3f", "#333333", "#3c4e4b", "#466964", "#599e94", "#6cd4c5"], 39 | } 40 | function isColorSchemeArray(obj: any): obj is ColorSchemeArray { 41 | return Array.isArray(obj); 42 | } 43 | 44 | export type ColorSchemeNames = keyof typeof colorSchemes; 45 | 46 | export function parseColorScheme( 47 | color_scheme: InputConfig["color_scheme"] 48 | ): ColorSchemeArray { 49 | const schemeName = color_scheme ?? "category10"; 50 | const colorScheme = isColorSchemeArray(schemeName) 51 | ? schemeName 52 | : colorSchemes[schemeName] || 53 | colorSchemes[Object.keys(colorSchemes)[schemeName]] || 54 | null; 55 | if (colorScheme === null) { 56 | throw new Error( 57 | `color_scheme: "${color_scheme}" is not valid. Valid are an array of colors (see readme) or ${Object.keys( 58 | colorSchemes 59 | )}` 60 | ); 61 | } 62 | return colorScheme; 63 | } 64 | -------------------------------------------------------------------------------- /src/touch-controller.ts: -------------------------------------------------------------------------------- 1 | import { Layout, LayoutAxis } from "plotly.js"; 2 | 3 | type PlotlyEl = Plotly.PlotlyHTMLElement & { 4 | data: (Plotly.PlotData & { entity: string })[]; 5 | layout: Plotly.Layout; 6 | }; 7 | const zoomedRange = (axis: Partial, zoom: number) => { 8 | if (!axis || !axis.range) return undefined; 9 | const center = (+axis.range[1] + +axis.range[0]) / 2; 10 | if (isNaN(center)) return undefined; // probably a categorical axis. Don't zoom 11 | const radius = (+axis.range[1] - +axis.range[0]) / zoom / 2; 12 | return [center - radius, center + radius]; 13 | }; 14 | const ONE_FINGER_DOUBLE_TAP_ZOOM_MS_THRESHOLD = 250; 15 | export class TouchController { 16 | isEnabled = true; 17 | lastTouches?: TouchList; 18 | clientX = 0; 19 | clientY = 0; 20 | lastSingleTouchTimestamp = 0; 21 | elRect?: DOMRect; 22 | el: PlotlyEl; 23 | onZoomStart: () => any; 24 | onZoomEnd: () => any; 25 | state: "one finger" | "two fingers" | "idle" = "idle"; 26 | constructor(param: { 27 | el: PlotlyEl; 28 | onZoomStart: () => any; 29 | onZoomEnd: () => any; 30 | }) { 31 | this.el = param.el; 32 | this.onZoomStart = param.onZoomStart; 33 | this.onZoomEnd = param.onZoomEnd; 34 | } 35 | disconnect() { 36 | this.el.removeEventListener("touchmove", this.onTouchMove); 37 | this.el.removeEventListener("touchstart", this.onTouchStart); 38 | this.el.removeEventListener("touchend", this.onTouchEnd); 39 | } 40 | connect() { 41 | this.el.addEventListener("touchmove", this.onTouchMove, { 42 | capture: true, 43 | }); 44 | this.el.addEventListener("touchstart", this.onTouchStart, { 45 | capture: true, 46 | }); 47 | this.el.addEventListener("touchend", this.onTouchEnd, { 48 | capture: true, 49 | }); 50 | } 51 | 52 | onTouchStart = async (e: TouchEvent) => { 53 | if (!this.isEnabled) return; 54 | const stateWas = this.state; 55 | this.state = "idle"; 56 | if (e.touches.length == 1) { 57 | const now = Date.now(); 58 | if ( 59 | now - this.lastSingleTouchTimestamp < 60 | ONE_FINGER_DOUBLE_TAP_ZOOM_MS_THRESHOLD 61 | ) { 62 | e.stopPropagation(); 63 | e.stopImmediatePropagation(); 64 | this.state = "one finger"; 65 | this.clientX = e.touches[0].clientX; 66 | this.clientY = e.touches[0].clientY; 67 | this.lastTouches = e.touches; 68 | this.elRect = this.el.getBoundingClientRect(); 69 | } else { 70 | this.lastSingleTouchTimestamp = now; 71 | } 72 | } else if (e.touches.length == 2) { 73 | this.state = "two fingers"; 74 | this.lastTouches = e.touches; 75 | this.clientX = (e.touches[0].clientX + e.touches[1].clientX) / 2; 76 | this.clientY = (e.touches[0].clientY + e.touches[1].clientY) / 2; 77 | } 78 | if (stateWas === "idle" && stateWas !== this.state) { 79 | this.onZoomStart(); 80 | } 81 | }; 82 | 83 | onTouchMove = async (e: TouchEvent) => { 84 | if (!this.isEnabled) return; 85 | 86 | if (e.touches.length === 1 && this.state === "one finger") 87 | this.handleSingleFingerZoom(e); 88 | if (e.touches.length === 2 && this.state === "two fingers") 89 | this.handleTwoFingersZoom(e); 90 | }; 91 | async handleSingleFingerZoom(e: TouchEvent) { 92 | e.preventDefault(); 93 | e.stopPropagation(); 94 | e.stopImmediatePropagation(); 95 | const ts_old = this.lastTouches!; 96 | this.lastTouches = e.touches; 97 | const ts_new = e.touches; 98 | const dist = ts_new[0].clientY - ts_old[0].clientY; 99 | 100 | await this.handleZoom(dist); 101 | } 102 | async handleTwoFingersZoom(e: TouchEvent) { 103 | e.preventDefault(); 104 | e.stopPropagation(); 105 | e.stopImmediatePropagation(); 106 | const ts_old = this.lastTouches!; 107 | this.lastTouches = e.touches; 108 | const ts_new = e.touches; 109 | const spread_old = Math.sqrt( 110 | (ts_old[0].clientX - ts_old[1].clientX) ** 2 + 111 | (ts_old[0].clientY - ts_old[1].clientY) ** 2 112 | ); 113 | const spread_new = Math.sqrt( 114 | (ts_new[0].clientX - ts_new[1].clientX) ** 2 + 115 | (ts_new[0].clientY - ts_new[1].clientY) ** 2 116 | ); 117 | await this.handleZoom(spread_new - spread_old); 118 | } 119 | async handleZoom(dist: number) { 120 | const wheelEvent = new WheelEvent("wheel", { 121 | clientX: this.clientX, 122 | clientY: this.clientY, 123 | deltaX: 0, 124 | deltaY: -dist, 125 | }); 126 | 127 | this.el.querySelector(".nsewdrag.drag")!.dispatchEvent(wheelEvent); 128 | } 129 | 130 | onTouchEnd = () => { 131 | if (!this.isEnabled) return; 132 | 133 | if (this.state !== "idle") { 134 | this.onZoomEnd(); 135 | this.state = "idle"; 136 | } 137 | }; 138 | } 139 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ColorSchemeArray, 3 | ColorSchemeNames, 4 | } from "./parse-config/parse-color-scheme"; 5 | 6 | import { RelativeTimeStr, TimeDurationStr } from "./duration/duration"; 7 | import { 8 | AutoPeriodConfig, 9 | StatisticPeriod, 10 | StatisticType, 11 | StatisticValue, 12 | } from "./recorder-types"; 13 | 14 | import { HassEntity } from "home-assistant-js-websocket"; 15 | import { FilterFn, FilterInput } from "./filters/filters"; 16 | import type filters from "./filters/filters"; 17 | import internal from "stream"; 18 | 19 | export { HassEntity } from "home-assistant-js-websocket"; 20 | 21 | export type YValue = number | string | null; 22 | 23 | export type InputConfig = { 24 | type: "custom:plotly-graph"; 25 | /** 26 | * The time to show on load. 27 | * It can be the number of hour (e.g 12), 28 | * a duration string, e.g 100ms, 10s, 30.5m, 2h, 7d, 2w, 1M, 1y, 29 | * or relative time, i.e: 30 | * * current_minute 31 | * * current_hour 32 | * * current_day 33 | * * current_week 34 | * * current_month 35 | * * current_quarter 36 | * * current_year 37 | */ 38 | hours_to_show?: number | TimeDurationStr | RelativeTimeStr; 39 | /** Either a number (seconds), or "auto" */ 40 | refresh_interval?: number | "auto"; // in seconds 41 | color_scheme?: ColorSchemeNames | ColorSchemeArray | number; 42 | title?: string; 43 | offset?: TimeDurationStr; 44 | entities: ({ 45 | entity?: string; 46 | name?: string; 47 | attribute?: string; 48 | statistic?: StatisticType; 49 | period?: StatisticPeriod | "auto" | AutoPeriodConfig; 50 | unit_of_measurement?: string; 51 | internal?: boolean; 52 | show_value?: 53 | | boolean 54 | | { 55 | right_margin: number; 56 | }; 57 | offset?: TimeDurationStr; 58 | extend_to_present?: boolean; 59 | filters?: FilterInput[]; 60 | on_legend_click?: Function; 61 | on_legend_dblclick?: Function; 62 | on_click?: Function; 63 | } & Partial)[]; 64 | defaults?: { 65 | entity?: Partial; 66 | xaxes?: Partial; 67 | yaxes?: Partial; 68 | }; 69 | on_dblclick?: Function; 70 | layout?: Partial; 71 | config?: Partial; 72 | ha_theme?: boolean; 73 | raw_plotly_config?: boolean; 74 | significant_changes_only?: boolean; // defaults to false 75 | minimal_response?: boolean; // defaults to true 76 | disable_pinch_to_zoom?: boolean; // defaults to false 77 | autorange_after_scroll?: boolean; // defaults to false 78 | preset?: string | string[]; 79 | }; 80 | 81 | export type EntityConfig = EntityIdConfig & { 82 | unit_of_measurement?: string; 83 | internal: boolean; 84 | show_value: 85 | | boolean 86 | | { 87 | right_margin: number; 88 | }; 89 | offset: number; 90 | extend_to_present: boolean; 91 | filters: FilterFn[]; 92 | on_legend_click: Function; 93 | on_legend_dblclick: Function; 94 | on_click: Function; 95 | } & Partial; 96 | 97 | export type Config = { 98 | title?: string; 99 | hours_to_show: number; 100 | refresh_interval: number | "auto"; // in seconds 101 | offset: number; 102 | entities: EntityConfig[]; 103 | layout: Partial; 104 | config: Partial; 105 | ha_theme: boolean; 106 | raw_plotly_config: boolean; 107 | significant_changes_only: boolean; 108 | minimal_response: boolean; 109 | disable_pinch_to_zoom: boolean; 110 | visible_range: [number, number]; 111 | on_dblclick: Function; 112 | autorange_after_scroll: boolean; 113 | }; 114 | export type EntityIdStateConfig = { 115 | entity: string; 116 | }; 117 | export type EntityIdAttrConfig = { 118 | entity: string; 119 | attribute: string; 120 | }; 121 | export type EntityIdStatisticsConfig = { 122 | entity: string; 123 | statistic: StatisticType; 124 | period: StatisticPeriod; 125 | }; 126 | export type EntityIdConfig = 127 | | EntityIdStateConfig 128 | | EntityIdAttrConfig 129 | | EntityIdStatisticsConfig; 130 | 131 | export function isEntityIdStateConfig( 132 | entityConfig: EntityIdConfig, 133 | ): entityConfig is EntityIdStateConfig { 134 | return !( 135 | isEntityIdAttrConfig(entityConfig) || 136 | isEntityIdStatisticsConfig(entityConfig) 137 | ); 138 | } 139 | export function isEntityIdAttrConfig( 140 | entityConfig: EntityIdConfig, 141 | ): entityConfig is EntityIdAttrConfig { 142 | return !!entityConfig["attribute"]; 143 | } 144 | export function isEntityIdStatisticsConfig( 145 | entityConfig: EntityIdConfig, 146 | ): entityConfig is EntityIdStatisticsConfig { 147 | return !!entityConfig["statistic"]; 148 | } 149 | 150 | export type Timestamp = number; 151 | 152 | export type CachedBaseEntity = { 153 | fake_boundary_datapoint?: true; 154 | x: Date; 155 | y: YValue; 156 | }; 157 | export type CachedStateEntity = CachedBaseEntity & { 158 | state: HassEntity; 159 | }; 160 | export type CachedStatisticsEntity = CachedBaseEntity & { 161 | statistics: StatisticValue; 162 | }; 163 | export type CachedEntity = CachedStateEntity | CachedStatisticsEntity; 164 | export type EntityData = { 165 | states: HassEntity[]; 166 | statistics: StatisticValue[]; 167 | xs: Date[]; 168 | ys: YValue[]; 169 | }; 170 | 171 | export type TimestampRange = Timestamp[]; // [Timestamp, Timestamp]; 172 | -------------------------------------------------------------------------------- /src/plotly.ts: -------------------------------------------------------------------------------- 1 | // import Plotly from "plotly.js-dist"; 2 | // export default Plotly as typeof import("plotly.js"); 3 | 4 | // TODO: optimize bundle size 5 | window.global = window; 6 | var Plotly = require("plotly.js/lib/core") as typeof import("plotly.js"); 7 | Plotly.register([ 8 | // traces 9 | require("plotly.js/lib/bar"), 10 | require("plotly.js/lib/box"), 11 | require("plotly.js/lib/heatmap"), 12 | require("plotly.js/lib/histogram"), 13 | require("plotly.js/lib/histogram2d"), 14 | require("plotly.js/lib/histogram2dcontour"), 15 | require("plotly.js/lib/contour"), 16 | 17 | require("plotly.js/lib/scatterternary"), 18 | require("plotly.js/lib/violin"), 19 | require("plotly.js/lib/funnel"), 20 | require("plotly.js/lib/waterfall"), 21 | // require("plotly.js/lib/image"), // NOGO 22 | require("plotly.js/lib/pie"), 23 | require("plotly.js/lib/sunburst"), 24 | require("plotly.js/lib/treemap"), 25 | require("plotly.js/lib/icicle"), 26 | require("plotly.js/lib/funnelarea"), 27 | 28 | require("plotly.js/lib/scatter3d"), 29 | require("plotly.js/lib/surface"), 30 | require("plotly.js/lib/isosurface"), 31 | require("plotly.js/lib/volume"), 32 | require("plotly.js/lib/mesh3d"), 33 | require("plotly.js/lib/cone"), 34 | require("plotly.js/lib/streamtube"), 35 | require("plotly.js/lib/scattergeo"), 36 | require("plotly.js/lib/choropleth"), 37 | require("plotly.js/lib/pointcloud"), 38 | require("plotly.js/lib/heatmapgl"), 39 | require("plotly.js/lib/parcats"), 40 | // require("plotly.js/lib/scattermapbox"), 41 | // require("plotly.js/lib/choroplethmapbox"), 42 | // // require("plotly.js/lib/densitymapbox"), 43 | require("plotly.js/lib/sankey"), 44 | require("plotly.js/lib/indicator"), 45 | require("plotly.js/lib/table"), 46 | require("plotly.js/lib/carpet"), 47 | require("plotly.js/lib/scattercarpet"), 48 | require("plotly.js/lib/contourcarpet"), 49 | require("plotly.js/lib/ohlc"), 50 | require("plotly.js/lib/candlestick"), 51 | require("plotly.js/lib/scatterpolar"), 52 | require("plotly.js/lib/barpolar"), 53 | 54 | // transforms 55 | require("plotly.js/lib/aggregate"), 56 | require("plotly.js/lib/filter"), 57 | require("plotly.js/lib/groupby"), 58 | require("plotly.js/lib/sort"), 59 | 60 | // components 61 | require("plotly.js/lib/calendars"), 62 | 63 | // locales 64 | require("plotly.js/lib/locales/af.js"), 65 | require("plotly.js/lib/locales/am.js"), 66 | require("plotly.js/lib/locales/ar-dz.js"), 67 | require("plotly.js/lib/locales/ar-eg.js"), 68 | require("plotly.js/lib/locales/ar.js"), 69 | require("plotly.js/lib/locales/az.js"), 70 | require("plotly.js/lib/locales/bg.js"), 71 | require("plotly.js/lib/locales/bs.js"), 72 | require("plotly.js/lib/locales/ca.js"), 73 | require("plotly.js/lib/locales/cs.js"), 74 | require("plotly.js/lib/locales/cy.js"), 75 | require("plotly.js/lib/locales/da.js"), 76 | require("plotly.js/lib/locales/de-ch.js"), 77 | require("plotly.js/lib/locales/de.js"), 78 | require("plotly.js/lib/locales/el.js"), 79 | require("plotly.js/lib/locales/eo.js"), 80 | require("plotly.js/lib/locales/es-ar.js"), 81 | require("plotly.js/lib/locales/es-pe.js"), 82 | require("plotly.js/lib/locales/es.js"), 83 | require("plotly.js/lib/locales/et.js"), 84 | require("plotly.js/lib/locales/eu.js"), 85 | require("plotly.js/lib/locales/fa.js"), 86 | require("plotly.js/lib/locales/fi.js"), 87 | require("plotly.js/lib/locales/fo.js"), 88 | require("plotly.js/lib/locales/fr-ch.js"), 89 | require("plotly.js/lib/locales/fr.js"), 90 | require("plotly.js/lib/locales/gl.js"), 91 | require("plotly.js/lib/locales/gu.js"), 92 | require("plotly.js/lib/locales/he.js"), 93 | require("plotly.js/lib/locales/hi-in.js"), 94 | require("plotly.js/lib/locales/hr.js"), 95 | require("plotly.js/lib/locales/hu.js"), 96 | require("plotly.js/lib/locales/hy.js"), 97 | require("plotly.js/lib/locales/id.js"), 98 | require("plotly.js/lib/locales/is.js"), 99 | require("plotly.js/lib/locales/it.js"), 100 | require("plotly.js/lib/locales/ja.js"), 101 | require("plotly.js/lib/locales/ka.js"), 102 | require("plotly.js/lib/locales/km.js"), 103 | require("plotly.js/lib/locales/ko.js"), 104 | require("plotly.js/lib/locales/lt.js"), 105 | require("plotly.js/lib/locales/lv.js"), 106 | require("plotly.js/lib/locales/me-me.js"), 107 | require("plotly.js/lib/locales/me.js"), 108 | require("plotly.js/lib/locales/mk.js"), 109 | require("plotly.js/lib/locales/ml.js"), 110 | require("plotly.js/lib/locales/ms.js"), 111 | require("plotly.js/lib/locales/mt.js"), 112 | require("plotly.js/lib/locales/nl-be.js"), 113 | require("plotly.js/lib/locales/nl.js"), 114 | require("plotly.js/lib/locales/no.js"), 115 | require("plotly.js/lib/locales/pa.js"), 116 | require("plotly.js/lib/locales/pl.js"), 117 | require("plotly.js/lib/locales/pt-br.js"), 118 | require("plotly.js/lib/locales/pt-pt.js"), 119 | require("plotly.js/lib/locales/rm.js"), 120 | require("plotly.js/lib/locales/ro.js"), 121 | require("plotly.js/lib/locales/ru.js"), 122 | require("plotly.js/lib/locales/si.js"), 123 | require("plotly.js/lib/locales/sk.js"), 124 | require("plotly.js/lib/locales/sl.js"), 125 | require("plotly.js/lib/locales/sq.js"), 126 | require("plotly.js/lib/locales/sr-sr.js"), 127 | require("plotly.js/lib/locales/sr.js"), 128 | require("plotly.js/lib/locales/sv.js"), 129 | require("plotly.js/lib/locales/sw.js"), 130 | require("plotly.js/lib/locales/ta.js"), 131 | require("plotly.js/lib/locales/th.js"), 132 | require("plotly.js/lib/locales/tr.js"), 133 | require("plotly.js/lib/locales/tt.js"), 134 | require("plotly.js/lib/locales/uk.js"), 135 | require("plotly.js/lib/locales/ur.js"), 136 | require("plotly.js/lib/locales/vi.js"), 137 | require("plotly.js/lib/locales/zh-cn.js"), 138 | require("plotly.js/lib/locales/zh-hk.js"), 139 | require("plotly.js/lib/locales/zh-tw.js"), 140 | ]); 141 | 142 | export default Plotly; 143 | //*/ 144 | -------------------------------------------------------------------------------- /yaml-editor/src/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | editor, 3 | languages, 4 | MarkerSeverity, 5 | type Position, 6 | Range, 7 | Uri, 8 | } from "monaco-editor"; 9 | import * as monaco from "monaco-editor"; 10 | import { ILanguageFeaturesService } from "monaco-editor/esm/vs/editor/common/services/languageFeatures.js"; 11 | import { OutlineModel } from "monaco-editor/esm/vs/editor/contrib/documentSymbols/browser/outlineModel.js"; 12 | import { StandaloneServices } from "monaco-editor/esm/vs/editor/standalone/browser/standaloneServices.js"; 13 | import { configureMonacoYaml, type SchemasSettings } from "monaco-yaml"; 14 | 15 | import "./index.css"; 16 | import schema from "./schema.json"; 17 | 18 | window.MonacoEnvironment = { 19 | getWorker(moduleId, label) { 20 | switch (label) { 21 | case "editorWorkerService": 22 | return new Worker( 23 | new URL("monaco-editor/esm/vs/editor/editor.worker", import.meta.url), 24 | ); 25 | case "yaml": 26 | return new Worker(new URL("monaco-yaml/yaml.worker", import.meta.url)); 27 | default: 28 | throw new Error(`Unknown label ${label}`); 29 | } 30 | }, 31 | }; 32 | 33 | const defaultSchema: SchemasSettings = { 34 | uri: window.location.href, 35 | schema, 36 | fileMatch: ["plotly-graph.yaml"], 37 | }; 38 | 39 | const monacoYaml = configureMonacoYaml(monaco, { 40 | schemas: [defaultSchema], 41 | completion: true, 42 | format: true, 43 | hover: true, 44 | validate: true, 45 | }); 46 | 47 | function showToast(message: string) { 48 | const toastContainer = document.getElementById("toast-container")!; 49 | const toast = document.createElement("div"); 50 | toast.className = "toast"; 51 | toast.innerText = message; 52 | toastContainer.appendChild(toast); 53 | setTimeout(() => { 54 | toast.classList.add("show"); 55 | }, 100); 56 | setTimeout(() => { 57 | toast.classList.remove("show"); 58 | setTimeout(() => toast.remove(), 500); // Remove after animation 59 | }, 3000); 60 | } 61 | 62 | if (localStorage["plotly-graph"]) 63 | showToast("Recovered yaml from local storage"); 64 | 65 | const value = 66 | localStorage["plotly-graph"] || 67 | `type: custom:plotly-graph 68 | entities: 69 | - entity: sensor.x 70 | - entity: sensor.y 71 | `; 72 | 73 | const ed = editor.create(document.getElementById("editor")!, { 74 | automaticLayout: true, 75 | model: editor.createModel(value, "yaml", Uri.parse("plotly-graph.yaml")), 76 | theme: window.matchMedia("(prefers-color-scheme: dark)").matches 77 | ? "vs-dark" 78 | : "vs-light", 79 | quickSuggestions: { 80 | other: true, 81 | comments: false, 82 | strings: true, 83 | }, 84 | formatOnType: true, 85 | }); 86 | 87 | /** 88 | * Get the document symbols that contain the given position. 89 | * 90 | * @param symbols 91 | * The symbols to iterate. 92 | * @param position 93 | * The position for which to filter document symbols. 94 | * @yields 95 | * The document symbols that contain the given position. 96 | */ 97 | function* iterateSymbols( 98 | symbols: languages.DocumentSymbol[], 99 | position: Position, 100 | ): Iterable { 101 | for (const symbol of symbols) { 102 | if (Range.containsPosition(symbol.range, position)) { 103 | yield symbol; 104 | if (symbol.children) { 105 | yield* iterateSymbols(symbol.children, position); 106 | } 107 | } 108 | } 109 | } 110 | 111 | ed.onDidChangeModelContent(() => { 112 | localStorage["plotly-graph"] = ed.getValue(); 113 | }); 114 | 115 | ed.onDidChangeCursorPosition(async (event) => { 116 | const breadcrumbs = document.getElementById("breadcrumbs")!; 117 | const { documentSymbolProvider } = StandaloneServices.get( 118 | ILanguageFeaturesService, 119 | ); 120 | const outline = await OutlineModel.create( 121 | documentSymbolProvider, 122 | ed.getModel()!, 123 | ); 124 | const symbols = outline.asListOfDocumentSymbols(); 125 | while (breadcrumbs.lastChild) { 126 | breadcrumbs.lastChild.remove(); 127 | } 128 | for (const symbol of iterateSymbols(symbols, event.position)) { 129 | const breadcrumb = document.createElement("span"); 130 | breadcrumb.setAttribute("role", "button"); 131 | breadcrumb.classList.add("breadcrumb"); 132 | breadcrumb.textContent = symbol.name; 133 | breadcrumb.title = symbol.detail; 134 | if (symbol.kind === languages.SymbolKind.Array) { 135 | breadcrumb.classList.add("array"); 136 | } else if (symbol.kind === languages.SymbolKind.Module) { 137 | breadcrumb.classList.add("object"); 138 | } 139 | breadcrumb.addEventListener("click", () => { 140 | ed.setPosition({ 141 | lineNumber: symbol.range.startLineNumber, 142 | column: symbol.range.startColumn, 143 | }); 144 | ed.focus(); 145 | }); 146 | breadcrumbs.append(breadcrumb); 147 | } 148 | }); 149 | 150 | editor.onDidChangeMarkers(([resource]) => { 151 | const problems = document.getElementById("problems")!; 152 | const markers = editor.getModelMarkers({ resource }); 153 | while (problems.lastChild) { 154 | problems.lastChild.remove(); 155 | } 156 | for (const marker of markers) { 157 | if (marker.severity === MarkerSeverity.Hint) { 158 | continue; 159 | } 160 | const wrapper = document.createElement("div"); 161 | wrapper.setAttribute("role", "button"); 162 | const codicon = document.createElement("div"); 163 | const text = document.createElement("div"); 164 | wrapper.classList.add("problem"); 165 | codicon.classList.add( 166 | "codicon", 167 | marker.severity === MarkerSeverity.Warning 168 | ? "codicon-warning" 169 | : "codicon-error", 170 | ); 171 | text.classList.add("problem-text"); 172 | text.textContent = marker.message; 173 | wrapper.append(codicon, text); 174 | wrapper.addEventListener("click", () => { 175 | ed.setPosition({ 176 | lineNumber: marker.startLineNumber, 177 | column: marker.startColumn, 178 | }); 179 | ed.focus(); 180 | }); 181 | problems.append(wrapper); 182 | } 183 | }); 184 | -------------------------------------------------------------------------------- /src/cache/Cache.ts: -------------------------------------------------------------------------------- 1 | import { HomeAssistant } from "custom-card-helpers"; 2 | import { compactRanges, subtractRanges } from "./date-ranges"; 3 | import fetchStatistics from "./fetch-statistics"; 4 | import fetchStates from "./fetch-states"; 5 | import { 6 | TimestampRange, 7 | isEntityIdAttrConfig, 8 | EntityConfig, 9 | isEntityIdStateConfig, 10 | isEntityIdStatisticsConfig, 11 | CachedEntity, 12 | CachedStatisticsEntity, 13 | CachedStateEntity, 14 | EntityData, 15 | } from "../types"; 16 | type FetchConfig = 17 | | { 18 | statistic: "state" | "sum" | "min" | "max" | "mean"; 19 | period: "5minute" | "hour" | "day" | "week" | "month"; 20 | entity: string; 21 | } 22 | | { 23 | attribute: string; 24 | entity: string; 25 | } 26 | | { 27 | entity: string; 28 | }; 29 | export function mapValues( 30 | o: Record, 31 | fn: (value: T, key: string) => S 32 | ) { 33 | return Object.fromEntries(Object.entries(o).map(([k, v]) => [k, fn(v, k)])); 34 | } 35 | async function fetchSingleRange( 36 | hass: HomeAssistant, 37 | entity: FetchConfig, 38 | [startT, endT]: number[] 39 | ): Promise<{ 40 | range: [number, number]; 41 | history: CachedEntity[]; 42 | }> { 43 | // We fetch slightly more than requested (i.e the range visible in the screen). The reason is the following: 44 | // When fetching data in a range `[startT,endT]`, Home Assistant adds a fictitious datapoint at 45 | // the start of the fetched period containing a copy of the first datapoint that occurred before 46 | // `startT`, except if there is actually one at `startT`. 47 | // We fetch slightly more than requested/visible (`[startT-1,endT]`) and we mark the datapoint at 48 | // `startT-1` to be deleted (`fake_boundary_datapoint`). When merging the fetched data into the 49 | // cache, we keep the fictitious datapoint only if it's placed at the start (see `add` function), otherwise it's 50 | // discarded. 51 | // In general, we don't really know whether the datapoint is fictitious or it's a real datapoint 52 | // that happened to be exactly at `startT-1`, therefore we purposely fetch it outside the requested range 53 | // (which is `[startT,endT]`) and we leave it out of the "known cached ranges". 54 | // If it happens to be a a real datapoint, it will be fetched properly when the user scrolls/zooms bring it into 55 | // the visible part of the screen. 56 | // 57 | // Examples: 58 | // 59 | // * = fictitious 60 | // + = real 61 | // _ = fetched range 62 | // 63 | // _________ 1st fetch 64 | // * + + 65 | // ^ 66 | // '-- point kept because it's at the start-edge of the trace and it's outside the visible range 67 | // 68 | // _______ 2nd fetch 69 | // * + * + + 70 | // ^ ^ 71 | // | '--- discarded as it was fictitious and not at the start-edge 72 | // '--- point at the edge, kept 73 | // 74 | // ________ 3rd fetch 75 | // * + + +* + + 76 | // ^ ^ 77 | // | '--- discarded as it is fictitious 78 | // '--- point at the edge, kept 79 | // 80 | // The above does not apply to statistics data where there are no fake data points. 81 | 82 | const l = Math.max(0, 5000 - (endT - startT)); // The HA API doesn't add the fake boundary if the interval requested is too small 83 | const start = new Date(startT - 1 - l); 84 | endT = Math.min(endT, Date.now()); 85 | const end = new Date(endT); 86 | let history: CachedEntity[]; 87 | if (isEntityIdStatisticsConfig(entity)) { 88 | history = await fetchStatistics(hass, entity, [start, end]); 89 | } else { 90 | history = await fetchStates(hass, entity, [start, end]); 91 | if (history.length) { 92 | history[0].fake_boundary_datapoint = true; 93 | } 94 | } 95 | 96 | let range: [number, number] = [startT, endT]; 97 | return { 98 | range, 99 | history, 100 | }; 101 | } 102 | 103 | export function getEntityKey(entity: FetchConfig) { 104 | if (isEntityIdAttrConfig(entity)) { 105 | return `${entity.entity}::attribute:`; 106 | } else if (isEntityIdStatisticsConfig(entity)) { 107 | return `${entity.entity}::statistics::${entity.period}`; 108 | } else if (isEntityIdStateConfig(entity)) { 109 | return `${entity.entity}`; 110 | } 111 | throw new Error(`Entity malformed:${JSON.stringify(entity)}`); 112 | } 113 | 114 | const MIN_SAFE_TIMESTAMP = Date.parse("0001-01-02T00:00:00.000Z"); 115 | export default class Cache { 116 | ranges: Record = {}; 117 | histories: Record = {}; 118 | busy: Promise = Promise.resolve(null as unknown as EntityData); // mutex 119 | 120 | add(entity: FetchConfig, states: CachedEntity[], range: [number, number]) { 121 | const entityKey = getEntityKey(entity); 122 | let h = (this.histories[entityKey] ??= []); 123 | h.push(...states); 124 | h.sort((a, b) => +a.x - +b.x); 125 | if (!isEntityIdStatisticsConfig(entity)) { 126 | h = h.filter((x, i) => i == 0 || !x.fake_boundary_datapoint); 127 | } 128 | h = h.filter((_, i) => +h[i - 1]?.x !== +h[i].x); 129 | this.histories[entityKey] = h; 130 | this.ranges[entityKey] ??= []; 131 | this.ranges[entityKey].push(range); 132 | this.ranges[entityKey] = compactRanges(this.ranges[entityKey]); 133 | } 134 | 135 | clearCache() { 136 | this.ranges = {}; 137 | this.histories = {}; 138 | } 139 | 140 | getData(entity: FetchConfig): EntityData { 141 | let key = getEntityKey(entity); 142 | const history = this.histories[key] || []; 143 | const data: EntityData = { 144 | xs: [], 145 | ys: [], 146 | states: [], 147 | statistics: [], 148 | }; 149 | data.xs = history.map(({ x }) => x); 150 | if (isEntityIdStatisticsConfig(entity)) { 151 | data.statistics = (history as CachedStatisticsEntity[]).map( 152 | ({ statistics }) => statistics 153 | ); 154 | data.ys = data.statistics.map((s) => s[entity.statistic]); 155 | } else if (isEntityIdAttrConfig(entity)) { 156 | data.states = (history as CachedStateEntity[]).map(({ state }) => state); 157 | data.ys = data.states.map((s) => s.attributes[entity.attribute]); 158 | } else if (isEntityIdStateConfig(entity)) { 159 | data.states = (history as CachedStateEntity[]).map(({ state }) => state); 160 | data.ys = data.states.map((s) => s.state); 161 | } else 162 | throw new Error( 163 | `Unrecognised fetch type for ${(entity as EntityConfig).entity}` 164 | ); 165 | data.ys = data.ys.map((y) => 166 | // see https://github.com/dbuezas/lovelace-plotly-graph-card/issues/146 167 | // and https://github.com/dbuezas/lovelace-plotly-graph-card/commit/3d915481002d03011bcc8409c2dcc6e6fb7c8674#r94899109 168 | y === "unavailable" || y === "none" || y === "unknown" ? null : y 169 | ); 170 | return data; 171 | } 172 | async fetch(range: TimestampRange, entity: FetchConfig, hass: HomeAssistant) { 173 | return (this.busy = this.busy 174 | .catch(() => {}) 175 | .then(async () => { 176 | range = range.map((n) => Math.max(MIN_SAFE_TIMESTAMP, n)); // HA API can't handle negative years 177 | if (entity.entity) { 178 | const entityKey = getEntityKey(entity); 179 | this.ranges[entityKey] ??= []; 180 | const rangesToFetch = subtractRanges([range], this.ranges[entityKey]); 181 | for (const aRange of rangesToFetch) { 182 | const fetchedHistory = await fetchSingleRange(hass, entity, aRange); 183 | this.add(entity, fetchedHistory.history, fetchedHistory.range); 184 | } 185 | } 186 | return this.getData(entity); 187 | })); 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /src/parse-config/defaults.ts: -------------------------------------------------------------------------------- 1 | import { merge } from "lodash"; 2 | import { Config, InputConfig } from "../types"; 3 | import { parseColorScheme } from "./parse-color-scheme"; 4 | import { getEntityIndex } from "./parse-config"; 5 | import getThemedLayout, { HATheme } from "./themed-layout"; 6 | declare const window: Window & { PlotlyGraphCardPresets?: Record }; 7 | const noop$fn = () => () => {}; 8 | const defaultEntityRequired = { 9 | entity: "", 10 | show_value: false, 11 | internal: false, 12 | time_offset: "0s", 13 | on_legend_click: noop$fn, 14 | on_legend_dblclick: noop$fn, 15 | on_click: noop$fn, 16 | }; 17 | const defaultEntityOptional = { 18 | mode: "lines", 19 | line: { 20 | width: 1, 21 | shape: "hv", 22 | color: ({ getFromConfig, path }) => { 23 | const color_scheme = parseColorScheme(getFromConfig("color_scheme")); 24 | return color_scheme[getEntityIndex(path) % color_scheme.length]; 25 | }, 26 | }, 27 | // extend_to_present: true unless using statistics. Defined inside parse-config.ts to avoid forward depndency 28 | unit_of_measurement: ({ meta }) => meta.unit_of_measurement || "", 29 | name: ({ meta, getFromConfig }) => { 30 | let name = meta.friendly_name || getFromConfig(`.entity`); 31 | const attribute = getFromConfig(`.attribute`); 32 | if (attribute) name += ` (${attribute}) `; 33 | return name; 34 | }, 35 | hovertemplate: ({ getFromConfig }) => 36 | `${getFromConfig(".name")}
%{x}
%{y} ${getFromConfig( 37 | ".unit_of_measurement" 38 | )}`, 39 | yaxis: ({ getFromConfig, path }) => { 40 | const units: string[] = []; 41 | for (let i = 0; i <= getEntityIndex(path); i++) { 42 | const unit = getFromConfig(`entities.${i}.unit_of_measurement`); 43 | const internal = getFromConfig(`entities.${i}.internal`); 44 | if (!internal && !units.includes(unit)) units.push(unit); 45 | } 46 | const yaxis_idx = units.indexOf(getFromConfig(`.unit_of_measurement`)) + 1; 47 | return "y" + (yaxis_idx === 1 ? "" : yaxis_idx); 48 | }, 49 | }; 50 | 51 | const defaultYamlRequired = { 52 | title: "", 53 | hours_to_show: 1, 54 | refresh_interval: "auto", 55 | color_scheme: "category10", 56 | time_offset: "0s", 57 | raw_plotly_config: false, 58 | ha_theme: true, 59 | disable_pinch_to_zoom: false, 60 | raw_plotly: false, 61 | defaults: { 62 | entity: {}, 63 | xaxes: {}, 64 | yaxes: {}, 65 | }, 66 | layout: {}, 67 | on_dblclick: noop$fn, 68 | autorange_after_scroll: false, 69 | }; 70 | 71 | // 72 | 73 | const defaultExtraXAxes: Partial = { 74 | // automargin: true, // it makes zooming very jumpy 75 | type: "date", 76 | autorange: false, 77 | overlaying: "x", 78 | showgrid: false, 79 | visible: false, 80 | }; 81 | 82 | const defaultExtraYAxes: Partial = { 83 | // automargin: true, // it makes zooming very jumpy 84 | side: "right", 85 | overlaying: "y", 86 | showgrid: false, 87 | visible: false, 88 | // This makes sure that the traces are rendered above the right y axis, 89 | // including the marker and its text. Useful for show_value. See cliponaxis in entity 90 | layer: "below traces", 91 | }; 92 | 93 | const defaultYamlOptional: { 94 | layout: Partial; 95 | config: Partial; 96 | } = { 97 | config: { 98 | displaylogo: false, 99 | scrollZoom: true, 100 | modeBarButtonsToRemove: ["resetScale2d", "toImage", "lasso2d", "select2d"], 101 | // @ts-expect-error expects a string, not a function 102 | locale: ({ hass }) => hass.locale?.language, 103 | }, 104 | layout: { 105 | height: 285, 106 | dragmode: "pan", 107 | xaxis: { 108 | autorange: false, 109 | type: "date", 110 | // automargin: true, // it makes zooming very jumpy 111 | }, 112 | ...Object.fromEntries( 113 | Array.from({ length: 28 }).map((_, i) => [ 114 | `xaxis${i + 2}`, 115 | { ...defaultExtraXAxes }, 116 | ]) 117 | ), 118 | yaxis: { 119 | // automargin: true, // it makes zooming very jumpy 120 | }, 121 | yaxis2: { 122 | // automargin: true, // it makes zooming very jumpy 123 | ...defaultExtraYAxes, 124 | visible: true, 125 | }, 126 | ...Object.fromEntries( 127 | Array.from({ length: 27 }).map((_, i) => [ 128 | `yaxis${i + 3}`, 129 | { ...defaultExtraYAxes }, 130 | ]) 131 | ), 132 | legend: { 133 | orientation: "h", 134 | bgcolor: "transparent", 135 | x: 0, 136 | y: 1, 137 | yanchor: "bottom", 138 | }, 139 | title: { 140 | y: 1, 141 | pad: { 142 | t: 15, 143 | }, 144 | }, 145 | modebar: { 146 | // vertical so it doesn't occlude the legend 147 | orientation: "v", 148 | }, 149 | margin: { 150 | b: 50, 151 | t: 0, 152 | l: 60, 153 | // @ts-expect-error functions are not a plotly thing, only this card 154 | r: ({ getFromConfig }) => { 155 | const entities = getFromConfig(`entities`); 156 | const usesRightAxis = entities.some(({ yaxis }) => yaxis === "y2"); 157 | const usesShowValue = entities.some(({ show_value }) => show_value); 158 | return usesRightAxis | usesShowValue ? 60 : 30; 159 | }, 160 | }, 161 | }, 162 | }; 163 | 164 | function getPresetYaml(presets: string | string[] | undefined, skips?: Set): Partial { 165 | if (!window.PlotlyGraphCardPresets || presets === undefined) return {}; 166 | if (!Array.isArray(presets)) presets = [presets]; 167 | if (presets.length == 0) return {}; 168 | if (skips === undefined) skips = new Set(); 169 | const nestedPresets: string[] = []; 170 | const presetYamls = presets.map((preset) => { 171 | const yaml = window.PlotlyGraphCardPresets![preset] ?? {}; 172 | if (yaml.preset !== undefined) { 173 | if (!Array.isArray(yaml.preset)) yaml.preset = [yaml.preset]; 174 | nestedPresets.push(...yaml.preset); 175 | } 176 | return yaml; 177 | }); 178 | const newPresets = nestedPresets.filter((preset) => !skips.has(preset)); 179 | const nestedYaml = getPresetYaml(newPresets, new Set([...skips, ...presets])); 180 | return merge({}, ...presetYamls, nestedYaml); 181 | } 182 | 183 | export function addPreParsingDefaults( 184 | yaml_in: InputConfig, 185 | css_vars: HATheme 186 | ): InputConfig { 187 | // merging in two steps to ensure ha_theme and raw_plotly_config took its default value 188 | let yaml = merge({}, yaml_in, defaultYamlRequired, yaml_in); 189 | const preset = getPresetYaml(yaml.preset); 190 | for (let i = 1; i < 31; i++) { 191 | for (const d of ["x", "y"]) { 192 | const axis = d + "axis" + (i == 1 ? "" : i); 193 | yaml.layout[axis] = merge( 194 | {}, 195 | yaml.layout[axis], 196 | yaml.defaults[d + "axes"], 197 | preset.defaults?.[d+ "axes"] ?? {}, 198 | yaml.layout[axis] 199 | ); 200 | } 201 | } 202 | yaml = merge( 203 | {}, 204 | yaml, 205 | { 206 | layout: yaml.ha_theme ? getThemedLayout(css_vars) : {}, 207 | }, 208 | yaml.raw_plotly_config ? {} : defaultYamlOptional, 209 | preset, 210 | yaml 211 | ); 212 | 213 | yaml.entities = yaml.entities.map((entity) => { 214 | if (typeof entity === "string") entity = { entity }; 215 | entity.entity ??= ""; 216 | const [oldAPI_entity, oldAPI_attribute] = entity.entity.split("::"); 217 | if (oldAPI_attribute) { 218 | entity.entity = oldAPI_entity; 219 | entity.attribute = oldAPI_attribute; 220 | } 221 | entity = merge( 222 | {}, 223 | entity, 224 | defaultEntityRequired, 225 | yaml.raw_plotly_config ? {} : defaultEntityOptional, 226 | yaml.defaults?.entity, 227 | entity 228 | ); 229 | return entity; 230 | }); 231 | return yaml; 232 | } 233 | 234 | export function addPostParsingDefaults( 235 | yaml: Config & { visible_range: [number, number] } 236 | ): Config { 237 | /** 238 | * These cannot be done via defaults because they depend on the entities already being fully evaluated and filtered 239 | * */ 240 | const yAxisTitles = Object.fromEntries( 241 | yaml.entities.map(({ unit_of_measurement, yaxis }) => [ 242 | "yaxis" + yaxis?.slice(1), 243 | { title: unit_of_measurement }, 244 | ]) 245 | ); 246 | const layout = merge( 247 | {}, 248 | yaml.layout, 249 | yaml.raw_plotly_config 250 | ? {} 251 | : { 252 | xaxis: { 253 | range: yaml.visible_range, 254 | }, 255 | }, 256 | yaml.raw_plotly_config ? {} : yAxisTitles, 257 | yaml.layout 258 | ); 259 | return merge({}, yaml, { layout }, yaml); 260 | } 261 | -------------------------------------------------------------------------------- /src/parse-config/parse-config.ts: -------------------------------------------------------------------------------- 1 | import Cache from "../cache/Cache"; 2 | import { HATheme } from "./themed-layout"; 3 | 4 | import propose from "propose"; 5 | 6 | import get from "lodash/get"; 7 | import { addPreParsingDefaults, addPostParsingDefaults } from "./defaults"; 8 | import { 9 | isRelativeTime, 10 | isTimeDuration, 11 | parseRelativeTime, 12 | parseTimeDuration, 13 | setDateFnDefaultOptions, 14 | } from "../duration/duration"; 15 | import { parseStatistics } from "./parse-statistics"; 16 | import { HomeAssistant } from "custom-card-helpers"; 17 | import filters from "../filters/filters"; 18 | import bounds from "binary-search-bounds"; 19 | import { has } from "lodash"; 20 | import { StatisticValue } from "../recorder-types"; 21 | import { Config, EntityData, HassEntity, InputConfig, YValue } from "../types"; 22 | import getDeprecationError from "./deprecations"; 23 | 24 | class ConfigParser { 25 | private yaml: Partial = {}; 26 | private errors?: Error[]; 27 | private yaml_with_defaults?: InputConfig; 28 | private hass?: HomeAssistant; 29 | cache = new Cache(); 30 | private busy = false; 31 | private fnParam!: FnParam; 32 | private observed_range: [number, number] = [Date.now(), Date.now()]; 33 | public resetObservedRange() { 34 | this.observed_range = [Date.now(), Date.now()]; 35 | } 36 | 37 | async update(input: { 38 | yaml: InputConfig; 39 | hass: HomeAssistant; 40 | css_vars: HATheme; 41 | }) { 42 | if (this.busy) throw new Error("ParseConfig was updated while busy"); 43 | this.busy = true; 44 | try { 45 | return this._update(input); 46 | } finally { 47 | this.busy = false; 48 | } 49 | } 50 | private async _update({ 51 | yaml: input_yaml, 52 | hass, 53 | css_vars, 54 | }: { 55 | yaml: InputConfig; 56 | hass: HomeAssistant; 57 | css_vars: HATheme; 58 | }): Promise<{ errors: Error[]; parsed: Config }> { 59 | this.yaml = {}; 60 | this.errors = []; 61 | this.hass = hass; 62 | this.yaml_with_defaults = addPreParsingDefaults(input_yaml, css_vars); 63 | setDateFnDefaultOptions(hass); 64 | 65 | this.fnParam = { 66 | vars: {}, 67 | path: "", 68 | hass, 69 | css_vars, 70 | getFromConfig: () => "", 71 | get: () => "", 72 | }; 73 | for (const [key, value] of Object.entries(this.yaml_with_defaults)) { 74 | try { 75 | await this.evalNode({ 76 | parent: this.yaml, 77 | path: key, 78 | key: key, 79 | value, 80 | }); 81 | } catch (e) { 82 | console.warn(`Plotly Graph Card: Error parsing [${key}]`, e); 83 | this.errors?.push(e as Error); 84 | } 85 | } 86 | this.yaml = addPostParsingDefaults(this.yaml as Config); 87 | 88 | return { errors: this.errors, parsed: this.yaml as Config }; 89 | } 90 | private async evalNode({ 91 | parent, 92 | path, 93 | key, 94 | value, 95 | }: { 96 | parent: object; 97 | path: string; 98 | key: string; 99 | value: any; 100 | }) { 101 | if (path.match(/^defaults$/)) return; 102 | this.fnParam.path = path; 103 | this.fnParam.getFromConfig = (pathQuery: string) => 104 | this.getEvaledPath(pathQuery, path /* caller */); 105 | this.fnParam.get = this.fnParam.getFromConfig; 106 | 107 | if ( 108 | !this.fnParam.xs && // hasn't fetched yet 109 | path.match(/^entities\.\d+\./) && 110 | !path.match( 111 | /^entities\.\d+\.(entity|attribute|time_offset|statistic|period)/ 112 | ) && //isInsideFetchParamNode 113 | (is$fn(value) || path.match(/^entities\.\d+\.filters\.\d+$/)) // if function of filter 114 | ) { 115 | const entityPath = path.match(/^(entities\.\d+)\./)![1]; 116 | await this.fetchDataForEntity(entityPath); 117 | } 118 | 119 | if (typeof value === "string") { 120 | if (value.startsWith("$ex")) { 121 | value = 122 | "$fn ({ getFromConfig, get, hass, vars, path, css_vars, xs, ys, statistics, states, meta }) => " + 123 | value.slice(3); 124 | } 125 | if (value.startsWith("$fn")) { 126 | value = myEval(value.slice(3)); 127 | } 128 | } 129 | const error = getDeprecationError(path, value); 130 | if (error) this.errors?.push(error); 131 | 132 | if (typeof value === "function") { 133 | /** 134 | * Allowing functions that return functions makes it very slow when large arrays are returned. 135 | * This is because awaits are expensive. 136 | */ 137 | 138 | parent[key] = value = value(this.fnParam); 139 | } else if (isObjectOrArray(value)) { 140 | const me = Array.isArray(value) ? [] : {}; 141 | parent[key] = me; 142 | for (const [childKey, childValue] of Object.entries(value)) { 143 | const childPath = `${path}.${childKey}`; 144 | try { 145 | await this.evalNode({ 146 | parent: me, 147 | path: childPath, 148 | key: childKey, 149 | value: childValue, 150 | }); 151 | } catch (e: any) { 152 | console.warn(`Plotly Graph Card: Error parsing [${childPath}]`, e); 153 | this.errors?.push(new Error(`at [${childPath}]: ${e?.message || e}`)); 154 | } 155 | } 156 | } else { 157 | parent[key] = value; 158 | } 159 | 160 | // we're now on the way back of traversal, `value` is fully evaluated (not a function) 161 | value = parent[key]; 162 | 163 | if (path.match(/^entities\.\d+\.filters\.\d+$/)) { 164 | this.evalFilter({ parent, path, key, value }); 165 | } 166 | if (path.match(/^entities\.\d+$/)) { 167 | if (!this.fnParam.xs) { 168 | await this.fetchDataForEntity(path); 169 | } 170 | const me = parent[key]; 171 | if (!this.fnParam.getFromConfig("raw_plotly_config")) { 172 | if (!me.x) me.x = this.fnParam.xs; 173 | if (!me.y) me.y = this.fnParam.ys; 174 | if (me.x.length === 0 && me.y.length === 0) { 175 | /* 176 | Traces with no data are removed from the legend by plotly. 177 | Setting them to have null element prevents that. 178 | */ 179 | me.x = [new Date()]; 180 | me.y = [null]; 181 | } 182 | } 183 | 184 | delete this.fnParam.xs; 185 | delete this.fnParam.ys; 186 | delete this.fnParam.statistics; 187 | delete this.fnParam.states; 188 | delete this.fnParam.meta; 189 | } 190 | if (path.match(/^entities$/)) { 191 | parent[key] = parent[key].filter(({ internal }) => !internal); 192 | const entities = parent[key]; 193 | const count = entities.length; 194 | // Preserving the original sequence of real_traces is important for `fill: tonexty` 195 | // https://github.com/dbuezas/lovelace-plotly-graph-card/issues/87 196 | for (let i = 0; i < count; i++) { 197 | const trace = entities[i]; 198 | if (trace.show_value) { 199 | trace.legendgroup ??= "group" + i; 200 | entities.push({ 201 | texttemplate: `%{y:.2~f} ${this.fnParam.getFromConfig( 202 | `entities.${i}.unit_of_measurement` 203 | )}`, // here so it can be overwritten 204 | ...trace, 205 | cliponaxis: false, // allows the marker + text to be rendered above the right y axis. See https://github.com/dbuezas/lovelace-plotly-graph-card/issues/171 206 | mode: "text+markers", 207 | showlegend: false, 208 | hoverinfo: "skip", 209 | textposition: "middle right", 210 | marker: { 211 | color: trace.line?.color, 212 | }, 213 | textfont: { 214 | color: trace.line?.color, 215 | }, 216 | x: trace.x.slice(-1), 217 | y: trace.y.slice(-1), 218 | }); 219 | } 220 | } 221 | } 222 | } 223 | 224 | private async fetchDataForEntity(path: string) { 225 | let visible_range = this.fnParam.getFromConfig("visible_range"); 226 | if (!visible_range) { 227 | let global_offset = parseTimeDuration( 228 | this.fnParam.getFromConfig("time_offset") 229 | ); 230 | const hours_to_show = this.fnParam.getFromConfig("hours_to_show"); 231 | if (isRelativeTime(hours_to_show)) { 232 | const [start, end] = parseRelativeTime(hours_to_show); 233 | visible_range = [start + global_offset, end + global_offset] as [ 234 | number, 235 | number 236 | ]; 237 | } else { 238 | let ms_to_show; 239 | if (isTimeDuration(hours_to_show)) { 240 | ms_to_show = parseTimeDuration(hours_to_show); 241 | } else if (typeof hours_to_show === "number") { 242 | ms_to_show = hours_to_show * 60 * 60 * 1000; 243 | } else { 244 | throw new Error( 245 | `${hours_to_show} is not a valid duration. Use numbers, durations (e.g 1d) or dynamic time (e.g current_day)` 246 | ); 247 | } 248 | visible_range = [ 249 | +new Date() - ms_to_show + global_offset, 250 | +new Date() + global_offset, 251 | ] as [number, number]; 252 | } 253 | this.yaml.visible_range = visible_range; 254 | } 255 | if (this.fnParam.getFromConfig("autorange_after_scroll")) { 256 | this.observed_range = visible_range.slice(); 257 | } 258 | this.observed_range[0] = Math.min(this.observed_range[0], visible_range[0]); 259 | this.observed_range[1] = Math.max(this.observed_range[1], visible_range[1]); 260 | const statisticsParams = parseStatistics( 261 | visible_range, 262 | this.fnParam.getFromConfig(path + ".statistic"), 263 | this.fnParam.getFromConfig(path + ".period") 264 | ); 265 | const attribute = this.fnParam.getFromConfig(path + ".attribute") as 266 | | string 267 | | undefined; 268 | const fetchConfig = { 269 | entity: this.fnParam.getFromConfig(path + ".entity"), 270 | ...(statisticsParams ? statisticsParams : attribute ? { attribute } : {}), 271 | }; 272 | const offset = parseTimeDuration( 273 | this.fnParam.getFromConfig(path + ".time_offset") 274 | ); 275 | 276 | const range_to_fetch = [ 277 | visible_range[0] - offset, 278 | visible_range[1] - offset, 279 | ]; 280 | const fetch_mask = this.fnParam.getFromConfig("fetch_mask"); 281 | const i = getEntityIndex(path); 282 | const data = 283 | // TODO: decide about minimal response 284 | fetch_mask[i] === false // also fetch if it is undefined. This means the entity is new 285 | ? this.cache.getData(fetchConfig) 286 | : await this.cache.fetch(range_to_fetch, fetchConfig, this.hass!); 287 | const extend_to_present = 288 | this.fnParam.getFromConfig(path + ".extend_to_present") ?? 289 | !statisticsParams; 290 | 291 | data.xs = data.xs.map((x) => new Date(+x + offset)); 292 | 293 | removeOutOfRange(data, this.observed_range); 294 | if (extend_to_present && data.xs.length > 0) { 295 | // Todo: should this be done after the entity was fully evaluated? 296 | // this would make it also work if filters change the data. 297 | // Would also need to be combined with yet another removeOutOfRange call. 298 | const last_i = data.xs.length - 1; 299 | const now = Math.min(this.observed_range[1], Date.now()); 300 | data.xs.push(new Date(Math.min(this.observed_range[1], now + offset))); 301 | data.ys.push(data.ys[last_i]); 302 | if (data.states.length) data.states.push(data.states[last_i]); 303 | if (data.statistics.length) data.statistics.push(data.statistics[last_i]); 304 | } 305 | this.fnParam.xs = data.xs; 306 | this.fnParam.ys = data.ys; 307 | this.fnParam.statistics = data.statistics; 308 | this.fnParam.states = data.states; 309 | this.fnParam.meta = this.hass?.states[fetchConfig.entity]?.attributes || {}; 310 | } 311 | 312 | private getEvaledPath(path: string, callingPath: string) { 313 | if (path.startsWith(".")) 314 | path = callingPath 315 | .split(".") 316 | .slice(0, -1) 317 | .concat(path.slice(1).split(".")) 318 | .join("."); 319 | if (has(this.yaml, path)) return get(this.yaml, path); 320 | 321 | let value = this.yaml_with_defaults; 322 | for (const key of path.split(".")) { 323 | if (value === undefined) return undefined; 324 | value = value[key]; 325 | if (is$fn(value)) { 326 | throw new Error( 327 | `Since [${path}] is a $fn, it has to be defined before [${callingPath}]` 328 | ); 329 | } 330 | } 331 | return value; 332 | } 333 | private evalFilter(input: { 334 | parent: object; 335 | path: string; 336 | key: string; 337 | value: any; 338 | }) { 339 | const obj = input.value; 340 | let filterName: string; 341 | let config: any = null; 342 | if (typeof obj === "string") { 343 | filterName = obj; 344 | } else { 345 | filterName = Object.keys(obj)[0]; 346 | config = Object.values(obj)[0]; 347 | } 348 | const filter = filters[filterName]; 349 | if (!filter) { 350 | throw new Error( 351 | `Filter '${filterName}' doesn't exist. Did you mean ${propose( 352 | filterName, 353 | Object.keys(filters) 354 | )}?\nOthers: ${Object.keys(filters)}` 355 | ); 356 | } 357 | const filterfn = config === null ? filter() : filter(config); 358 | try { 359 | const r = filterfn(this.fnParam); 360 | for (const key in r) { 361 | this.fnParam[key] = r[key]; 362 | } 363 | } catch (e) { 364 | console.error(e); 365 | throw new Error(`Error in filter: ${e}`); 366 | } 367 | } 368 | } 369 | 370 | const myEval = typeof window != "undefined" ? window.eval : global.eval; 371 | 372 | function isObjectOrArray(value) { 373 | return value !== null && typeof value == "object" && !(value instanceof Date); 374 | } 375 | 376 | function is$fn(value) { 377 | return ( 378 | typeof value === "function" || 379 | (typeof value === "string" && value.startsWith("$fn")) || 380 | (typeof value === "string" && value.startsWith("$ex")) 381 | ); 382 | } 383 | 384 | function removeOutOfRange(data: EntityData, range: [number, number]) { 385 | const first = bounds.le(data.xs, new Date(range[0])); 386 | if (first > -1) { 387 | data.xs.splice(0, first); 388 | data.xs[0] = new Date(range[0]); 389 | data.ys.splice(0, first); 390 | data.states.splice(0, first); 391 | data.statistics.splice(0, first); 392 | } 393 | const last = bounds.gt(data.xs, new Date(range[1])); 394 | if (last > -1) { 395 | data.xs.splice(last); 396 | data.ys.splice(last); 397 | data.states.splice(last); 398 | data.statistics.splice(last); 399 | } 400 | } 401 | type GetFromConfig = ( 402 | string 403 | ) => ReturnType["getEvaledPath"]>; 404 | type FnParam = { 405 | getFromConfig: GetFromConfig; 406 | get: GetFromConfig; 407 | hass: HomeAssistant; 408 | vars: Record; 409 | path: string; 410 | css_vars: HATheme; 411 | xs?: Date[]; 412 | ys?: YValue[]; 413 | statistics?: StatisticValue[]; 414 | states?: HassEntity[]; 415 | meta?: HassEntity["attributes"]; 416 | }; 417 | export const getEntityIndex = (path: string) => 418 | +path.match(/entities\.(\d+)/)![1]; 419 | export { ConfigParser }; 420 | -------------------------------------------------------------------------------- /src/filters/filters.ts: -------------------------------------------------------------------------------- 1 | import propose from "propose"; 2 | import { HomeAssistant } from "custom-card-helpers"; 3 | import { 4 | parseTimeDuration, 5 | TimeDurationStr, 6 | timeUnits, 7 | } from "../duration/duration"; 8 | import { StatisticValue } from "../recorder-types"; 9 | import { HassEntity, YValue } from "../types"; 10 | 11 | import BaseRegression from "ml-regression-base"; 12 | import LinearRegression from "ml-regression-simple-linear"; 13 | import PolynomialRegression from "ml-regression-polynomial"; 14 | import PowerRegression from "ml-regression-power"; 15 | import ExponentialRegression from "ml-regression-exponential"; 16 | import TheilSenRegression from "ml-regression-theil-sen"; 17 | import { RobustPolynomialRegression } from "ml-regression-robust-polynomial"; 18 | import FFTRegression from "./fft-regression"; 19 | 20 | const castFloat = (y: any) => parseFloat(y); 21 | const myEval = typeof window != "undefined" ? window.eval : global.eval; 22 | 23 | type FilterData = { 24 | xs: Date[]; 25 | ys: YValue[]; 26 | states: HassEntity[]; 27 | statistics: StatisticValue[]; 28 | meta: HassEntity["attributes"]; 29 | vars: Record; 30 | hass: HomeAssistant; 31 | }; 32 | export type FilterFn = (p: FilterData) => Partial; 33 | 34 | type FilterParam = Parameters< 35 | (typeof filters)[K] 36 | >[0]; 37 | 38 | type CheckType = 39 | | (T extends undefined ? IfUndef : never) 40 | | (T extends unknown ? IfNonUndef : never); 41 | 42 | export type FilterInput = { 43 | [K in keyof typeof filters]: CheckType< 44 | FilterParam, 45 | K, 46 | { [P in K]: Exclude, undefined> } 47 | >; 48 | }[keyof typeof filters]; 49 | 50 | const mapNumbers = (ys: YValue[], fn: (y: number, i: number) => number) => 51 | ys.map((y, i) => { 52 | const n = castFloat(y); 53 | if (Number.isNaN(n)) return y; 54 | return fn(n, i); 55 | }); 56 | 57 | /** 58 | * Removes from all params the indexes for which ys is not numeric, and parses ys to numbers. 59 | * WARNING: when used inside a filter, it is important to return all arrays. Otherwise the lengths 60 | * between say ys and states won't be consistent 61 | */ 62 | const force_numeric: (p: FilterData) => { ys: number[] } & FilterData = ({ 63 | xs, 64 | ys: ys2, 65 | states, 66 | statistics, 67 | ...rest 68 | }) => { 69 | const ys = ys2.map((y) => castFloat(y)); 70 | const mask = ys.map((y) => !isNaN(y)); 71 | return { 72 | ys: ys.filter((_, i) => mask[i]), 73 | xs: xs.filter((_, i) => mask[i]), 74 | states: states.filter((_, i) => mask[i]), 75 | statistics: statistics.filter((_, i) => mask[i]), 76 | ...rest, 77 | }; 78 | }; 79 | 80 | const filters = { 81 | force_numeric: () => force_numeric, 82 | add: 83 | (val: number) => 84 | ({ ys }) => ({ 85 | ys: mapNumbers(ys, (y) => y + val), 86 | }), 87 | multiply: 88 | (val: number) => 89 | ({ ys }) => ({ 90 | ys: mapNumbers(ys, (y) => y * val), 91 | }), 92 | calibrate_linear: 93 | (mappingStr: `${number} -> ${number}`[]) => 94 | ({ ys, meta }) => { 95 | const mapping = mappingStr.map((str) => str.split("->").map(parseFloat)); 96 | const regression = new LinearRegression( 97 | mapping.map(([x, _y]) => x), 98 | mapping.map(([_x, y]) => y), 99 | ); 100 | return { 101 | ys: regression.predict(ys.map(castFloat)), 102 | meta: { ...meta, regression }, 103 | }; 104 | }, 105 | deduplicate_adjacent: 106 | () => 107 | ({ xs, ys, states, statistics }) => { 108 | const mask = ys.map((y, i) => y !== ys[i - 1]); 109 | return { 110 | ys: ys.filter((_, i) => mask[i]), 111 | xs: xs.filter((_, i) => mask[i]), 112 | states: states.filter((_, i) => mask[i]), 113 | statistics: statistics.filter((_, i) => mask[i]), 114 | }; 115 | }, 116 | delta: 117 | () => 118 | ({ ys, meta, xs, statistics, states }) => { 119 | const last = { 120 | y: NaN, 121 | }; 122 | return { 123 | meta: { 124 | ...meta, 125 | unit_of_measurement: `Δ${meta.unit_of_measurement}`, 126 | }, 127 | ys: mapNumbers(ys, (y) => { 128 | const yDelta = y - last.y; 129 | last.y = y; 130 | return yDelta; 131 | }).slice(1), 132 | xs: xs.slice(1), 133 | statistics: statistics.slice(1), 134 | states: states.slice(1), 135 | }; 136 | }, 137 | derivate: 138 | (unit: keyof typeof timeUnits = "h") => 139 | ({ xs, ys, meta }) => { 140 | const last = { 141 | x: +xs[0], 142 | y: NaN, 143 | }; 144 | checkTimeUnits(unit); 145 | checkTimeUnits(unit); 146 | return { 147 | meta: { 148 | ...meta, 149 | unit_of_measurement: `${meta.unit_of_measurement}/${unit}`, 150 | }, 151 | xs, 152 | ys: mapNumbers(ys, (y, i) => { 153 | const x = +xs[i]; 154 | const dateDelta = (x - last.x) / timeUnits[unit]; 155 | const yDeriv = (y - last.y) / dateDelta; 156 | last.y = y; 157 | last.x = x; 158 | return yDeriv; 159 | }), 160 | }; 161 | }, 162 | integrate: ( 163 | unitOrObject: 164 | | keyof typeof timeUnits 165 | | { 166 | unit?: keyof typeof timeUnits; 167 | reset_every?: TimeDurationStr; 168 | offset?: TimeDurationStr; 169 | } = "h", 170 | ) => { 171 | const param = 172 | typeof unitOrObject == "string" ? { unit: unitOrObject } : unitOrObject; 173 | const unit = param.unit ?? "h"; 174 | const reset_every = parseTimeDuration(param.reset_every ?? "0s"); 175 | const offset = parseTimeDuration(param.offset ?? "0s"); 176 | checkTimeUnits(unit); 177 | const date = new Date(); 178 | date.setHours(0); 179 | date.setMinutes(0); 180 | date.setSeconds(0); 181 | const t0 = +date + offset; 182 | return ({ xs, ys, meta }) => { 183 | let yAcc = 0; 184 | let last = { 185 | x: NaN, 186 | laps: 0, 187 | y: 0, 188 | }; 189 | return { 190 | meta: { 191 | ...meta, 192 | unit_of_measurement: `${meta.unit_of_measurement}${unit}`, 193 | }, 194 | xs: xs, 195 | ys: mapNumbers(ys, (y, i) => { 196 | const x = +xs[i]; 197 | if (reset_every > 0) { 198 | const laps = Math.floor((x - t0) / reset_every); 199 | if (laps !== last.laps) { 200 | yAcc = 0; 201 | last.laps = laps; 202 | } 203 | } 204 | const dateDelta = (x - last.x) / timeUnits[unit]; 205 | const isFirst = isNaN(last.x); 206 | last.x = x; 207 | if (isFirst) return NaN; 208 | yAcc += last.y * dateDelta; 209 | last.y = y; 210 | return yAcc; 211 | }), 212 | }; 213 | }; 214 | }, 215 | sliding_window_moving_average: 216 | ({ 217 | window_size = 10, 218 | extended = false, 219 | centered = true, 220 | }: { window_size?: number; extended?: boolean; centered?: boolean } = {}) => 221 | (params) => { 222 | const { xs, ys, ...rest } = force_numeric(params); 223 | const ys2: number[] = []; 224 | const xs2: Date[] = []; 225 | let acc = { 226 | y: 0, 227 | count: 0, 228 | x: 0, 229 | }; 230 | for (let i = 0; i < ys.length + window_size; i++) { 231 | if (i < ys.length) { 232 | acc.x += +xs[i]; 233 | acc.y += ys[i]; 234 | acc.count++; 235 | } 236 | if (i >= window_size) { 237 | acc.x -= +xs[i - window_size]; 238 | acc.y -= ys[i - window_size]; 239 | acc.count--; 240 | } 241 | if ((i >= window_size && i < ys.length) || extended) { 242 | if (centered) xs2.push(new Date(acc.x / acc.count)); 243 | else xs2.push(xs[i]); 244 | ys2.push(acc.y / acc.count); 245 | } 246 | } 247 | return { xs: xs2, ys: ys2, ...rest }; 248 | }, 249 | median: 250 | ({ 251 | window_size = 10, 252 | extended = false, 253 | centered = true, 254 | }: { window_size?: number; extended?: boolean; centered?: boolean } = {}) => 255 | (params) => { 256 | const { xs, ys, ...rest } = force_numeric(params); 257 | const ys2: number[] = []; 258 | const xs2: Date[] = []; 259 | let acc = { 260 | ys: [] as number[], 261 | x: 0, 262 | }; 263 | for (let i = 0; i < ys.length + window_size; i++) { 264 | if (i < ys.length) { 265 | acc.x += +xs[i]; 266 | acc.ys.push(ys[i]); 267 | } 268 | if (i >= window_size) { 269 | acc.x -= +xs[i - window_size]; 270 | acc.ys.shift(); 271 | } 272 | if ((i >= window_size && i < ys.length) || extended) { 273 | if (centered) xs2.push(new Date(acc.x / acc.ys.length)); 274 | else xs2.push(xs[i]); 275 | const sorted = acc.ys.slice().sort(); 276 | const mid1 = Math.floor(sorted.length / 2); 277 | const mid2 = Math.ceil(sorted.length / 2); 278 | ys2.push((sorted[mid1] + sorted[mid2]) / 2); 279 | } 280 | } 281 | return { ys: ys2, xs: xs2, ...rest }; 282 | }, 283 | exponential_moving_average: 284 | ({ alpha = 0.1 }: { alpha?: number } = {}) => 285 | (params) => { 286 | const { ys, ...rest } = force_numeric(params); 287 | let last = ys[0]; 288 | return { 289 | ys: ys.map((y) => (last = last * (1 - alpha) + y * alpha)), 290 | ...rest, 291 | }; 292 | }, 293 | map_y_numbers: (fnStr: string) => { 294 | const fn = myEval( 295 | `(i, x, y, state, statistic, xs, ys, states, statistics, meta, vars, hass) => ${fnStr}`, 296 | ); 297 | return ({ xs, ys, states, statistics, meta, vars, hass }) => ({ 298 | xs, 299 | ys: mapNumbers(ys, (_, i) => 300 | // prettier-ignore 301 | fn(i, xs[i], ys[i], states[i], statistics[i], xs, ys, states, statistics, meta, vars, hass), 302 | ), 303 | }); 304 | }, 305 | map_y: (fnStr: string) => { 306 | const fn = myEval( 307 | `(i, x, y, state, statistic, xs, ys, states, statistics, meta, vars, hass) => ${fnStr}`, 308 | ); 309 | return ({ xs, ys, states, statistics, meta, vars, hass }) => ({ 310 | xs, 311 | ys: ys.map((_, i) => 312 | // prettier-ignore 313 | fn(i, xs[i], ys[i], states[i], statistics[i], xs, ys, states, statistics, meta, vars, hass), 314 | ), 315 | }); 316 | }, 317 | map_x: (fnStr: string) => { 318 | const fn = myEval( 319 | `(i, x, y, state, statistic, xs, ys, states, statistics, meta, vars, hass) => ${fnStr}`, 320 | ); 321 | return ({ xs, ys, states, statistics, meta, vars, hass }) => ({ 322 | ys, 323 | xs: xs.map((_, i) => 324 | // prettier-ignore 325 | fn(i, xs[i], ys[i], states[i], statistics[i], xs, ys, states, statistics, meta, vars, hass), 326 | ), 327 | }); 328 | }, 329 | resample: 330 | (intervalStr: TimeDurationStr = "5m") => 331 | ({ xs, ys, states, statistics }) => { 332 | const data = { 333 | xs: [] as Date[], 334 | ys: [] as YValue[], 335 | states: [] as HassEntity[], 336 | statistics: [] as StatisticValue[], 337 | }; 338 | const interval = parseTimeDuration(intervalStr); 339 | const x0 = Math.floor(+xs[0] / interval) * interval; 340 | const x1 = +xs[xs.length - 1]; 341 | let i = 0; 342 | for (let x = x0; x < x1; x += interval) { 343 | while (+xs[i + 1] < x && i < xs.length - 1) { 344 | i++; 345 | } 346 | data.xs.push(new Date(x)); 347 | data.ys.push(ys[i]); 348 | if (states[i]) data.states.push(states[i]); 349 | if (statistics[i]) data.statistics.push(statistics[i]); 350 | } 351 | return data; 352 | }, 353 | load_var: 354 | (var_name: string) => 355 | ({ vars }) => 356 | vars[var_name], 357 | store_var: 358 | (var_name: string) => 359 | ({ vars, xs, ys, states, statistics, meta }) => ({ 360 | vars: { ...vars, [var_name]: { xs, ys, states, statistics, meta } }, 361 | }), 362 | trendline: (p3: TrendlineType | Partial = "linear") => { 363 | let p2: Partial = {}; 364 | if (typeof p3 == "string") { 365 | p2 = { type: p3 }; 366 | } else p2 = { ...p3 }; 367 | p2.type ??= "linear"; 368 | p2.forecast ??= "0s"; 369 | p2.show_formula ??= false; 370 | p2.show_r2 ??= false; 371 | p2.degree ??= 2; 372 | const p = p2 as TrendlineParam; 373 | const forecast = parseTimeDuration(p.forecast); 374 | return (data) => { 375 | const { xs, ys, meta, ...rest } = force_numeric(data); 376 | const t0 = +xs[0] - 0.1; // otherwise the power series doesn't work 377 | const t1 = +xs[xs.length - 1]; 378 | const xs_numbers = xs.map((x) => +x - t0); 379 | let RegressionClass = trendlineTypes[p.type]; 380 | if (!RegressionClass) { 381 | throw new Error( 382 | `Trendline '${p.type}' doesn't exist. Did you mean ${propose( 383 | p.type, 384 | Object.keys(trendlineTypes), 385 | )}?\nOthers: ${Object.keys(trendlineTypes)}`, 386 | ); 387 | } 388 | const regression: BaseRegression = new RegressionClass( 389 | xs_numbers, 390 | ys, 391 | p.degree, 392 | ); 393 | let extras: string[] = []; 394 | if (p.show_r2) 395 | extras.push( 396 | `r²=${maxDecimals(regression.score(xs_numbers, ys).r2, 2)}`, 397 | ); 398 | 399 | if (forecast > 0) { 400 | const N = Math.round( 401 | (xs_numbers.length / 402 | (xs_numbers[xs_numbers.length - 1] - xs_numbers[0])) * 403 | forecast, 404 | ); 405 | xs_numbers.push( 406 | ...Array.from({ length: N }).map( 407 | (_, i) => t1 - t0 + (forecast / N) * i, 408 | ), 409 | ); 410 | } 411 | const ys_out = regression.predict(xs_numbers); 412 | 413 | if (p.show_formula) extras.push(regression.toString(2)); 414 | return { 415 | ...rest, 416 | xs: xs_numbers.map((x) => new Date(x + t0)), 417 | ys: ys_out, 418 | meta: { 419 | ...meta, 420 | friendly_name: 421 | "Trend" + (extras.length ? ` (${extras.join(", ")})` : ""), 422 | }, 423 | }; 424 | }; 425 | }, 426 | fn: (fnStr: string) => myEval(fnStr), 427 | /* 428 | example: fn("({xs, ys, states, statistics }) => ({xs: ys})") 429 | */ 430 | filter: (fnStr: string) => { 431 | const fn = myEval( 432 | `(i, x, y, state, statistic, xs, ys, states, statistics, meta, vars, hass) => ${fnStr}`, 433 | ); 434 | return ({ xs, ys, states, statistics, meta, vars, hass }) => { 435 | const mask = ys.map((_, i) => 436 | // prettier-ignore 437 | fn(i, xs[i], ys[i], states[i], statistics[i], xs, ys, states, statistics, meta, vars, hass), 438 | ); 439 | return { 440 | ys: ys.filter((_, i) => mask[i]), 441 | xs: xs.filter((_, i) => mask[i]), 442 | states: states.filter((_, i) => mask[i]), 443 | statistics: statistics.filter((_, i) => mask[i]), 444 | }; 445 | }; 446 | }, 447 | } satisfies Record FilterFn>; 448 | export default filters; 449 | function checkTimeUnits(unit: string) { 450 | if (!timeUnits[unit]) { 451 | throw new Error( 452 | `Unit '${unit}' is not valid, use ${Object.keys(timeUnits)}`, 453 | ); 454 | } 455 | } 456 | const trendlineTypes = { 457 | linear: LinearRegression, 458 | polynomial: PolynomialRegression, 459 | power: PowerRegression, 460 | exponential: ExponentialRegression, 461 | theil_sen: TheilSenRegression, 462 | robust_polynomial: RobustPolynomialRegression, 463 | fft: FFTRegression, 464 | }; 465 | type TrendlineType = keyof typeof trendlineTypes; 466 | type TrendlineParam = { 467 | type: TrendlineType; 468 | forecast: TimeDurationStr; 469 | show_formula: boolean; 470 | show_r2: boolean; 471 | degree: number; 472 | }; 473 | function maxDecimals(n: number, decimals: number) { 474 | return Math.round(n * 10 ** decimals) / 10 ** decimals; 475 | } 476 | -------------------------------------------------------------------------------- /src/plotly-graph-card.ts: -------------------------------------------------------------------------------- 1 | import { HomeAssistant } from "custom-card-helpers"; 2 | import EventEmitter from "events"; 3 | import mapValues from "lodash/mapValues"; 4 | import { version } from "../package.json"; 5 | import insertStyleHack from "./style-hack"; 6 | import Plotly from "./plotly"; 7 | import { 8 | Config, 9 | InputConfig, 10 | isEntityIdAttrConfig, 11 | isEntityIdStateConfig, 12 | isEntityIdStatisticsConfig, 13 | } from "./types"; 14 | import isProduction from "./is-production"; 15 | import "./hot-reload"; 16 | import { debounce, sleep } from "./utils"; 17 | import { parseISO } from "date-fns"; 18 | import { TouchController } from "./touch-controller"; 19 | import { ConfigParser } from "./parse-config/parse-config"; 20 | import { merge } from "lodash"; 21 | 22 | const componentName = isProduction ? "plotly-graph" : "plotly-graph-dev"; 23 | 24 | console.info( 25 | `%c ${componentName.toUpperCase()} %c ${version} ${process.env.NODE_ENV}`, 26 | "color: orange; font-weight: bold; background: black", 27 | "color: white; font-weight: bold; background: dimgray" 28 | ); 29 | 30 | export class PlotlyGraph extends HTMLElement { 31 | contentEl: Plotly.PlotlyHTMLElement & { 32 | data: (Plotly.PlotData & { entity: string })[]; 33 | layout: Plotly.Layout; 34 | }; 35 | errorMsgEl: HTMLElement; 36 | cardEl: HTMLElement; 37 | resetButtonEl: HTMLButtonElement; 38 | titleEl: HTMLElement; 39 | config!: InputConfig; 40 | parsed_config!: Config; 41 | size: { width?: number; height?: number } = {}; 42 | _hass?: HomeAssistant; 43 | isBrowsing = false; 44 | isInternalRelayout = 0; 45 | touchController: TouchController; 46 | configParser = new ConfigParser(); 47 | pausedRendering = false; 48 | handles: { 49 | resizeObserver?: ResizeObserver; 50 | relayoutListener?: EventEmitter; 51 | restyleListener?: EventEmitter; 52 | refreshTimeout?: number; 53 | legendItemClick?: EventEmitter; 54 | legendItemDoubleclick?: EventEmitter; 55 | dataClick?: EventEmitter; 56 | doubleclick?: EventEmitter; 57 | annotationClick?: EventEmitter; 58 | buttonClick?: EventEmitter; 59 | } = {}; 60 | 61 | constructor() { 62 | super(); 63 | if (!isProduction) { 64 | // for dev purposes 65 | // @ts-expect-error 66 | window.plotlyGraphCard = this; 67 | } 68 | const shadow = this.attachShadow({ mode: "open" }); 69 | shadow.innerHTML = ` 70 | 71 | 118 |
119 |
120 | 121 | 122 |
`; 123 | this.errorMsgEl = shadow.querySelector("#error-msg")!; 124 | this.cardEl = shadow.querySelector("ha-card")!; 125 | this.contentEl = shadow.querySelector("div#plotly")!; 126 | this.resetButtonEl = shadow.querySelector("button#reset")!; 127 | this.titleEl = shadow.querySelector("ha-card > #title")!; 128 | insertStyleHack(shadow.querySelector("style")!); 129 | this.contentEl.style.visibility = "hidden"; 130 | this.touchController = new TouchController({ 131 | el: this.contentEl, 132 | onZoomStart: () => { 133 | this.pausedRendering = true; 134 | }, 135 | onZoomEnd: () => { 136 | this.pausedRendering = false; 137 | this.plot({ should_fetch: true }); 138 | }, 139 | }); 140 | this.withoutRelayout(() => Plotly.newPlot(this.contentEl, [], {})); 141 | } 142 | 143 | connectedCallback() { 144 | const updateCardSize = async () => { 145 | const width = this.cardEl.offsetWidth; 146 | this.contentEl.style.position = "absolute"; 147 | const height = this.cardEl.offsetHeight; 148 | this.contentEl.style.position = ""; 149 | this.size = { width }; 150 | if (height > 100) { 151 | // Panel view type has the cards covering 100% of the height of the window. 152 | // Masonry lets the cards grow by themselves. 153 | // if height > 100 ==> Panel ==> use available height 154 | // else ==> Mansonry ==> let the height be determined by defaults 155 | this.size.height = height - this.titleEl.offsetHeight; 156 | } 157 | this.plot({ should_fetch: false }); 158 | }; 159 | this.handles.resizeObserver = new ResizeObserver(updateCardSize); 160 | this.handles.resizeObserver.observe(this.cardEl); 161 | 162 | updateCardSize(); 163 | this.handles.relayoutListener = this.contentEl.on( 164 | "plotly_relayout", 165 | this.onRelayout 166 | )!; 167 | this.handles.restyleListener = this.contentEl.on( 168 | "plotly_restyle", 169 | this.onRestyle 170 | )!; 171 | this.handles.legendItemClick = this.contentEl.on( 172 | "plotly_legendclick", 173 | this.onLegendItemClick 174 | )!; 175 | this.handles.legendItemDoubleclick = this.contentEl.on( 176 | "plotly_legenddoubleclick", 177 | this.onLegendItemDoubleclick 178 | )!; 179 | this.handles.doubleclick = this.contentEl.on( 180 | "plotly_doubleclick", 181 | this.onDoubleclick 182 | )!; 183 | this.handles.annotationClick = this.contentEl.on( 184 | "plotly_clickannotation", 185 | this.onAnnotationClick 186 | )!; 187 | this.handles.buttonClick = this.contentEl.on( 188 | // @ts-ignore Not properly typed in @types/plotly.js 189 | "plotly_buttonclicked", 190 | this.onButtonClick 191 | )!; 192 | this.resetButtonEl.addEventListener("click", this.exitBrowsingMode); 193 | this.touchController.connect(); 194 | this.plot({ should_fetch: true }); 195 | } 196 | 197 | disconnectedCallback() { 198 | this.handles.resizeObserver?.disconnect(); 199 | this.handles.relayoutListener?.off("plotly_relayout", this.onRelayout); 200 | this.handles.restyleListener?.off("plotly_restyle", this.onRestyle); 201 | this.handles.legendItemClick?.off( 202 | "plotly_legendclick", 203 | this.onLegendItemClick 204 | ); 205 | this.handles.legendItemDoubleclick?.off( 206 | "plotly_legenddoubleclick", 207 | this.onLegendItemDoubleclick 208 | ); 209 | this.handles.dataClick?.off("plotly_click", this.onDataClick); 210 | this.handles.doubleclick?.off("plotly_doubleclick", this.onDoubleclick); 211 | this.handles.annotationClick?.off("plotly_clickannotation", this.onAnnotationClick); 212 | this.handles.buttonClick?.off("plotly_buttonclicked", this.onButtonClick); 213 | clearTimeout(this.handles.refreshTimeout!); 214 | this.resetButtonEl.removeEventListener("click", this.exitBrowsingMode); 215 | this.touchController.disconnect(); 216 | } 217 | 218 | get hass() { 219 | return this._hass; 220 | } 221 | set hass(hass) { 222 | if (!hass) { 223 | // shouldn't happen, this is only to let typescript know hass != undefined 224 | return; 225 | } 226 | if (this.parsed_config?.refresh_interval === "auto") { 227 | let shouldPlot = false; 228 | let should_fetch = false; 229 | for (const entity of this.parsed_config.entities) { 230 | const state = hass.states[entity.entity]; 231 | const oldState = this._hass?.states[entity.entity]; 232 | if (state && oldState !== state) { 233 | shouldPlot = true; 234 | const start = new Date(oldState?.last_updated || state.last_updated); 235 | const end = new Date(state.last_updated); 236 | const range: [number, number] = [+start, +end]; 237 | let shouldAddToCache = false; 238 | if (isEntityIdAttrConfig(entity)) { 239 | shouldAddToCache = true; 240 | } else if (isEntityIdStateConfig(entity)) { 241 | shouldAddToCache = true; 242 | } else if (isEntityIdStatisticsConfig(entity)) { 243 | should_fetch = true; 244 | } 245 | 246 | if (shouldAddToCache) { 247 | this.configParser.cache.add( 248 | entity, 249 | [{ state, x: new Date(end), y: null }], 250 | range 251 | ); 252 | } 253 | } 254 | } 255 | if (shouldPlot) { 256 | this.plot({ should_fetch }, 500); 257 | } 258 | } 259 | this._hass = hass; 260 | } 261 | 262 | async withoutRelayout(fn: Function) { 263 | this.isInternalRelayout++; 264 | await fn(); 265 | this.isInternalRelayout--; 266 | } 267 | 268 | getVisibleRange() { 269 | // TODO: if the x axis is not there, or is not time, don't fetch & replot 270 | return this.contentEl.layout.xaxis?.range?.map((date) => { 271 | // if autoscale is used after scrolling, plotly returns the dates as timestamps (numbers) instead of iso strings 272 | if (Number.isFinite(date)) return date; 273 | if (date.startsWith("-")) { 274 | /* 275 | The function parseISO can't handle negative dates. 276 | To work around that, I'm parsing it without the minus, and then manually calculating the timestamp from that. 277 | The arithmetic has a twist because timestamps start on 1970 and not on year zero, 278 | so the distance to a the year zero has to be calculated by subtracting the "zero year" timestamp. 279 | positive_date = -date (which is negative) 280 | timestamp = (year 0) - (time from year 0) 281 | timestamp = (year 0) - (positive_date - year 0) 282 | timestamp = 2 * (year 0) - positive_date 283 | timestamp = 2 * (year 0) - (-date) 284 | */ 285 | return ( 286 | 2 * +parseISO("0000-01-01 00:00:00.000") - +parseISO(date.slice(1)) 287 | ); 288 | } 289 | return +parseISO(date); 290 | }); 291 | } 292 | enterBrowsingMode = () => { 293 | this.isBrowsing = true; 294 | this.resetButtonEl.classList.remove("hidden"); 295 | }; 296 | exitBrowsingMode = async () => { 297 | this.isBrowsing = false; 298 | this.resetButtonEl.classList.add("hidden"); 299 | this.withoutRelayout(async () => { 300 | this.configParser.resetObservedRange(); 301 | await this.plot({ should_fetch: true }); 302 | }); 303 | }; 304 | onLegendItemClick = ({ curveNumber, ...rest }) => { 305 | return this.parsed_config.entities[curveNumber].on_legend_click({ 306 | curveNumber, 307 | ...rest, 308 | }); 309 | }; 310 | onLegendItemDoubleclick = ({ curveNumber, ...rest }) => { 311 | return this.parsed_config.entities[curveNumber].on_legend_dblclick({ 312 | curveNumber, 313 | ...rest, 314 | }); 315 | }; 316 | onDataClick = ({ points, ...rest }) => { 317 | return this.parsed_config.entities[points[0].curveNumber].on_click({ 318 | points, 319 | ...rest, 320 | }); 321 | }; 322 | onDoubleclick = () => { 323 | return this.parsed_config.on_dblclick(); 324 | }; 325 | onAnnotationClick = ({ annotation, ...rest }) => { 326 | if (annotation.on_click) { 327 | return annotation.on_click({ annotation, ...rest }); 328 | } 329 | return true; 330 | }; 331 | onButtonClick = ({ button, ...rest }) => { 332 | if (button._input.on_click) { 333 | return button._input.on_click({ button, ...rest }); 334 | } 335 | return true; 336 | }; 337 | onRestyle = async () => { 338 | // trace visibility changed, fetch missing traces 339 | if (this.isInternalRelayout) return; 340 | this.enterBrowsingMode(); 341 | await this.plot({ should_fetch: true }); 342 | }; 343 | onRelayout = async () => { 344 | // user panned/zoomed 345 | if (this.isInternalRelayout) return; 346 | this.enterBrowsingMode(); 347 | await this.plot({ should_fetch: true }); 348 | }; 349 | 350 | // The user supplied configuration. Throw an exception and Lovelace will 351 | // render an error card. 352 | async setConfig(config: InputConfig) { 353 | const was = this.config; 354 | this.config = config; 355 | const is = this.config; 356 | this.touchController.isEnabled = !is.disable_pinch_to_zoom; 357 | this.exitBrowsingMode(); 358 | } 359 | getCSSVars() { 360 | const styles = window.getComputedStyle(this.contentEl); 361 | let haTheme = { 362 | "card-background-color": "red", 363 | "primary-background-color": "red", 364 | "primary-color": "red", 365 | "primary-text-color": "red", 366 | "secondary-text-color": "red", 367 | }; 368 | return mapValues(haTheme, (_, key) => styles.getPropertyValue("--" + key)); 369 | } 370 | fetchScheduled = false; 371 | plot = async ( 372 | { should_fetch }: { should_fetch: boolean }, 373 | delay?: number 374 | ) => { 375 | if (should_fetch) this.fetchScheduled = true; 376 | await this._plot(delay); 377 | }; 378 | _plot = debounce(async () => { 379 | if (this.pausedRendering) return; 380 | const should_fetch = this.fetchScheduled; 381 | this.fetchScheduled = false; 382 | let i = 0; 383 | while (!(this.config && this.hass && this.isConnected)) { 384 | if (i++ > 50) throw new Error("Card didn't load"); 385 | console.log("waiting for loading"); 386 | await sleep(100); 387 | } 388 | const fetch_mask = this.contentEl.data.map( 389 | ({ visible }) => should_fetch && visible !== "legendonly" 390 | ); 391 | const uirevision = this.isBrowsing 392 | ? this.contentEl.layout?.uirevision || 0 393 | : Math.random(); 394 | const yaml = merge( 395 | {}, 396 | this.config, 397 | { 398 | layout: { 399 | ...this.size, 400 | ...{ uirevision }, 401 | }, 402 | fetch_mask, 403 | }, 404 | this.isBrowsing ? { visible_range: this.getVisibleRange() } : {}, 405 | 406 | this.config 407 | ); 408 | const { errors, parsed } = await this.configParser.update({ 409 | yaml, 410 | hass: this.hass, 411 | css_vars: this.getCSSVars(), 412 | }); 413 | this.errorMsgEl.style.display = errors.length ? "block" : "none"; 414 | this.errorMsgEl.innerHTML = errors 415 | .map((e) => "" + (e || "See devtools console") + "") 416 | .join("\n
\n"); 417 | this.parsed_config = parsed; 418 | 419 | const { 420 | entities, 421 | layout, 422 | config, 423 | refresh_interval, 424 | autorange_after_scroll, 425 | } = this.parsed_config; 426 | clearTimeout(this.handles.refreshTimeout!); 427 | if (refresh_interval !== "auto" && refresh_interval > 0) { 428 | this.handles.refreshTimeout = window.setTimeout( 429 | () => this.plot({ should_fetch: true }), 430 | refresh_interval * 1000 431 | ); 432 | } 433 | this.titleEl.innerText = this.parsed_config.title || ""; 434 | if (layout.paper_bgcolor) { 435 | this.titleEl.style.background = layout.paper_bgcolor as string; 436 | } 437 | await this.withoutRelayout(async () => { 438 | await Plotly.react(this.contentEl, entities, layout, config); 439 | if (autorange_after_scroll) { 440 | await Plotly.relayout(this.contentEl, { 441 | "yaxis.autorange": true, 442 | }); 443 | } 444 | this.contentEl.style.visibility = ""; 445 | }); 446 | this.handles.dataClick?.off("plotly_click", this.onDataClick)!; 447 | this.handles.dataClick = this.contentEl.on( 448 | "plotly_click", 449 | this.onDataClick 450 | )!; 451 | }); 452 | // The height of your card. Home Assistant uses this to automatically 453 | // distribute all cards over the available columns. 454 | getCardSize() { 455 | return 3; 456 | } 457 | static getStubConfig() { 458 | return { 459 | entities: [{ entity: "sun.sun" }], 460 | hours_to_show: 24, 461 | refresh_interval: 10, 462 | }; 463 | } 464 | static async getConfigElement() { 465 | const { createCardElement } = await (window as any).loadCardHelpers(); 466 | 467 | const historyGraphCard = createCardElement({ 468 | type: "history-graph", 469 | ...this.getStubConfig(), 470 | }); 471 | while (!historyGraphCard.constructor.getConfigElement) await sleep(100); 472 | return historyGraphCard.constructor.getConfigElement(); 473 | } 474 | } 475 | //@ts-expect-error 476 | window.customCards = window.customCards || []; 477 | //@ts-expect-error 478 | window.customCards.push({ 479 | type: componentName, 480 | name: "Plotly Graph Card", 481 | preview: true, // Optional - defaults to false 482 | description: "Plotly in HA", // Optional 483 | }); 484 | 485 | customElements.define(componentName, PlotlyGraph); 486 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | [!["Buy Me A Coffee"](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://www.buymeacoffee.com/dbuezas) 2 | [![hacs_badge](https://img.shields.io/badge/HACS-Custom-41BDF5.svg?style=for-the-badge)](https://github.com/hacs/integration) 3 | 4 | # Plotly Graph Card 5 | 6 | 7 | 8 | 9 |
10 |
11 | 12 | 13 | image 14 | 15 |
16 |
17 | 18 | image 19 | 20 | 21 | 22 |
23 | 24 | ## [Post in HomeAssistant community forum](https://community.home-assistant.io/t/plotly-interactive-graph-card/347746) 25 | 26 | You may find some extra info there in this link 27 | 28 | ## [Index of examples with images](./discussion-index.md) 29 | 30 | You can browse this list and find yamls by looking at images 31 | 32 | Created with this [quick and dirty script](./discussion-index.mjs) 33 | 34 | ## More yaml examples 35 | 36 | Find more advanced examples in [Show & Tell](https://github.com/dbuezas/lovelace-plotly-graph-card/discussions/categories/show-and-tell) 37 | 38 | ## Yaml syntax validatoin 39 | 40 | Web app to assist you with syntax validation and autocomplete: [Plotly graph card yaml editor](https://dbuezas.github.io/lovelace-plotly-graph-card/) 41 | 42 | image 43 | 44 | ## Installation 45 | 46 | ### Via Home Assistant Community Store (Recommended) 47 | 48 | 1. Install [HACS](https://hacs.xyz/docs/configuration/basic) 49 | 2. Search & Install `Plotly Graph Card`. 50 | 51 | ### Manually 52 | 53 | 1. Go to [Releases](https://github.com/dbuezas/lovelace-plotly-graph-card/releases) 54 | 2. Download `plotly-graph-card.js` and copy it to your Home Assistant config dir as `/www/plotly-graph-card.js` 55 | 3. Add a resource to your dashboard configuration. There are two ways: 56 | 1. **Using UI**: `Settings` → `Dashboards` → `More Options icon` → `Resources` → `Add Resource` → Set Url as `/local/plotly-graph-card.js` → Set Resource type as `JavaScript Module`. 57 | _Note: If you do not see the Resources menu, you will need to enable Advanced Mode in your User Profile_ 58 | 2. **Using YAML**: Add following code to lovelace section. 59 | ```resources: 60 | - url: /local/plotly-graph-card.js 61 | type: module 62 | ``` 63 | 64 | ## Card Config 65 | 66 | Visual Config editor available for Basic Configs (\*) 67 | 68 | ```yaml 69 | type: custom:plotly-graph 70 | entities: 71 | - sensor.monthly_internet_energy 72 | - sensor.monthly_teig_energy 73 | - sensor.monthly_office_energy 74 | - sensor.monthly_waschtrockner_energy 75 | hours_to_show: 24 76 | refresh_interval: 10 77 | ``` 78 | 79 | (\*) I'm reusing the editor of the standard History Card. Cheap, yes, but it works fine. Use yaml for advanced functionality 80 | 81 | ## Advanced 82 | 83 | ### Filling, line width, color 84 | 85 | ![](docs/resources/example1.png) 86 | 87 | ```yaml 88 | type: custom:plotly-graph 89 | entities: 90 | - entity: sensor.office_plug_wattage 91 | # see examples: https://plotly.com/javascript/line-and-scatter/ 92 | # see full API: https://plotly.com/javascript/reference/scatter/#scatter 93 | - entity: sensor.freezer_plug_power 94 | fill: tozeroy 95 | line: 96 | color: red 97 | dash: dot 98 | width: 1 99 | 100 | layout: 101 | plot_bgcolor: lightgray 102 | height: 400 103 | config: 104 | scrollZoom: false 105 | 106 | hours_to_show: 1h 107 | refresh_interval: 10 # in seconds 108 | ``` 109 | 110 | ### Range Selector buttons 111 | 112 | ![](docs/resources/rangeselector.apng) 113 | 114 | ```yaml 115 | type: custom:plotly-graph 116 | entities: 117 | - entity: sensor.temperature 118 | refresh_interval: 10 119 | hours_to_show: 12h 120 | layout: 121 | xaxis: 122 | rangeselector: 123 | # see examples: https://plotly.com/javascript/range-slider/ 124 | # see API: https://plotly.com/javascript/reference/layout/xaxis/#layout-xaxis-rangeselector 125 | "y": 1.2 126 | buttons: 127 | - count: 1 128 | step: minute 129 | - count: 1 130 | step: hour 131 | - count: 12 132 | step: hour 133 | - count: 1 134 | step: day 135 | - count: 7 136 | step: day 137 | ``` 138 | 139 | See also: [autorange_after_scroll](#autorange_after_scroll) 140 | 141 | See also: [Custom buttons](https://github.com/dbuezas/lovelace-plotly-graph-card/discussions/231#discussioncomment-4869001) 142 | 143 | ![btns](https://user-images.githubusercontent.com/777196/216764329-94b9cd7e-fee9-439b-9134-95b7be626592.gif) 144 | 145 | ## Features 146 | 147 | - Anything you can do with in plotlyjs except maps 148 | - Zoom / Pan, etc. 149 | - Data is loaded on demand 150 | - Axes are automatically configured based on the units of each trace 151 | - Basic configuration compatible with the History Card 152 | 153 | Get ideas from all charts in here https://plotly.com/javascript/ 154 | 155 | ## Entities: 156 | 157 | - `entities` translates to the `data` argument in PlotlyJS 158 | 159 | - each `entity` will be translated to a trace inside the data array. 160 | - `x` (states) and `y` (timestamps of stored states) 161 | - you can add any attribute that works in a plotly trace 162 | - see https://plotly.com/javascript/reference/scatter/#scatter-line for more 163 | 164 | ```yaml 165 | type: custom:plotly-graph 166 | entities: 167 | - entity: sensor.temperature 168 | - entity: sensor.humidity 169 | ``` 170 | 171 | Alternatively: 172 | 173 | ```yaml 174 | type: custom:plotly-graph 175 | entities: 176 | - sensor.temperature 177 | - sensor.humidity 178 | ``` 179 | 180 | ## Color schemes 181 | 182 | Changes default line colors. 183 | See more here: https://github.com/dbuezas/lovelace-plotly-graph-card/blob/master/src/parse-config/parse-color-scheme.ts 184 | 185 | ```yaml 186 | type: custom:plotly-graph 187 | entities: 188 | - sensor.temperature1 189 | - sensor.temperature2 190 | color_scheme: dutch_field 191 | # or use numbers instead 0 to 24 available: 192 | # color_scheme: 1 193 | # or pass your color scheme 194 | # color_scheme: ["#1b9e77","#d95f02","#7570b3","#e7298a","#66a61e","#e6ab02","#a6761d","red"] 195 | ``` 196 | 197 | ### Attribute values 198 | 199 | Plot the attributes of an entity 200 | 201 | ```yaml 202 | type: custom:plotly-graph 203 | entities: 204 | - entity: climate.living 205 | attribute: temperature 206 | - entity: climate.kitchen 207 | attribute: temperature 208 | ``` 209 | 210 | ### Statistics support 211 | 212 | Fetch and plot long-term statistics of an entity 213 | 214 | #### for entities with state_class=measurement (normal sensors, like temperature) 215 | 216 | ```yaml 217 | type: custom:plotly-graph 218 | entities: 219 | - entity: sensor.temperature 220 | statistic: max # `min`, `mean` of `max` 221 | period: 5minute # `5minute`, `hour`, `day`, `week`, `month`, `auto` # `auto` varies the period depending on the zoom level 222 | ``` 223 | 224 | #### for entities with state_class=total (such as utility meters) 225 | 226 | ```yaml 227 | type: custom:plotly-graph 228 | entities: 229 | - entity: sensor.temperature 230 | statistic: state # `state` or `sum` 231 | period: 5minute # `5minute`, `hour`, `day`, `week`, `month`, `auto` # `auto` varies the period depending on the zoom level 232 | ``` 233 | 234 | #### automatic period 235 | 236 | The option `auto` makes the period relative to the currently visible time range. It picks the longest period, such that there are at least 100 datapoints in screen. 237 | 238 | ```yaml 239 | type: custom:plotly-graph 240 | entities: 241 | - entity: sensor.temperature 242 | statistic: mean 243 | period: auto 244 | ``` 245 | 246 | It is equivalent to writing: 247 | 248 | ```yaml 249 | type: custom:plotly-graph 250 | entities: 251 | - entity: sensor.temperature 252 | statistic: mean 253 | period: 254 | 0m: 5minute 255 | 100h: hour 256 | 100d: day 257 | 100w: week 258 | 100M: month # note uppercase M for month. Lowercase are minutes 259 | ``` 260 | 261 | #### step function for auto period 262 | 263 | ```yaml 264 | type: custom:plotly-graph 265 | entities: 266 | - entity: sensor.temperature 267 | statistic: mean 268 | period: 269 | 0s: 5minute 270 | 24h: hour # when the visible range is ≥ 1 day, use the `hour` period 271 | 7d: day # from 7 days on, use `day` 272 | 6M: week # from 6 months on, use weeks. Note Uppercase M! (lower case m means minutes) 273 | 1y: month # from 1 year on, use `month 274 | ``` 275 | 276 | Note that `5minute` period statistics are limited in time as normal recorder history is, contrary to other periods which keep data for years. 277 | 278 | ## show_value: 279 | 280 | Shows the value of the last datapoint as text in a scatter plot. 281 | 282 | > Warning: don't use it with bar charts, it will only add an extra bar and no text 283 | 284 | Examples: 285 | 286 | ```yaml 287 | type: custom:plotly-graph 288 | entities: 289 | - entity: sensor.temperature 290 | show_value: true 291 | ``` 292 | 293 | Often one wants this to be the case for all entities 294 | 295 | ```yaml 296 | defaults: 297 | entity: 298 | show_value: true 299 | ``` 300 | 301 | If you want to make extra room for the value, you can either increase the right margin of the whole plot like this: 302 | 303 | ```yaml 304 | layout: 305 | margin: 306 | r: 100 307 | ``` 308 | 309 | Or make space inside the the plot like this: 310 | 311 | ```yaml 312 | time_offset: 3h 313 | ``` 314 | 315 | ## Offsets 316 | 317 | Offsets are useful to shift data in the temporal axis. For example, if you have a sensor that reports the forecasted temperature 3 hours from now, it means that the current value should be plotted in the future. With the `time_offset` attribute you can shift the data so it is placed in the correct position. 318 | Another possible use is to compare past data with the current one. For example, you can plot yesterday's temperature and the current one on top of each other. 319 | 320 | The `time_offset` flag can be specified in two places. 321 | **1)** When used at the top level of the configuration, it specifies how much "future" the graph shows by default. For example, if `hours_to_show` is 16 and `time_offset` is 3h, the graph shows the past 13 hours (16-3) plus the next 3 hours. 322 | **2)** When used at the trace level, it offsets the trace by the specified amount. 323 | 324 | ```yaml 325 | type: custom:plotly-graph 326 | hours_to_show: 16 327 | time_offset: 3h 328 | entities: 329 | - entity: sensor.current_temperature 330 | line: 331 | width: 3 332 | color: orange 333 | - entity: sensor.current_temperature 334 | name: Temperature yesterday 335 | time_offset: 1d 336 | line: 337 | width: 1 338 | dash: dot 339 | color: orange 340 | - entity: sensor.temperature_12h_forecast 341 | time_offset: 12h 342 | name: Forecast temperature 343 | line: 344 | width: 1 345 | dash: dot 346 | color: grey 347 | ``` 348 | 349 | ![Graph with offsets](docs/resources/offset-temperature.png) 350 | 351 | ### Now line 352 | 353 | When using offsets, it is useful to have a line that indicates the current time. This can be done by using a universal function that returns a line with the current time as x value and 0 and 1 as y values. The line is then hidden from the legend. 354 | 355 | ```yaml 356 | type: custom:plotly-graph 357 | hours_to_show: 6h 358 | time_offset: 3h 359 | entities: 360 | - entity: sensor.forecast_temperature 361 | yaxis: y1 362 | time_offset: 3h 363 | - entity: "" 364 | name: Now 365 | yaxis: y9 366 | showlegend: false 367 | line: 368 | width: 1 369 | dash: dot 370 | color: deepskyblue 371 | x: $ex [Date.now(), Date.now()] 372 | y: [0, 1] 373 | layout: 374 | yaxis9: 375 | visible: false 376 | fixedrange: true 377 | ``` 378 | 379 | ![Graph with offsets and now-line](docs/resources/offset-nowline.png) 380 | 381 | ## Duration 382 | 383 | Whenever a time duration can be specified, this is the notation to use: 384 | 385 | | Unit | Suffix | Notes | 386 | | ------------ | ------ | -------- | 387 | | Milliseconds | `ms` | | 388 | | Seconds | `s` | | 389 | | Minutes | `m` | | 390 | | Hours | `h` | | 391 | | Days | `d` | | 392 | | Weeks | `w` | | 393 | | Months | `M` | 30 days | 394 | | Years | `y` | 365 days | 395 | 396 | Example: 397 | 398 | ```yaml 399 | time_offset: 3h 400 | ``` 401 | 402 | ## Extra entity attributes: 403 | 404 | ```yaml 405 | type: custom:plotly-graph 406 | entities: 407 | - entity: sensor.temperature_in_celsius 408 | name: living temperature in Farenheit # Overrides the entity name 409 | unit_of_measurement: °F # Overrides the unit 410 | show_value: true # shows the last value as text 411 | customdata: | 412 | $fn ({states}) => 413 | states.map( () => ({ extra_attr: "hello" }) ) 414 | # customdata is array with the same number of values as x axis (states) 415 | # use statistics instead of states if entity is based on statistic 416 | texttemplate: >- # custom format for show_value 417 | %{y}%{customdata.extra_attr}
418 | # to show only 2 decimals: "%{y:.2f}" 419 | # see more here: https://plotly.com/javascript/hover-text-and-formatting/ 420 | # only x, y, customdata are available as %{} template 421 | 422 | hovertemplate: | # custom format for hover text using entity properites name and unit_of_measurement 423 | $fn ({ getFromConfig }) => 424 | ` ${getFromConfig(".name")}
425 | %{x}
426 | %{y}${getFromConfig(".unit_of_measurement")} 427 | ` # removes text on the side of the tooltip (it otherwise defaults to the entity name) 428 | ``` 429 | 430 | ### Extend_to_present 431 | 432 | The boolean `extend_to_present` will take the last known datapoint and "expand" it to the present by creating a duplicate and setting its date to `now`. 433 | This is useful to make the plot look fuller. 434 | It's recommended to turn it off when using `offset`s, or when setting the mode of the trace to `markers`. 435 | Defaults to `true` for state history, and `false` for statistics. 436 | 437 | ```yaml 438 | type: custom:plotly-graph 439 | entities: 440 | - entity: sensor.weather_24h_forecast 441 | mode: "markers" 442 | extend_to_present: false # true by default for state history 443 | - entity: sensor.actual_temperature 444 | statistics: mean 445 | extend_to_present: true # false by default for statistics 446 | ``` 447 | 448 | ### `filters:` 449 | 450 | Filters are used to process the data before plotting it. Inspired by [ESPHome's sensor filters](https://esphome.io/components/sensor/index.html#sensor-filters). 451 | Filters are applied in order. 452 | 453 | ```yaml 454 | type: custom:plotly-graph 455 | entities: 456 | - entity: sensor.temperature_in_celsius 457 | filters: 458 | - store_var: myVar # stores the datapoints inside `vars.myVar` 459 | - load_var: myVar # loads the datapoints from `vars.myVar` 460 | 461 | # The filters below will only be applied to numeric values. Missing (unavailable) and non-numerics will be left untouched 462 | - add: 5 # adds 5 to each datapoint 463 | - multiply: 2 # multiplies each datapoint by 2 464 | - calibrate_linear: 465 | # Left of the arrow are the measurements, right are the expected values. 466 | # The mapping is then approximated through linear regression, and that correction is applied to the data. 467 | - 0.0 -> 0.0 468 | - 40.0 -> 45.0 469 | - 100.0 -> 102.5 470 | - deduplicate_adjacent # removes all adjacent duplicate values. Useful for type: marker+text 471 | - delta # computes the delta between each two consecutive numeric y values. 472 | - derivate: h # computes rate of change per unit of time: h # ms (milisecond), s (second), m (minute), h (hour), d (day), w (week), M (month), y (year) 473 | - integrate: h # computes area under the curve in a specific unit of time using Right hand riemann integration. Same units as the derivative 474 | - integrate: 475 | unit: h # defaults to h 476 | reset_every: 1h # Defaults to 0 (never reset). Any duration unit (ms, s, m, h, d, w, M, y). 477 | offset: 30m # defaults to 0. Resets happen 30m later 478 | 479 | - map_y_numbers: Math.sqrt(y + 10*100) # map the y coordinate of each datapoint. Same available variables as for `map_y` 480 | # In the filters below, missing and non numeric datapoints will be discarded 481 | - sliding_window_moving_average: # best for smoothing 482 | # default parameters: 483 | window_size: 10 484 | extended: false # when true, smaller window sizes are used on the extremes. 485 | centered: true # compensate for averaging lag by offsetting the x axis by half a window_size 486 | - exponential_moving_average: # good for smoothing 487 | # default parameters: 488 | alpha: 0.1 # between 0 an 1. The lower the alpha, the smoother the trace. 489 | - median: # got to remove outliers 490 | # default parameters: 491 | window_size: 10 492 | extended: false 493 | centered: true 494 | - trendline # converts the data to a linear trendline // TODO: force line.shape = linear 495 | - trendline: linear # defaults to no forecast, no formula, no error squared 496 | - trendline: 497 | type: polynomial # linear, polynomial, power, exponential, theil_sen, robust_polynomial, fft 498 | forecast: 1d # continue trendline after present. Use global time_offset to show beyond present. 499 | degree: 3 # only appliable to polynomial regression and fft. 500 | show_formula: true 501 | show_r2: true 502 | # The filters below receive all datapoints as they come from home assistant. Y values are strings or null (unless previously mapped to numbers or any other type) 503 | - map_y: 'y === "heat" ? 1 : 0' # map the y values of each datapoint. Variables `i` (index), `x`, `y`, `state`, `statistic`, `xs`, `ys`, `states`, `statistics`, `meta`, `vars` and `hass` are in scope. The outer quoutes are there because yaml doesn't like colons in strings without quoutes. 504 | - map_x: new Date(+x + 1000) # map the x coordinate (javascript date object) of each datapoint. Same variables as map_y are in scope 505 | - fn: |- # arbitrary function. Only the keys that are returned are replaced. Returning null or undefined, leaves the data unchanged (useful ) 506 | ({xs, ys, vars, meta, states, statistics, hass}) => { 507 | # either statistics or states will be available, depending on if "statistics" are fetched or not 508 | # attributes will be available inside states only if an attribute is picked in the trace 509 | return { 510 | ys: states.map(state => +state?.attributes?.current_temperature - state?.attributes?.target_temperature + hass.states["sensor.temperature"].state, 511 | meta: { unit_of_measurement: "delta" } 512 | }; 513 | }, 514 | - resample: 5m # Rebuilds data so that the timestamps in xs are exact multiples of the specified interval, and without gaps. The parameter is the length of the interval and defaults to 5 minutes (see #duration for the format). This is useful when combining data from multiple entities, as the index of each datapoint will correspond to the same instant of time across them. 515 | - filter: y !== null && +y > 0 && x > new Date(Date.now()-1000*60*60) # filter out datapoints for which this returns false. Also filters from xs, states and statistics. Same variables as map_y are in scope 516 | - force_numeric # converts number-lookinig-strings to actual js numbers and removes the rest. Any filters used after this one will receive numbers, not strings or nulls. Also removes respective elements from xs, states and statistics parameters 517 | ``` 518 | 519 | #### Examples 520 | 521 | ##### Celcious to farenheit 522 | 523 | ```yaml 524 | - entity: sensor.wintergarten_clima_temperature 525 | unit_of_measurement: °F 526 | filters: # °F = °C×(9/5)+32 527 | - multiply: 1.8 528 | - add: 32 529 | ``` 530 | 531 | alternatively, 532 | 533 | ```yaml 534 | - entity: sensor.wintergarten_clima_temperature 535 | unit_of_measurement: °F 536 | filters: # °F = °C×(9/5)+32 537 | - map_y_numbers: y * 9/5 + 32 538 | ``` 539 | 540 | ##### Energy from power 541 | 542 | ```yaml 543 | - entity: sensor.fridge_power 544 | filters: 545 | - integrate: h # resulting unit_of_measurement will be Wh (watts hour) 546 | ``` 547 | 548 | ##### Using state attributes 549 | 550 | ```yaml 551 | - entity: climate.loungetrv_climate 552 | attribute: current_temperature # an attribute must be set to ensure attributes are fetched. 553 | filters: 554 | - map_y_numbers: | 555 | state.state === "heat" ? state.attributes.current_temperature : 0 556 | ``` 557 | 558 | or alternatively, 559 | 560 | ```yaml 561 | - map_y_numbers: 'state.state === "heat" ? y : 0' 562 | ``` 563 | 564 | or alternatively, 565 | 566 | ```yaml 567 | - map_y_numbers: | 568 | { 569 | const isHeat = state.state === "heat"; 570 | return isHeat ? y : 0; 571 | } 572 | ``` 573 | 574 | or alternatively, 575 | 576 | ```yaml 577 | - map_y: | 578 | state?.state === "heat" ? state.attributes?.current_temperature : 0 579 | ``` 580 | 581 | or alternatively, 582 | 583 | ```yaml 584 | - fn: |- 585 | ({ys, states}) => ({ 586 | ys: states.map((state, i) => 587 | state?.state === "heat" ? state.attributes?.current_temperature : 0 588 | ), 589 | }), 590 | ``` 591 | 592 | or alternatively, 593 | 594 | ```yaml 595 | - fn: |- 596 | ({ys, states}) => { 597 | return { 598 | ys: states.map((state, i) => 599 | state?.state === "heat" ? state.attributes?.current_temperature : 0 600 | ), 601 | } 602 | }, 603 | ``` 604 | 605 | #### Advanced 606 | 607 | ##### Debugging 608 | 609 | 1. Open [your browser's devtools console](https://balsamiq.com/support/faqs/browserconsole/) 610 | 2. Use `console.log` or the `debugger` statement to execute your map filter step by step 611 | ```yaml 612 | type: custom:plotly-graph 613 | entities: 614 | - entity: sensor.temperature_in_celsius 615 | statistics: mean 616 | filters: 617 | - fn: console.log # open the devtools console to see the data 618 | - fn: |- 619 | (params) => { 620 | const ys = []; 621 | debugger; 622 | for (let i = 0; i < params.statistics.length; i++){ 623 | ys.pushh(params.statistics.max); // <--- here's the bug 624 | } 625 | return { ys }; 626 | } 627 | ``` 628 | 629 | ##### Using the hass object 630 | 631 | Funcitonal filters receive `hass` (Home Assistant) as parameter, which gives you access to the current states of all entities. 632 | 633 | ```yaml 634 | type: custom:plotly-graph 635 | entities: 636 | - entity: sensor.power_consumption 637 | filters: 638 | - map_y: parseFloat(y) * parseFloat(hass.states['sensor.cost'].state) 639 | ``` 640 | 641 | ##### Using vars 642 | 643 | Compute absolute humidity 644 | 645 | ```yaml 646 | type: custom:plotly-graph 647 | entities: 648 | - entity: sensor.wintergarten_clima_humidity 649 | internal: true 650 | filters: 651 | - resample: 5m # important so the datapoints align in the x axis 652 | - map_y: parseFloat(y) 653 | - store_var: relative_humidity 654 | - entity: sensor.wintergarten_clima_temperature 655 | period: 5minute 656 | name: Absolute Hty 657 | unit_of_measurement: g/m³ 658 | filters: 659 | - resample: 5m 660 | - map_y: parseFloat(y) 661 | - map_y: (6.112 * Math.exp((17.67 * y)/(y+243.5)) * +vars.relative_humidity.ys[i] * 2.1674)/(273.15+y); 662 | ``` 663 | 664 | Compute dew point 665 | 666 | ```yaml 667 | type: custom:plotly-graph 668 | entities: 669 | - entity: sensor.openweathermap_humidity 670 | internal: true 671 | period: 5minute # important so the datapoints align in the x axis. Alternative to the resample filter using statistics 672 | filters: 673 | - map_y: parseFloat(y) 674 | - store_var: relative_humidity 675 | - entity: sensor.openweathermap_temperature 676 | period: 5minute 677 | name: Dew point 678 | filters: 679 | - map_y: parseFloat(y) 680 | - map_y: >- 681 | { 682 | // https://www.omnicalculator.com/physics/dew-point 683 | const a = 17.625; 684 | const b = 243.04; 685 | const T = y; 686 | const RH = vars.relative_humidity.ys[i]; 687 | const α = Math.log(RH/100) + a*T/(b+T); 688 | const Ts = (b * α) / (a - α); 689 | return Ts; 690 | } 691 | hours_to_show: 24 692 | ``` 693 | 694 | ### `internal:` 695 | 696 | setting it to `true` will remove it from the plot, but the data will still be fetch. Useful when the data is only used by a filter in a different trace. Similar to plotly's `visibility: false`, except it internal traces won't use up new yaxes. 697 | 698 | ```yaml 699 | type: custom:plotly-graph 700 | entities: 701 | - entity: sensor.temperature1 702 | internal: true 703 | period: 5minute 704 | filters: 705 | - map_y: parseFloat(y) 706 | - store_var: temp1 707 | - entity: sensor.temperature2 708 | period: 5minute 709 | name: sum of temperatures 710 | filters: 711 | - map_y: parseFloat(y) 712 | - map_y: y + vars.temp1.ys[i] 713 | ``` 714 | 715 | ### Entity click handlers 716 | 717 | When the legend is clicked (or doubleclicked), the trace will be hidden (or showed alone) by default. This behaviour is controlled by [layout-legend-itemclick](https://plotly.com/javascript/reference/layout/#layout-legend-itemclick). 718 | On top of that, a `$fn` function can be used to add custom behaviour. 719 | If a handler returns false, the default behaviour trace toggle behaviour will be disabled, but this will also inhibit the `on_legend_dblclick ` handler. Disable the default behaviour via layout-legend-itemclick instead if you want to use both click and dblclick handlers. 720 | 721 | ```yaml 722 | type: custom:plotly-graph 723 | entities: 724 | - entity: sensor.temperature1 725 | on_legend_click: |- 726 | $fn () => (event_data) => { 727 | event = new Event( "hass-more-info") 728 | event.detail = { entityId: 'sensor.temperature1' }; 729 | document.querySelector('home-assistant').dispatchEvent(event); 730 | return false; // disable trace toggling 731 | } 732 | ``` 733 | 734 | Alternatively, clicking on points of the trace itself. 735 | 736 | ```yaml 737 | type: custom:plotly-graph 738 | entities: 739 | - entity: sensor.temperature1 740 | on_click: |- 741 | $fn () => (event_data) => { 742 | ... 743 | // WARNING: this doesn't work and I don't understand why. Help welcome 744 | } 745 | ``` 746 | 747 | There is also a double click plot handler, it works on the whole plotting area (not points of an entity). Beware that double click also autoscales the plot. 748 | 749 | ```yaml 750 | type: custom:plotly-graph 751 | entities: 752 | - entity: sensor.temperature1 753 | on_dblclick: |- 754 | $fn ({ hass }) => () => { 755 | hass.callService('light', 'turn_on', { 756 | entity_id: 'light.portique_lumiere' 757 | }) 758 | } 759 | ``` 760 | 761 | ## Annotation and button click handlers 762 | 763 | In a similar way, you can respond to clicks on annotations (requiring `captureevents: true`). 764 | 765 | ```yaml 766 | type: custom:plotly-graph 767 | entities: 768 | - entity: sensor.temperature1 769 | layout: 770 | annotations: 771 | - x: 1 772 | xref: paper 773 | "y": 1 774 | yref: paper 775 | showarrow: false 776 | text: "📊" 777 | captureevents: true 778 | on_click: $ex () => { window.location="/history?entity_id=sensor.temperature1"; } 779 | ``` 780 | 781 | Or to clicks on custom update menu buttons. 782 | 783 | ```yaml 784 | type: custom:plotly-graph 785 | entities: 786 | - entity: sensor.temperature1 787 | layout: 788 | updatemenus: 789 | - buttons: 790 | - label: History 791 | method: skip 792 | on_click: $ex () => { window.location="/history?entity_id=sensor.temperature1"; } 793 | showactive: false 794 | type: buttons 795 | x: 1 796 | "y": 1 797 | ``` 798 | 799 | See more in plotly's [official docs](https://plotly.com/javascript/plotlyjs-events) 800 | 801 | ## Universal functions 802 | 803 | Javascript functions allowed everywhere in the yaml. Evaluation is top to bottom and shallow to deep (depth first traversal). 804 | 805 | The returned value will be used as value for the property where it is found. E.g: 806 | 807 | ```js 808 | name: $fn ({ hass }) => hass.states["sensor.garden_temperature"].state 809 | ``` 810 | 811 | or a universal expression `$ex` (the parameters and arrow are added automatically): 812 | 813 | ```js 814 | name: $ex hass.states["sensor.garden_temperature"].state 815 | ``` 816 | 817 | which can also take a block: 818 | 819 | ```js 820 | name: | 821 | $ex { 822 | return hass.states["sensor.garden_temperature"].state 823 | } 824 | ``` 825 | 826 | ### Available parameters: 827 | 828 | Remember you can add a `console.log(the_object_you_want_to_inspect)` and see its content in the devTools console. 829 | 830 | #### Everywhere: 831 | 832 | - `getFromConfig: (path) => value;` Pass a path (e.g `entities.0.name`) and get back its value 833 | - `get: (path) => value;` same as `getFromConfig` 834 | - `hass: HomeAssistant object;` For example: `hass.states["sensor.garden_temperature"].state` to get its current state 835 | - `vars: Record;` You can communicate between functions with this. E.g `vars.temperatures = ys` 836 | - `path: string;` The path of the current function 837 | - `css_vars: HATheme;` The colors set by the active Home Assistant theme (see #ha_theme) 838 | 839 | #### Only inside entities 840 | 841 | - `xs: Date[];` Array of timestamps 842 | - `ys: YValue[];` Array of values of the sensor/attribute/statistic 843 | - `statistics: StatisticValue[];` Array of statistics objects 844 | - `states: HassEntity[];` Array of state objects 845 | - `meta: HassEntity["attributes"];` The current attributes of the sensor 846 | 847 | #### Gotchas 848 | 849 | - The following entity attributes are required for fetching, so if another function needs the entity data it needs to be declared below them. `entity`,`attribute`,`offset`,`statistic`,`period` 850 | - Functions are allowed for those properties (`entity`, `attribute`, ...) but they do not receive entity data as parameters. You can still use the `hass` parameter to get the last state of an entity if you need to. 851 | - Functions cannot return functions for performance reasons. (feature request if you need this) 852 | - Defaults are not applied to the subelements returned by a function. (feature request if you need this) 853 | - You can get other values from the yaml with the `getFromConfig` parameter, but if they are functions they need to be defined before. 854 | - Any function which uses the result of a filter, needs to be placed in the YAML below the filter. For instance, `name: $ex ys.at(-1)` where the filter is modifying `ys`. 855 | - The same is true of consecutive filters - order matters. This is due to the fact that filters are translated internally to function calls, executed in the order they are parsed. 856 | 857 | #### Adding the last value to the entitiy's name 858 | 859 | ```yaml 860 | type: custom:plotly-graph 861 | entities: 862 | - entity: sensor.garden_temperature 863 | name: | 864 | $ex meta.friendly_name + " " + ys[ys.length - 1] 865 | ``` 866 | 867 | #### Sharing data across functions 868 | 869 | ```yaml 870 | type: custom:plotly-graph 871 | entities: 872 | - entity: sensor.garden_temperature 873 | 874 | # the fn attribute has no meaning, it is just a placeholder to put a function there. It can be any name not used by plotly 875 | fn: $ex vars.title = ys[ys.length - 1]; 876 | title: $ex vars.title 877 | ``` 878 | 879 | #### Histograms 880 | 881 | ```yaml 882 | type: custom:plotly-graph 883 | entities: 884 | - entity: sensor.openweathermap_temperature 885 | x: $ex ys 886 | type: histogram 887 | title: Temperature Histogram last 10 days 888 | hours_to_show: 10d 889 | raw_plotly_config: true 890 | layout: 891 | margin: 892 | t: 0 893 | l: 50 894 | b: 40 895 | height: 285 896 | xaxis: 897 | autorange: true 898 | ``` 899 | 900 | #### custom hover text 901 | 902 | ```yaml 903 | type: custom:plotly-graph 904 | title: hovertemplate 905 | entities: 906 | - entity: climate.living 907 | attribute: current_temperature 908 | customdata: | 909 | $fn ({states}) => 910 | states.map( ({state, attributes}) =>({ 911 | ...attributes, 912 | state 913 | }) 914 | ) 915 | hovertemplate: |- 916 |
Mode: %{customdata.state}
917 | Target:%{y}
918 | Current:%{customdata.current_temperature} 919 | 920 | hours_to_show: current_day 921 | ``` 922 | 923 | ## Default trace & axis styling 924 | 925 | default configurations for all entities and all xaxes (e.g xaxis, xaxis2, xaxis3, etc) and yaxes (e.g yaxis, yaxis2, yaxis3, etc). 926 | 927 | ```yaml 928 | type: custom:plotly-graph 929 | entities: 930 | - sensor.temperature1 931 | - sensor.temperature2 932 | defaults: 933 | entity: 934 | fill: tozeroy 935 | line: 936 | width: 2 937 | xaxes: 938 | showgrid: false # Disables vertical gridlines 939 | yaxes: 940 | fixedrange: true # disables vertical zoom & scroll 941 | ``` 942 | 943 | ## layout: 944 | 945 | To define layout aspects, like margins, title, axes names, ... 946 | Anything from https://plotly.com/javascript/reference/layout/. 947 | 948 | ### Home Assistant theming: 949 | 950 | Toggle Home Assistant theme colors: 951 | 952 | - card-background-color 953 | - primary-background-color 954 | - primary-color 955 | - primary-text-color 956 | - secondary-text-color 957 | 958 | ```yaml 959 | type: custom:plotly-graph 960 | entities: 961 | - entity: sensor.temperature_in_celsius 962 | ha_theme: false #defaults to true 963 | ``` 964 | 965 | ### Raw plotly config: 966 | 967 | Toggle all in-built defaults for layout and entitites. Useful when using histograms, 3d plots, etc. 968 | When true, the `x` and `y` properties of the traces won't be automatically filled with entity data, you need to use $fn for that. 969 | 970 | ```yaml 971 | type: custom:plotly-graph 972 | entities: 973 | - entity: sensor.temperature_in_celsius 974 | x: $ex xs 975 | y: $ex ys 976 | raw_plotly_config: true # defaults to false 977 | ``` 978 | 979 | ## config: 980 | 981 | To define general configurations like enabling scroll to zoom, disabling the modebar, etc. 982 | Anything from https://plotly.com/javascript/configuration-options/. 983 | 984 | ## disable_pinch_to_zoom 985 | 986 | ```yaml 987 | disable_pinch_to_zoom: true # defaults to false 988 | ``` 989 | 990 | When true, the custom implementations of pinch-to-zoom and double-tap-drag-to-zooming will be disabled. 991 | 992 | ## hours_to_show: 993 | 994 | How many hours are shown. 995 | Exactly the same as the history card, but more powerful 996 | 997 | ### Fixed Relative Time 998 | 999 | - Decimal values (e.g `hours_to_show: 0.5`) 1000 | - Duration strings (e.g `hours_to_show: 2h`, `3d`, `1w`, `1M`). See [Durations](#Duration) 1001 | 1002 | ### Dynamic Relative Time 1003 | 1004 | Shows the current day, hour, etc from beginning to end. 1005 | The options are: `current_minute`, `current_hour`, `current_day`, `current_week`, `current_month`, `current_quarter`, `current_year` 1006 | It can be combined with the global `time_offset`. 1007 | 1008 | ## autorange_after_scroll: 1009 | 1010 | Removes all data out of the visible range, and autoscales after each replot. 1011 | Particularly useful when combined with [Range Selector Buttons](#Range-Selector-buttons) 1012 | 1013 | ```yaml 1014 | type: custom:plotly-graph 1015 | entities: 1016 | - entity: sensor.garden_temperature 1017 | autorange_after_scroll: true 1018 | ``` 1019 | 1020 | ## refresh_interval: 1021 | 1022 | Update data every `refresh_interval` seconds. 1023 | 1024 | Examples: 1025 | 1026 | ```yaml 1027 | refresh_interval: auto # (default) update automatically when an entity changes its state. 1028 | refresh_interval: 0 # never update. 1029 | refresh_interval: 5 # update every 5 seconds 1030 | ``` 1031 | 1032 | ## localization: 1033 | 1034 | The locale is directly taken from Home Assistant's configuration, but can be overridden like this: 1035 | 1036 | ```yaml 1037 | config: 1038 | locale: ar 1039 | ``` 1040 | 1041 | ** Home Assistant custom Number and Date format will be ignored, only the language determines the locale ** 1042 | 1043 | When using `hours_to_show: current_week`, the "First day of the week" configured in Home Assistant is used 1044 | 1045 | ## Presets 1046 | 1047 | If you find yourself reusing the same card configuration frequently, you can save it as a preset. 1048 | 1049 | ### Setup 1050 | 1051 | Presets are loaded from the global `PlotlyGraphCardPresets` JS object (such that they can be shared across different dashboards). 1052 | The recommended way to add or modify presets is to set up a `plotly_presets.js` script in the `www` subdirectory of your `config` folder. 1053 | ```js 1054 | window.PlotlyGraphCardPresets = { 1055 | // Add your presets here with the following format (or check the examples below) 1056 | // PresetName: { PresetConfiguration } 1057 | }; 1058 | ``` 1059 | To ensure this file is loaded on every dashboard, add the following lines to your `configuration.yaml`. 1060 | ```yaml 1061 | frontend: 1062 | extra_module_url: 1063 | - /local/plotly_presets.js 1064 | ``` 1065 | You might have to clear your browser cache or restart HA for changes to take effect. 1066 | 1067 | ### Examples 1068 | 1069 | The preset configuration should be defined as a JS object instead of the YAML format used by the card. 1070 | Below is an example YAML configuration that is split into several corresponding presets. 1071 | 1072 | 1073 | 1074 | 1075 | 1076 | 1077 | 1095 | 1096 | 1097 | 1098 | 1099 | 1100 | 1130 | 1131 |
YAML configuration
1078 | 1079 | ```yaml 1080 | hours_to_show: current_day 1081 | time_offset: -24h 1082 | defaults: 1083 | entity: 1084 | hovertemplate: | 1085 | $fn ({ get }) => ( 1086 | `%{y:,.1f} ${get('.unit_of_measurement')}${get('.name')}` 1087 | ) 1088 | xaxes: 1089 | showspikes: true 1090 | spikemode: across 1091 | spikethickness: -2 1092 | ``` 1093 | 1094 |
Preset configurations
1101 | 1102 | ```js 1103 | window.PlotlyGraphCardPresets = { 1104 | yesterday: { // Start of preset with name 'yesterday' 1105 | hours_to_show: "current_day", 1106 | time_offset: "-24h", 1107 | }, 1108 | simpleHover: { // Start of preset with name 'simpleHover' 1109 | defaults: { 1110 | entity: { 1111 | hovertemplate: ({get}) => ( 1112 | `%{y:,.1f} ${get('.unit_of_measurement')}${get('.name')}` 1113 | ), 1114 | }, 1115 | }, 1116 | }, 1117 | verticalSpikes: { // Start of preset with name 'verticalSpikes' 1118 | defaults: { 1119 | xaxes: { 1120 | showspikes: true, 1121 | spikemode: "across", 1122 | spikethickness: -2, 1123 | }, 1124 | }, 1125 | }, 1126 | }; 1127 | ``` 1128 | 1129 |
1132 | 1133 | ### Usage 1134 | 1135 | To use your defined templates, simply specify the preset name under the `preset` key. 1136 | You can also specify a list of preset names to combine several of them. 1137 | 1138 | E.g. with the above preset definitions, we can show yesterday's temperatures. 1139 | ```yaml 1140 | type: custom:plotly-graph 1141 | entities: 1142 | - sensor.temperature1 1143 | - sensor.temperature2 1144 | preset: yesterday 1145 | ``` 1146 | 1147 | Or show a simplified hover tooltip together with vertical spikes. 1148 | ```yaml 1149 | type: custom:plotly-graph 1150 | entities: 1151 | - sensor.temperature1 1152 | - sensor.temperature2 1153 | preset: 1154 | - simpleHover 1155 | - verticalSpikes 1156 | ``` 1157 | 1158 | # deprecations: 1159 | 1160 | ### `no_theme` 1161 | 1162 | Renamed to `ha_theme` (inverted logic) in v3.0.0 1163 | 1164 | ### `no_default_layout` 1165 | 1166 | Replaced with more general `raw_plotly_config` in v3.0.0. 1167 | If you were using it, you most likely can delete it and add this to your yaxes defaults: 1168 | 1169 | ```yaml 1170 | defaults: 1171 | yaxes: 1172 | side: left 1173 | overlaying: "y" 1174 | visible: true 1175 | showgrid: true 1176 | ``` 1177 | 1178 | ### `offset` 1179 | 1180 | Renamed to time_offset in v3.0.0 to avoid conflicts with PlotlyJS bar offset configuration. 1181 | 1182 | ### `lambda` 1183 | 1184 | Removed in v3.0.0, use filters instead. There is most likely a filter (or combination) that will give you the same result, but you can also translate an old lambda to a filter like this: 1185 | 1186 | ```yaml 1187 | lambda: | 1188 | (ys,xs) => { 1189 | ... 1190 | return {x: arr_x, y: arr_y}; 1191 | } 1192 | # becomes 1193 | filters: 1194 | - fn: | 1195 | ({ys,xs}) => { 1196 | ... 1197 | return {xs: arr_x, ys: arr_y}; 1198 | } 1199 | ``` 1200 | 1201 | and 1202 | 1203 | ```yaml 1204 | lambda: | 1205 | (ys) => ys.map(y => y+1...etc...) 1206 | # becomes 1207 | filters: 1208 | - map_y: y+1...etc... 1209 | ``` 1210 | 1211 | ### `entities/show_value/right_margin` 1212 | 1213 | Removed in v3.0.0, use `show_value: true` instead and if necessary, set the global `time_offset` or `layout.margins.r` to make extra space to the right. 1214 | 1215 | ### `significant_changes_only` 1216 | 1217 | Removed in v3.0.0, non significant changes are also fetched now. The bandwidth savings weren't worth the issues it created. 1218 | 1219 | ### `minimal_response` 1220 | 1221 | Removed in v3.0.0, if you need access to the attributes use the 'attribute' parameter instead. It doesn't matter which attribute you pick, all of them are still accessible inside filters and universal functions 1222 | 1223 | # Development 1224 | 1225 | - Clone the repo 1226 | - run `npm i` 1227 | - run `npm start` 1228 | - From a dashboard in edit mode, go to `Manage resources` and add `http://127.0.0.1:8000/plotly-graph-card.js` as url with resource type JavaScript 1229 | - ATTENTION: The development card is `type: custom:plotly-graph-dev` (mind the extra `-dev`) 1230 | - Either use Safari or Enable [chrome://flags/#unsafely-treat-insecure-origin-as-secure](chrome://flags/#unsafely-treat-insecure-origin-as-secure) and add your HA address (e.g http://homeassistant.local:8123): Chrome doesn't allow public network resources from requesting private-network resources - unless the public-network resource is secure (HTTPS) and the private-network resource provides appropriate (yet-undefined) CORS headers. More [here](https://stackoverflow.com/questions/66534759/chrome-cors-error-on-request-to-localhost-dev-server-from-remote-site) 1231 | 1232 | # Build 1233 | 1234 | `npm run build` 1235 | 1236 | # Release 1237 | 1238 | - Click on releases/new draft from tag in github 1239 | - The bundle will be built by the CI action thanks to @zanna-37 in #143 1240 | - The version in the artifact will be set from the created tag while building. 1241 | 1242 | # Popularity 1243 | 1244 | ## Star History 1245 | 1246 | [![Star History Chart](https://api.star-history.com/svg?repos=dbuezas/lovelace-plotly-graph-card&type=Date)](https://star-history.com/#dbuezas/lovelace-plotly-graph-card&Date) 1247 | 1248 | --------------------------------------------------------------------------------